System Contracts

Stable contracts. Do not change without updating this document.

The CLI surface is the first half; the daemon, storage and protocol contracts follow it.

Binaries

BinaryCratePurpose
facelockfacelock-cliUnified CLI (daemon, auth, enroll, test, setup, etc.)
facelock-benchfacelock-benchDeveloper benchmark utility; not shipped by current Arch, Debian, RPM, or release-artifact recipes
pam_facelock.sopam-facelockPAM authentication module
facelock-polkit-agentfacelock-polkitPolkit face authentication agent

CLI Subcommands

CommandPurpose
facelock setupInteractive setup wizard (camera, models, inference device, encryption, daemon, enrollment, PAM — the daemon before enrollment, so enrollment and the recognition test run on the transport later authentications use); removes a leftover facelock group from an older install, best-effort (ADR 010)
facelock setup --systemdValidate installed systemd/D-Bus assets, retire exact known legacy copies, reload, verify resolution, and enable the daemon unit. Refused under a non-default --config, before anything is written: the unit runs bare facelock daemon and reads only /etc/facelock/config.toml (see "facelock setup Flag Composition")
facelock setup --pamAlias onto facelock pam add|remove (see "facelock pam" below). Kept, and kept parsing, for every wrapper written against it
facelock setup --pam --allow-sensitiveExplicitly authorize an add to a sensitive PAM service. Does not suppress confirmation and conflicts with --remove
facelock pam addAdd the facelock line to one or more /etc/pam.d/<service> files. Root
facelock pam removeRemove it. Root. Cleans validated Facelock-owned rollback state by default; --keep-backup preserves it
facelock pam remove --allConfig-independent, whole-machine removal of recognized Facelock-owned direct PAM edits beneath compiled roots. Root. Conflicts with --service
facelock pam statusReport whether services carry the line. Reads only, no root — the probe to branch on instead of grepping /etc/pam.d
facelock setup choice flags--camera <PATH|auto>, --models <standard|balanced|high>, --execution-provider <cpu|cuda|rocm|openvino|auto>, --encryption <tpm|keyfile|none|auto>. Precedence: CLI flag > config file > built-in default. Inside the interactive prompt, the wizard's highlighted default comes from ONNX Runtime detection unless the config already names a GPU provider
facelock setup action opt-outs--no-pam, --no-systemd, --no-enroll decline an action outright (and their --pam/--systemd/--enroll counterparts force it). Later flag wins
facelock is-enrolledReport the user's enrollment marker state, a hint rather than proof that face auth is operational. Exit code is the contract; no daemon activation, no camera, no group: it opens the user's 0600 marker under 0711 directories (ADR 010)
facelock capabilitiesReport what this build can do: one capability name per line, or --json for {"version", "capabilities"}. Unprivileged, reads no config, activates no daemon. The feature probe to branch on instead of grepping --help
facelock enrollCapture and store a face
facelock testTest face recognition
facelock listList enrolled face models
facelock remove <id>Remove a specific model by decimal unsigned 32-bit ID, as printed by list; hexadecimal spellings are rejected
facelock clearRemove all models for a user
facelock previewLive camera preview
facelock devicesList V4L2 cameras
facelock statusCheck system status. Root. --json renders the same report as one object a script can parse (see "facelock status Semantics")
facelock config showShow configuration. Bare facelock config is config show
facelock config editOpen the config file in $EDITOR and report validation on save; request restart for listed settings only when old and new config both parse. Root; invalid saved config is retained with a warning and exit 0
facelock daemon runRun the persistent daemon. Bare facelock daemon is daemon run — the form every shipped service unit invokes
facelock daemon restartRequest restart with systemctl restart, falling back to D-Bus Shutdown on any failure. Root; fallback failure does not change exit 0, and --config does not change the service's configuration
facelock auth --user XOne-shot auth (PAM helper). --user is required here and only here; --config is the global flag, not a per-command one
facelock hyprlock enable|disable|statusManage hyprlock lock-screen integration (user, no root); enable accepts --no-icon to skip the cosmetic face glyph
facelock tpm statusTPM status, sealed-key presence and encrypted/plaintext embedding counts. Root, like every tpm verb
facelock tpm encryptEncrypt plaintext embedding rows, not the whole database; refuses active hard device binding. --generate-key only creates/replaces key material and refuses encrypted templates
facelock tpm decryptDecrypt AES embedding rows and legacy TPM blobs into plaintext; does not change configuration or keys and cannot decrypt AAD-bound templates. Conversion is per-row, so failure can leave partial progress
facelock tpm resealRe-seal the TPM AES key under current PCRs (recovery after a firmware/kernel change)
facelock tpm seal-key / unseal-keyMigrate keyfile↔tpm key protection and update the configured method. Require the source key artifact and refuse an existing destination; retain the source artifact and leave embeddings unchanged
facelock tpm unseal-checkRead-only: verify the sealed key still unseals (PCR policy satisfied)
facelock auditView audit log
facelock benchBenchmarks
facelock data purgeRemove retained Facelock state inside the compiled roots. Root, and additionally --allow-destruction. Holds the lifecycle exclusion lease for the duration; reports retained names and never claims secure erasure (see "Fixed-root purge boundary")
facelock data purge --dry-runClassify configured paths and remove nothing. Root, no authorization flag, no lease — a preview must not stop the daemon as a side effect. Does not traverse the roots, and therefore reports no completeness verdict
facelock data purge --leave-activation-barredKeep the daemon stopped and D-Bus activation barred after purging, for a caller that uninstalls next. Conflicts with --dry-run

Where a command goes. A top-level command names a user task and keeps its spelling for the life of the binary. A noun group exists when the noun names a distinct operational domain and owns two or more subcommands. The domains: pam (/etc/pam.d), tpm (the TPM device and the encryption key), hyprlock (hyprlock.conf), daemon (the running service), config (the config file), bench (measurement runs). Facelock's primary objects, meaning face models, cameras, the audit log and the install itself, are reached by top-level commands and never earn a group. Inside a group the second word is spelled the way its domain spells it, verb or noun: tpm seal-key and tpm pcr-baseline follow tpm2-tools, bench cold-auth names a measurement. A new command must fit an existing domain before it may claim a top-level name. Commands named by pam_facelock.so or the service units never move. See ADR 009.

data is the one group that owns a single subcommand, and it is a stated exception rather than drift. The domain is retained state on this machine — distinct from config (the config file) and from the per-user face models the top-level verbs reach. It is spelled as a group because the alternative is worse. The rejected spelling illustrates the ambiguity:

facelock purge

That spelling would sit immediately beside facelock clear, which already means "remove all face models for a user", and the two would differ only in blast radius. Naming the object first makes that difference the first word a reader sees, which on an irreversible command is worth a group that ADR 009's two-subcommand rule would otherwise refuse.

Bare facelock data is a usage error, not an implied purge: unlike daemon and config, whose bare forms are load-bearing invocations, nothing invokes this one and a destructive default would be a trap.

The top-level set is pinned by the TOP_LEVEL_COMMANDS registry in crates/facelock-cli/src/conformance/flags.rs, checked in both directions against Cli::command(): a name in the registry the binary does not offer fails, and a top-level command with no row fails too. Nested verbs are deliberately absent from it — where a verb sits inside its group is that group's business.

CLI Flag Spelling

Flag spelling is a compatibility surface, not a presentation detail: pam_facelock.so spawns facelock auth --user <name> --config <path> byte for byte, and wrapper scripts hard-code the rest. Two things hold it still.

Shared clap arg structs in crates/facelock-cli/src/args.rs (UserArg, ConfirmArg, JsonArg, DryRunArg) are flattened at every site, so a command either offers a flag with the one spelling or does not offer it. cli_flag_conformance in crates/facelock-cli/src/conformance/flags.rs walks the whole command tree, nested subcommands included, and fails on any drift; spending a new short letter means editing its registry on purpose.

The invariants it pins:

  • --user is -u on every command that has it, including auth
  • auth --user stays required — PAM names the subject and it must never fall back to the process owner. Every other --user defaults to the current user
  • --yes is -y and accepts --no-confirm everywhere (it was setup-only)
  • --json and --dry-run take no short letter
  • --config (-c), --quiet (-q) and --verbose (-v) are declared once, global = true, and are accepted on either side of the subcommand name. No command re-declares them. facelock daemon -c X and facelock -c X daemon are equivalent, as are facelock is-enrolled --quiet and facelock --quiet is-enrolled. A non-default --config makes setup --systemd refuse — except --disable, which stays allowed since stopping the packaged unit reads no config file — and routes every backend-using command direct (see "facelock setup Flag Composition" and "Operating Modes")
  • --verbose counts its repeats, one level per -v from the program's own starting level (warn for the CLI, info for daemon run). RUST_LOG outranks it
  • every subcommand has non-empty about text

legacy_invocations_still_parse, alongside it, is a table of real argv — the PAM spawn included — that must keep parsing.

CLI Output Streams

stdout is the answer; stderr is everything else. Every facelock subcommand prints its result — the JSON payload of --json, the rendered table, the state word — on stdout, and only that. Diagnostics (tracing output, whatever RUST_LOG selects, warnings such as the D-Bus fallback notice) go to stderr on every process this repository builds.

This is what makes facelock devices --json | jq . and facelock is-enrolled --json safe to pipe: an integration reading stdout gets the payload whatever the log level, and an operator raising RUST_LOG to debug cannot break a script by doing so. Before this was contract, the subscriber inherited tracing_subscriber's stdout default and a single WARN corrupted the JSON (#149).

An unparseable RUST_LOG is reported at WARN (on stderr) and the built-in filter is used, rather than the value being silently discarded.

Diagnostics default to warn in the CLI and info in the daemon. Someone typing a command reads its prompts and its report on the same terminal these events land on, so the CLI prints warnings and errors and nothing quieter. facelock daemon run keeps info, because the journal is its reader and nothing competes with it there. -v raises the level one step per repeat from whichever of the two this process started at. RUST_LOG outranks both, including when it is the quieter of them: an override a flag can shout over is not an override.

The level governs output volume and nothing else. Exit codes and stdout payloads are identical at every level, so a consumer needs no flag it did not need before. Every degradation an operator has to act on is WARN or above and so survives the default: the D-Bus fallback in backend::select, an ONNX Runtime that would not load, a provider that could not be queried, an unreadable quirks file, an ignored RUST_LOG.

--quiet suppresses informational chatter, and on commands whose stdout is the payload, the payload too; errors, prompts and exit codes are unchanged. A quiet run that fails still says why on stderr and still exits non-zero, and a prompt still asks — a silenced question is a hang, not a quieter program. This is is-enrolled --quiet's rule ("leave only the exit code") generalized to every payload: facelock --quiet devices --json writes nothing on stdout, and the exit code is the answer. list --json and devices --json printed their payload under --quiet before this rule; they no longer do.

The flag is read once, by the two suppressible stdout sinks of the message seam — Terminal::info for human text, message::payload for machine output — so no command implements it and no command can forget it. There is a third stdout sink, Terminal::notice, which --quiet deliberately does not reach: it is for the human lines that must be seen and must stay on stdout: pam add's rollback instructions, the plaintext-embeddings warning, and the context a confirmation needs to be answerable (pam add's preview of the edit, and the orphaned-models warning ahead of setup's delete confirmation). Everything else informational stays on Terminal::info — a notice that did not have to be seen is just an unquietable one. #140 tracks the commands still printing human text directly.

preview --json is the one payload outside this rule: it emits a document per frame until interrupted, so silencing it would leave a command that produces nothing forever.

CLI Machine Output

Every command whose output a script would parse takes --json, and spells it --json. One flag family (the shared JsonArg in crates/facelock-cli/src/args.rs), no short letter, no --output json, no per-command invention. cli_flag_conformance pins both halves: an arg whose help advertises JSON must carry the id json, so a second spelling fails the build instead of shipping.

A command gains --json when it has a named consumer, not to complete a matrix. The coverage list is the JSON_COMMANDS registry inside that test, checked in both directions against the clap tree, so adding a row is the moment someone states who parses the output.

CommandPayload
facelock is-enrolled --jsonone object. See "facelock is-enrolled Exit Codes"
facelock capabilities --jsonone object. See "facelock capabilities"
facelock list --jsonarray of enrolled models
facelock devices --jsonarray of IpcDeviceInfo (facelock_core::ipc): path, name, driver, is_ir, formats (empty whenever the daemon answers, which carries no format detail). Serde-derived, so it is a typed schema rather than a scrape of the human renderer, whose columns and [IR] tag are free to change
facelock preview --jsonone object per line, one per frame
facelock status --jsonone object, one key per report section, each carrying an ok/problem/unknown verdict. See "facelock status Semantics"
facelock pam add|remove|status --jsonone object, whose shape is a stability contract. See "facelock pam Semantics"
facelock data purge --jsonone purge or dry-run report. See "Fixed-root purge boundary"

preview is on the list because it always emitted JSON. It shipped calling the flag --text-only, which survives as a hidden alias and keeps parsing; the per-frame payload is byte for byte what it was.

Machine output does not pass through the translation seam: every --json payload is built with serde_json and is C-locale by construction. It reaches stdout through message::payload, which takes an already-rendered &str and consults no catalog, so routing a payload through the seam to pick up --quiet cannot translate it on the way. Two documented exceptions to the C-locale rule, both diagnostics rather than things to branch on: pam's error field can interpolate a strerror string (see "facelock pam Semantics"), and status's reason and error fields can interpolate an OS, parser or runtime message (see "facelock status Semantics"). Neither is a vocabulary — a consumer prints them and branches on the typed words beside them. Neither exception covers facelock's own catalog: a probe whose diagnostic is a translated string does not get a field, which is why status's daemon section has no error.

facelock setup Flag Composition

Flags compose; they are not mutually exclusive. The rule:

  • --pam and/or --systemd on their own perform just that action and touch nothing else. This preserves the historical standalone meaning, including --pam --service <name>, --pam --remove, and --systemd --disable.
  • Any flag that only makes sense while the base setup runs — --non-interactive, a choice flag, or any of --no-pam / --no-systemd / --enroll / --no-enroll — forces the base setup to run, and the requested actions run in addition.

Consequently setup --systemd --pam now runs both (it previously dropped --pam), and setup --non-interactive --pam now runs the base setup plus PAM (it previously dropped --non-interactive). Both were silent flag drops. --remove and --service require --pam, and --disable requires --systemd, so a dropped flag is now a parse error rather than silence.

--systemd is unsupported under a non-default --config, in a base flow and on its own. The unit it enables runs bare facelock daemon (ADR 009 §4), which reads only /etc/facelock/config.toml: FACELOCK_CONFIG is ignored by a privileged process and the unit passes no --config. A daemon enabled from a setup run under any other file would authenticate with a store, camera, model set and encryption policy that setup never configured. So facelock --config <other> setup --systemd ... exits non-zero before the base flow creates a directory, downloads a model or mints a key, and before any systemctl call; the message names the two ways out, copying the file to the default path or running setup without --systemd. The wizard, which would otherwise ask, prints why it is skipping the daemon step instead. --systemd --disable reads no config file and still runs, with a note that the unit it stops reads the default file. "Non-default" is decided on the filesystem: a symlink or .. spelling of the default file is the default. Under such an override, enrollment and the recognition test use direct camera access under that file and never the daemon on the bus, whatever owns the name (see "Operating Modes").

--if-present requires --pam and applies to the add side as well as --remove. "Configure hyprlock if this machine has hyprlock" is the same question in either direction, and it is what a provisioning script over a set of optional integrations is asking. The flag turns a missing target service file from an error into a successful no-op and does nothing else; read, parse and write failures remain fatal, and without it both directions keep their historical missing-file error. The exit code is facelock pam's and identical on both: an absent service is reported absent and the alias exits 0.

The flag is not a way around a service that should resolve. Since the search path took in the vendor directories, --service polkit-1 finds /usr/lib/pam.d/polkit-1 on a stock Arch box without it. Reserve --if-present for services a machine may genuinely not have. The default stays a hard error, which is what catches --service polkti-1.

facelock setup System Asset Ownership

facelock setup --systemd is not an installer for immutable service assets. The Debian/RPM/Arch packages and just install-files own these canonical files:

  • /usr/lib/systemd/system/facelock-daemon.service
  • /usr/share/dbus-1/system.d/org.facelock.Daemon.conf
  • /usr/share/dbus-1/system-services/org.facelock.Daemon.service

Setup requires each path to be a single-link 0644 root:root regular file whose bytes match the running build. It never creates, overwrites, changes the mode or changes the owner of one. After daemon-reload, setup requires systemd's FragmentPath to name the canonical unit and refuses drop-ins. D-Bus activation is also winner-selected: setup refuses exact-name definitions in the higher-priority /etc, /run, and /usr/local service directories. D-Bus policy is different: every policy fragment is merged. Setup proves the canonical policy bytes are present and the exact historical /etc duplicate is retired; it preserves and reports every other local policy fragment and never claims those administrator rules are ineffective.

The only static-file mutation setup may perform is retiring these historical legacy copies:

  • /etc/systemd/system/facelock-daemon.service
  • /etc/dbus-1/system.d/org.facelock.Daemon.conf
  • /etc/dbus-1/system-services/org.facelock.Daemon.service

The complete canonical, legacy, and fixed quarantine set is preflighted before the first mutation. A legacy path is removable only when it is a single-link 0644 root:root regular file whose SHA-256 is in dist/legacy-system-assets.sha256, the reviewed inventory of exact historical Facelock bytes. Missing paths are healthy and repeated migration is a no-op. Modified, unknown, symlinked, hardlinked, non-regular, wrongly owned or wrongly moded paths are preserved and make the action fail with the exact path and recovery direction; an ambiguous peer or quarantine collision prevents removal of every otherwise exact peer in that run. The sole recoverable pre-existing quarantine state is an absent public legacy name paired with its exact known, trusted fixed quarantine. Before restoring any such pair, setup preflights the complete canonical and three-pair set. It restores every recoverable pair in reverse order with no-replace semantics, revalidates the result, then restarts the complete migration preflight. A dual-name pair, changed quarantine, ambiguous peer, or recovery collision is preserved and reported without overwrite.

Migration first moves every candidate with no-replace semantics to its fixed, same-parent .facelock-migrate-* quarantine. A later staging or revalidation failure rolls earlier candidates back in reverse order before any quarantine is deleted. Rollback never overwrites a replacement: a collision preserves both fixed names and reports incomplete recovery. Only after every legacy name is absent and the complete canonical and quarantine set has been revalidated does deletion of the quarantines publish the migration. Cleanup failure after that boundary can leave a reported fixed quarantine, but cannot leave a mix of active historical names; the next run authenticates, restores, and remigrates that exact pair through the same bounded protocol. Rust uses Linux renameat2(RENAME_NOREPLACE) without a replacing-rename fallback; the source helper uses same-parent GNU mv -Tn and verifies both names so a no-clobber skip is an error.

Production pins the trusted identity to UID/GID 0:0 and requires the layout root, every existing parent, and every asset to have the documented trusted ownership and modes. The source helper's explicit alternate-layout test mode treats that real, non-linked layout directory's owner as the root-equivalent identity and also rejects a group/world-writable root. That identity can change the fixture just as root can change the production filesystem; it is not an isolation boundary.

setup --systemd --disable stops and disables the unit without writing any service asset. A concurrent privileged writer can always replace root-owned names between filesystem operations; setup detects stable-path changes by revalidating immediately before removal, but does not claim isolation from a second root process. Administrators must not edit these paths concurrently. just uninstall removes only canonical /usr assets. It never inspects or removes any of the three historical /etc names, even when one is an exact known regular copy; linked and parent-linked forms are preserved as well. It preflights the complete canonical set before deletion: every existing parent component must be a non-linked directory owned by the trusted root-equivalent identity and not writable by group/other, while each existing direct target must be a single-link 0644 regular file with that identity. A linked, multiply linked, wrongly owned, wrongly moded or non-regular canonical target, or any untrusted canonical parent, preserves every canonical peer and fails the uninstall-files action. This path-based shell preflight does not claim isolation from another process with the same root-equivalent authority.

--pam is an alias onto facelock pam add / facelock pam remove. The plan resolution above stays on setup--pam, --no-pam, --service, --remove, --if-present and their precedence rules are unchanged — and only the execution moved. The alias is exact, including the two things that make it not a plain forward:

  • setup --yes maps onto --no-confirm only. It suppresses the ordinary per-file question and does not authorize a sensitive PAM mutation. --non-interactive has the same prompt-only effect, as it always has. setup --pam --allow-sensitive maps onto the writer's separate authorization and does not suppress the question. The flag conflicts with --remove, whose safe direction is never sensitive-gated.
  • The root refusal is a hard error, not a sudo re-exec. Standalone --pam never offered the interactive escalation (needs_root_precheck), and facelock pam add|remove does not either.

Supplying a choice flag suppresses the corresponding wizard step. auto means "re-derive from hardware", not "use the default" — omitting the flag already gives the default. Under --non-interactive, an unresolvable choice is an error, never a prompt.

Setup's automatic camera choices apply the same post-classification decodability predicate as device.path-unset auto-detection. The interactive wizard excludes nodes that advertise none of GREY/Y16/YUYV/NV12/MJPG before it auto-selects or presents candidates, and --camera auto considers only the remaining IR-classified nodes. Exclusion never reclassifies a node: an IR node whose only formats are Y8/Y10/Y12 remains IR, but setup reports its path and advertised formats and does not select it. GREY and Y16 remain eligible. If security.require_ir = true and IR-classified nodes were detected but none has a decodable format, the wizard aborts setup before constructing its menu, reports every excluded IR path and format, and never presents, recommends, or persists an RGB fallback; unrelated camera-enumeration and prompt failures retain the wizard's recoverable camera-step behavior. With require_ir = false, decodable RGB nodes remain explicit wizard choices. --camera auto likewise errors instead of falling back to RGB when no usable IR candidate remains. An explicit --camera /dev/videoN remains an operator override and is still subject to the auth/open fail-closed checks.

Encryption method transitions (--encryption). Switching to tpm with a keyfile present seals that key (same bytes) rather than minting a new one. Switching to keyfile with a sealed key present and a usable TPM unseals it into the keyfile. A fresh key is minted only when no key artifact exists, behind the orphaned-models guard. When both artifacts exist and hold different keys, setup keeps the target's and prints a notice naming both files. A TPM device that exists but fails to initialize is reported (warn + notice) rather than silently downgrading to keyfile.

facelock pam Semantics

facelock pam add | remove | status and the machine-wide facelock pam remove --all cleanup own every direct write to /etc/pam.d. setup --pam is an alias onto it (above), and the wizard's step 9 calls the same writer, so there is one implementation of the edit and one set of rules.

Resolution order: /etc/pam.d, then /usr/lib/pam.d. First hit wins. That is Linux-PAM's own precedence, and it is not academic: on current Arch polkit ships its configuration as /usr/lib/pam.d/polkit-1 and /etc/pam.d/polkit-1 does not exist, so a writer that looked only in /etc/pam.d could not configure the service at all. The list is [pam] config_dirs (Config Schema below) for a distribution whose vendor directory is somewhere else; there is no way to ask Linux-PAM at run time which one it was compiled with, so the default for named add, remove and status is the pair above and configuration is never required. Machine-wide remove --all uses its own fixed roots described below. A hit that is refused — a hard link or any symlink — is still a hit: the search does not fall through to the next directory, because that would let a vendor file silently take over from an /etc entry facelock declined to follow.

Only the first directory is ever written to. The rest are package-owned: an edit there is clobbered by the next upgrade and makes pacman -Qkk report a modified file. A service that resolves only in a vendor directory is copied into /etc/pam.d with the facelock line already in it — one atomic write of the final content, not a copy followed by an edit — and the copy carries a two-line provenance header naming the file it was forked from and saying that it shadows it and will not track vendor updates. The copy reports overridden rather than installed, and the operator is told at the time, on the unsuppressible notice stream, because a new shadowing file in /etc is a durable change with a maintenance consequence. Deleting the override restores the vendor file. Named pam remove deletes an unchanged Facelock-created override after taking out its line; header, payload, metadata, identity or current-vendor drift keeps the no-rule local override and reports why. If no current vendor source exists, an exact header path derived from a normalized configured later-root candidate is recognition only: removal retains the local override and reports that the source is absent. An arbitrary path in a header is never authority. The vendor file is never read-modified-written, never backed up, and never renamed over.

The module is probed too, and that is a different list. The service-file order above says where a service file is looked up; the module pam_facelock.so is looked up in /lib/security, then /usr/lib/security, then /usr/lib64/security, first hit wins. Two lists for two things: one is configuration, the other is a shared object, and they are never merged. /lib/security is first so the answer on usrmerged Arch is unchanged; /usr/lib64/security is where dist/facelock.spec installs on x86-64 Fedora and RHEL, which is why the single hardcoded path was a refusal-to-write on the distribution this repository ships a spec file for. There is deliberately no Debian multiarch triple: Debian's idiomatic path is pam-auth-update, which is out of scope for this command. The probe is read only — it finds the module, and never installs, copies or links it. When it finds nothing, add refuses and the refusal names every candidate, so an operator on an unlisted layout can see what to add. The list is not configurable.

A vendor file that already carries the line needs no override. add reports unchanged and writes nothing, and status reports present: a distribution that ships face auth in its own PAM stack is configured, and saying otherwise would send an integrator off to create a copy that adds nothing.

Managed shared stacks are not leaf services. Debian and Ubuntu compose their shared stacks with pam-auth-update; Fedora and RHEL use authselect. Facelock does not write through either manager's generated shared files. Before any direct pam add or setup --pam plan, Debian's detector reads only the compiled /usr/share/pam-configs/facelock, /var/lib/pam/auth, and /etc/pam.d/common-auth locations. It requires root-owned, non-writable, regular single-link files opened beneath no-follow directory descriptors, the exact packaged profile bytes, an exact Module: facelock selection, and a live Facelock rule inside pam-auth-update's Primary block. An active profile refuses the direct edit before backup state is created and says exactly how to run sudo pam-auth-update --disable facelock, verify password authentication, and retry the original Facelock command with all services and flags intact. Selected-but-inconsistent or untrusted state fails closed rather than being treated as inactive. A live managed rule whose saved selection is absent fails closed as well. The roots are not configurable and the environment cannot redirect them. Explicit named leaf services remain supported on every package family: the writer resolves and edits only that requested service under the PAM roots, while the sensitive-service gate and no-follow checks refuse generated system-auth and password-auth links.

Confinement. A service name is one path component: not empty, no /, not . or .., no interior NUL. Rejected before any I/O, on add, remove and status alike. base.join(service) is not a confinement primitive — an absolute name replaces the base — so this is the check, not the join. Anything else is accepted: PAM_CANDIDATES is the wizard's menu, not an allowlist, and a service that is not on it must keep working.

Every symlinked service entry is refused by named add, remove and status. The writer lstats the entry for diagnostics, but read, mutation and recovery all reopen the confined service basename relative to an already-open PAM root with O_NOFOLLOW; neither a resolved absolute target nor a target recorded in provenance is ever opened. This applies even when the link text appears to remain in the same directory. It also prevents Facelock from editing generated authselect state through /etc/pam.d/system-auth or /etc/pam.d/password-auth, and prevents a hand-made /etc/pam.d/polkit-1 -> /usr/lib/pam.d/polkit-1 link from turning a vendor file into a write target. A symlink is a validation failure for the whole write run, and pam status reports it as unknown with the retained fixed reason symlinked outside /etc/pam.d; that token names the compatibility class, not a claim that an in-directory link would be followed. The human diagnostic names the link text and the directory whose entry was refused.

A file with more than one hard link is refused. A symlink is a visible indirection this can reject by name; a second hard link says another name for the inode exists but not where, so the edit cannot be shown to stay inside the directory. pam status reports it as unknown with the fixed reason hard-linked service file. This is conservative rather than adversarial: a /etc that has been through a deduplicating backup or jdupes -L can trip it with nobody attacking anything, so the message says how to break the link. The atomic replace does not retire the rule: a rename writes a new inode, so it leaves the other name holding the old content — one of a file's names carrying the line and the rest not is a worse answer than a refusal, and still a change to a file facelock cannot name.

--if-present does not forgive a link fault. A dangling or looping symlink is not an absent service file: absence is a fact about the directory, and an unresolvable link is the absence of an answer about where a write would land. Both are phase-one failures on add and remove under --if-present, and exit 2 on status --if-present.

The sensitive gate is applied before any write. It checks the typed service and the confined basename returned by resolution. Symlinks cannot provide an alternate ungated name because every symlink is refused; the system-auth-ac and password-auth-ac spellings remain explicit members because older authconfig installations can use those names as real service files.

Two-phase across services. Every requested service is validated — name, existence (subject to --if-present), the sensitive gate, and what the edit would be — before any file is written. A validation failure writes nothing at all, which is what makes a caller's loop all-or-nothing for the failure that actually happens: a typo'd or gated service name. It is not a transaction: a write-phase I/O error on service N leaves 1..N-1 written. Those are reported per service and the exit code is non-zero; the remaining services are still attempted. Each individual local mutation has its own serialized, crash-recoverable transaction and rollback pair, described below. Named pam add and pam remove do not use a whole-set journal; the compiled-root pam remove --all transaction is specified separately below.

--no-confirm never implies --allow-sensitive, including through setup --pam. They are separate authorizations: "do not ask me" and "yes, edit the shared auth stack". The gated services are common-auth, login, password-auth, password-auth-ac, sshd, system-auth, system-auth-ac and system-login. Six of the eight are shared stacks — files that other service files include, so one edit reaches su, passwd, chsh and the display manager at once — and which name a distribution uses is the only difference between them (system-auth/password-auth on Fedora, RHEL and Arch, the -ac spellings where authconfig wrote the real file, common-auth on Debian and Ubuntu, system-login on Arch). Gating one spelling made the gate depend on the operator's distribution. login and sshd are the two that are not: each locks one specific door — the TTY, the network — rather than every one at once. --yes and --no-confirm are the same flag (the shared ConfirmArg spelling, so "skip prompts" reads the same on setup, pam add, remove and clear) and neither unlocks the gate. Both pam add and its setup --pam alias expose the same explicit --allow-sensitive authorization. remove is never gated on sensitivity — removal can only take away a way to authenticate — and never prompts, which is what setup --pam --remove has always done; the confinement rules below apply to every verb, remove and status included. --yes/--no-confirm is accepted there for symmetry and has nothing to suppress today.

With no TTY on stdin, pam add proceeds as if --no-confirm were given. A question nobody can answer is a hang, not a safeguard, and this is what has always made setup --pam work from a provisioning script — so sudo facelock pam add --service sudo < /dev/null writes without the flag. The prompt this skips defaults to yes, so the flag changes nothing about the outcome on a TTY either; what it changes is whether you are asked. This never touches --allow-sensitive: the sensitive-service gate is decided in the validation phase, before any prompt exists to skip, so an unattended pam add --service system-auth still refuses.

Exit codes.

CommandCodeMeaning
pam status0every requested service carries the line
pam status1at least one requested service exists without it, in /etc/pam.d (missing) or only in a vendor directory (vendor-only)
pam status2at least one is absent, unreadable, misnamed, symlinked, or hard-linked
pam status --if-present0every requested service that exists carries the line
pam status --if-present1at least one existing service carries no line
pam status --if-present2as above, minus the absent case, which no longer forces 2
pam status --all0at least one service carries the line, and every directory was read
pam status --all1nothing on the machine carries it, or an enumerated service has no line in the file Linux-PAM reads
pam status --all2a directory could not be listed, or an enumerated service could not be answered for
pam status --all --if-present0/1/2unchanged from --all: an enumerated name was found, so there is no absent case to forgive
pam add, pam remove0every service reached its requested state and required default rollback-state cleanup completed — including unchanged, overridden (add created the /etc/pam.d copy), vendor-only (remove had nothing of its own to take out of a package-owned file), absent under --if-present, and declined
pam add, pam removenon-zeroa validation failure (nothing written) or a write failure, including cleanup-failed after the requested PAM state was reached
pam remove --all0every recognized writable direct reference was removed, the final compiled-root scan was clear, and the whole-set transaction committed and cleaned; an empty scan is an idempotent success
pam remove --allnon-zeropreflight, journal, identity, write, final-rescan or recovery failure; direct PAM mutations are rolled back where their exact identities prove that safe, otherwise transaction evidence is retained

pam status is on grep's scale and is-enrolled's: a boolean query whose exit code is the answer. Across several services the worst outcome wins. A declined confirmation is exit 0, since the command did what the operator asked, and --json is how a script tells it from an install.

--dry-run validates and prints the resolved plan without applying the requested edit; a validation failure still exits non-zero. It is honoured after the root check (see DEC-6 above). On remove --all it performs no transaction recovery and creates no state.

A service that exists in no directory names them all. The refusal add and remove raise, and the line status prints, both list every path tried: the same question must not be answered two ways by two verbs. The machine path field on a status row stays a single path — the first directory's, where an override would go — because the field is one string and always has been.

--if-present means the same thing on all three verbs. A service file that is not there is not an error: on add and remove the service is reported absent and the exit code is unaffected, and on status the absent row no longer forces exit 2, so the exit code is decided by the services that do exist. That is what lets "install the optional integrations with --if-present, then verify" be written as a pair. It converts absence and nothing else — an unreadable file, a rejected name, or a link out of the directory is still an error on every verb.

pam status --all reports every configured service; a bare pam status still means sudo. Without --all the command answers only about names it is given, so a configured polkit-1 or omarchy-lock-face is invisible to it. --all replaces the service list with every service in the resolved directories whose file names pam_facelock.so. It is a flag rather than a new default because a bare pam status exits 0/1/2 about sudo today, and an integrator branching on that would have got a different answer without changing a command line. --all and --service are mutually exclusive: enumerating and naming are two questions, and a request that asked both would have to drop one silently.

The scan parses; it keeps no manifest. A state file listing what facelock has edited drifts the moment anyone edits /etc/pam.d by hand, restores a backup, or removes a package, and the report is then confidently wrong. Names are collected across every directory and then resolved through the rules above, so --all and --service X cannot answer differently about one service. Four consequences:

  • a vendor file carrying the line while an /etc file shadows it without one is reported missing. The file Linux-PAM reads has no line in it, and dropping the name would hide the one machine an operator cannot otherwise explain.
  • an entry the resolver refuses (symlinked or hard-linked) is an unknown row with its usual reason: never followed, never dropped.
  • a service file that could not be read is an unknown row too. Omitting it would report "not configured" for a machine this could not check.
  • .facelock-backup, .pacnew, .pacsave, .pacorig, .rpmnew, .rpmsave, .rpmorig, .dpkg-old, .dpkg-new, .dpkg-dist, pam-auth-update's .pam-old, names ending in ~, and dotfiles are not services. Each can carry the line, and none is a name Linux-PAM is ever asked for.
  • only a regular file is read. A FIFO blocks the read until a writer appears, and a diagnostic command that hangs on a malformed /etc/pam.d is worse than one that omits an entry no PAM stack could use; device nodes, sockets and symlinks to directories go the same way. The check is on the followed metadata, since a symlink is how a non-regular file reaches the scan. "Not a regular file" and "could not be examined" are different answers, and only the first is a skip: an entry whose stat fails — a symlink into a directory the caller may not traverse, a symlink loop, a dead network mount — is carried into the report as unknown, the same answer --service gives for it. The one exception is ENOENT, which is an absence and is skipped like any other file that is not there; a dangling symlink is therefore absent from an --all report while --service on the same name reports unknown, because a link pointing at nothing carries no facelock line but is still an entry the writer refuses to follow.
  • an entry whose name is not valid UTF-8 is skipped and logged. Spelling it lossily would hand the resolver a name no file has and report a configured service as absent at a path that does not exist.

A directory that could not be listed is reported, not treated as empty. "Nothing is configured here" and "I could not look here" are different answers, and rendering them identically is what made a broken lock stack and a healthy one look the same. Every directory searched appears in the --all document with a status of scanned, absent or unreadable, and an unreadable one makes the exit code 2 whatever the services said. An absent directory is not an error: one that does not exist demonstrably holds no service files, and the default search path names a vendor directory many machines lack, so treating that as unanswerable would make every one of them exit 2 forever.

Nothing configured is exit 1. A machine with no facelock line anywhere is not configured, which is the answer pam status already gives for a service file with no line in it. --if-present does not change it. A name reaches an --all report by having been found, so there is nothing for the flag to forgive, and pam status --all --if-present on an unconfigured machine exits 1 like the bare form. One state produces an absent row anyway: a file deleted between the listing and the read. It is the only one, and --if-present scores it 0 there as everywhere else.

The empty answer is scoped to what could be read. "No service file under these directories carries the line" is a claim, and it may only name directories that were listed or proven not to exist. When some directory could not be read, the sentence names the ones that could and says in the same breath which could not; when none could be read there is no set to make the claim about, so only the per-directory lines are printed. Without that scoping the sentence read under 2>/dev/null asserts exactly what --all exists to stop it asserting.

facelock status summarizes the same scan. Its report carries one PAM services: line built by running the scan above, so the two commands cannot disagree about whether a service is configured. The detailed listing stays in pam status --all. The summary keeps the "not checked" distinction: it reads none configured only when every directory was read, not checked when nothing was found and something could not be, and it names each unread place on a not checked: line of its own.

PAM publication is complete-file atomic and identity-checked. Planning captures the regular, single-link service file's device, inode, link count, SHA-256 hash, exact mode, UID and GID. Stable identity comparisons bind all seven values; timestamps and other unstable metadata are deliberately excluded. Immediately before publication the writer reopens the confined basename beneath its configured PAM write root and checks that same identity. An existing local file is replaced with RENAME_EXCHANGE, leaving the exact displaced inode named and open for a final check. There is an unavoidable bounded interval after the exchange in which the complete replacement is the canonical service file before that displaced-original check completes. If an administrator or package replaced the file at the boundary, a second exchange restores that complete intervening file; only a verified displaced inode is unlinked. Neither side is ever partially written, and PAM's password fallback is unchanged.

The replacement file preserves the existing file's owner, exact mode and SELinux context. Ownership is applied before the final mode because fchown can clear setuid/setgid bits. POSIX ACLs and xattrs other than the SELinux label are not carried across. A vendor-only service is rechecked by identity and hash immediately before a complete local override is published with RENAME_NOREPLACE; an override that appeared after planning is preserved. The new override takes the vendor file's owner and exact mode, but deliberately does not copy the vendor file's SELinux xattr: the destination directory's type transition supplies the local label. Each new file and its parent directory are fsynced at the committed boundary.

Rollback state has a fixed root. An in-place pam add writes the original service bytes under /var/lib/facelock/pam-backups, a 0700 root:root directory independent of both [pam].config_dirs and storage.db_path. The production store opens that fixed directory without following the final path component, applies root ownership before the final mode, and verifies exact directory type, 0700 mode and root:root ownership before accepting it. It reopens, locks and verifies the descriptor again before trusting state in a transaction. State-entry authority is the fixed expected root owner, never the owner observed on the directory. Only explicitly injected setup/test roots use the process EUID and EGID as their expected state owner.

The published backup and its adjacent JSON record are regular, single-link 0600 root:root files. Their exact basenames are <service>.<seconds>-<nine-digit-nanoseconds> and that basename plus .json; seconds are decimal and in range for u64, and nanoseconds are decimal, exactly nine digits and below one billion. Collision probing is bounded, advances the nanoseconds and publishes with no-clobber semantics. A failed publication cleans only an identity created and validated by that transaction, never a pre-existing name.

Version 1 provenance JSON is strict and rejects unknown fields. It contains exactly version, positive sequence, state (prepared or committed), a confined service, the exact backup basename, original_sha256 and installed_sha256; both hashes are 64 hexadecimal characters, and no target path is stored. A record can participate in sequence allocation, duplicate detection, newest-backup reporting or cleanup only as part of a complete record/backup pair whose names and schema agree, whose backup hash validates, whose entries have the fixed expected state owner, mode 0600 and one link, and whose record and backup can be read within 16 KiB and 1 MiB respectively. Duplicate sequences make every pair at that sequence ambiguous for service selection and cleanup. Sequence allocation checks overflow, and the newest committed backup is selected by validated sequence rather than wall-clock filename order.

Every multi-name mutation has a strict durable intent. Version 1 intent JSON rejects unknown fields and always carries version, role, positive sequence, confined service, strict transaction basename in backup, original_sha256, installed_sha256, nullable record_sha256 and replacement_record_sha256, and nullable original_device, original_inode, original_links, original_mode, original_uid and original_gid. Option fields are serialized as JSON null. Each intent must be a regular, single-link, state-owner 0600 file no larger than 16 KiB, every present hash must be 64 hexadecimal characters, and every present mode must identify a regular file. Fields that are irrelevant to the selected role must be null; any other combination invalidates the intent. For mutation-only roles the backup value is a collision-resistant operation identifier, not a claim that a rollback pair exists.

JSON roleRequired role-specific fields
prepare, cleanuprecord_sha256 present; replacement-record hash and all six original identity/metadata fields null
commitrecord and replacement-record hashes present; all six original identity/metadata fields null
pam_replacerecord hash present; replacement-record hash null; exact original device/inode, original_links = 1, mode, UID and GID
pam_removerecord hashes null; exact original device/inode, original_links = 1, mode, UID and GID
vendor_createrecord hashes and original device/inode/links null; original mode/UID/GID present as the expected newly-created destination metadata

A strict publication binding authenticates every created inode. Version 1 binding JSON rejects unknown fields and contains exactly version, role, a positive u64 sequence, confined service, strict transaction basename in backup, intent_sha256, device, inode, links, sha256, mode, uid and gid. JSON role is one of commit, pam_replace, pam_remove or vendor_create. Both hashes are 64 hexadecimal characters, links is exactly one, and mode identifies a regular file. The binding itself must be a regular, single-link, fixed-state-owner 0600 file no larger than 16 KiB.

While the base intent exists, its exact bytes must hash to intent_sha256, and its role, sequence, service and operation basename must agree with the binding. The bound replacement hash must also equal the commit intent's replacement record hash or, for a PAM/vendor mutation, its installed hash. After the named replacement temp exists, Facelock publishes the binding atomically with no-clobber semantics before the RENAME_EXCHANGE or RENAME_NOREPLACE that makes the replacement canonical. The full identity is captured from the still-open created temp only after its bytes and requested metadata/context are applied, the file is synced, and its desired hash, mode, UID, GID and single-link count are validated. Facelock then reopens the exact reserved temp basename and full-compares it before publishing any binding. A failure at this boundary uses identity-checked cleanup; any reopen, comparison or cleanup uncertainty is ambiguous and preserves the base intent and filesystem evidence. A crash in the gap before the binding is published leaves the base intent and unbound temp conservatively preserved. A synchronous no-clobber binding-publication failure is distinct from that crash: it preserves the colliding administrator state entry and reopens and full-compares the unpublished replacement temp. Facelock may remove the base intent only after the exact temp unlink and parent-directory fsync succeed. Any reopen, full-identity, unlink or fsync ambiguity returns AmbiguousPublication and retains the base intent and colliding administrator evidence. The temp is also retained unless its exact identity-checked unlink succeeded and only the subsequent parent-directory durability sync failed; in that case the temp name may already be absent. Commit, PAM replacement/removal and vendor creation all use this same checked cleanup primitive. The same full-identity cleanup applies after the binding is durable if source drift or an exchange/no-replace failure prevents PAM or vendor publication. Any uncertainty returns AmbiguousPublication and preserves the base intent, binding and all remaining evidence, subject to the same post-unlink fsync boundary above.

The reserved name grammar is exact:

  • intents: .facelock-intent-{prepare|commit|cleanup|pam-replace|pam-remove|vendor-create}-<transaction>.json
  • publication bindings: .facelock-publication-{commit|pam-replace|pam-remove|vendor-create}-<transaction>.json
  • state quarantine: .facelock-quarantine-backup-<backup>, .facelock-quarantine-record-<backup>.json, .facelock-quarantine-commit-<backup>.json
  • PAM-directory temps: .facelock-pam-replace-<transaction>, .facelock-pam-remove-<transaction>, .facelock-vendor-create-<transaction>
  • PAM-directory vendor-retirement quarantine: .facelock-vendor-retire-<transaction>
  • atomic state temps: .facelock-tmp-<destination>-<64hex-content-hash>-<pid-digits>-<nanos-digits>

The strict atomic-state-temp destination grammar includes publication-binding destinations; a binding temp is not authenticated by its prefix alone. Backup and record temp destinations additionally require a confined service component, so empty, . and .. services are never owned.

A reserved-looking name alone never establishes ownership. The applicable intent or pair schema and derived names, the owner/mode/link requirements for that role, the bounded content hash and, where applicable, the captured PAM identity and metadata must all agree before Facelock resumes or removes it. Every state match, committed-record transition, quarantine move and unlink rechecks that the entry is single-link, state-owner and mode 0600 in addition to its content and identity. Same-inode, same-content mode or ownership drift is ambiguous and is preserved. State publication, vendor publication, state quarantine and vendor-retirement quarantine moves use RENAME_NOREPLACE; committed-record and existing-PAM transitions use RENAME_EXCHANGE.

If an atomic state temp-to-final RENAME_NOREPLACE succeeds but syncing the parent directory fails, the result is AmbiguousPublication, not an ordinary create failure, and every caller propagates it before cleanup. Prepare retains its durable intent plus the visible final backup or record. Commit-replacement publication retains the commit intent plus its named replacement. If the destination is a publication binding, each of commit, pam_replace, pam_remove and vendor_create retains its base intent, exact replacement temp and visible binding. Checked cleanup is limited to definite failures before the rename; strict identity binding lets recovery classify and complete each retained evidence set.

After exchange or publication, the canonical file is reopened and compared against the binding's full device, inode, link count, hash, mode, UID and GID. This happens immediately after publication, immediately before a displaced inode is unlinked where that boundary exists, and immediately before the base intent is cleaned; commit checks once more after unlinking its displaced prepared record. A canonical mismatch preserves the canonical name, any remaining temp or displaced name, the base intent and the binding for manual inspection. These checks do not weaken complete-file atomicity or PAM password fallback.

One state-directory flock spans a local mutation. For an in-place add it covers recovery, bounded timestamp and sequence allocation, durable prepare intent and rollback-pair publication, PAM intent/temp/exchange, and committed record intent/exchange. Local remove and vendor-create planning, publication and recovery run under the same guard; unchanged vendor-override quarantine, validation, deletion or restoration completes before the local-remove guard is released. A competing writer or recovery cannot discard an add's prepared pair between persistence and commit. Backup cleanup after a successful or no-op remove is a separate locked quarantine phase; this does not make named mutations multi-service atomic. The pam remove --all transaction below keeps its whole-set lock through batch cleanup.

Recovery treats state as an untrusted hint and re-resolves only the recorded confined service under the current PAM write root. It recovers publication bindings before base intents. A bound commit, PAM or vendor canonical candidate must match the exact full created identity in its binding; an original PAM candidate must match the exact original identity captured in its base intent. While both state files exist they must bind to one another as described above. Without a valid binding, a named or published replacement is ambiguous and is preserved; only a clearly pre-temp base-intent shape is cleaned.

The base intent is removed first and the self-contained binding last. Recovery considers a binding orphaned only when the exact derived base-intent name is definitely absent. An invalid-mode, invalid-owner, malformed, mismatching, symlinked, or hard-linked exact entry blocks destructive binding recovery. If a crash truly leaves an orphan after the base unlink, recovery removes it only when the canonical file still matches the full bound identity and the temp is absent. A mismatch preserves the binding and every ambiguous name. A crash after the binding unlink leaves no publication-state debris.

Prepare recovery handles an intent alone, a backup alone, a record alone or a complete pair. Bound commit recovery distinguishes pre-exchange, exchanged and displaced-record-unlinked boundaries. Cleanup resumes the no-replace backup/record quarantines and identity-rechecked unlinks. Bound PAM replacement/removal recovery distinguishes an intent alone, a ready temp and the exchanged canonical/displaced pair. A remaining ambiguous pam_replace intent also blocks generic prepared-pair recovery, preserving the rollback pair. Bound vendor-create recovery handles absent/temp and published/absent boundaries. A wrong owner, mode, link count, schema, hash or identity, an extra conflicting entry, a symlink/hard link, or any other ambiguity is preserved for manual inspection.

pam remove takes no new rollback copy. After every successful or no-op removal it deletes validated committed Facelock-owned pairs for that service and the exact legacy <service>.facelock-backup entry by default. Pair deletion first moves the record and backup to no-replace quarantine names, then rechecks the identities before unlinking. Legacy cleanup is confined to the override root and rechecks the exact regular, single-link entry immediately before unlink. Malformed provenance, lookalike names, symlinks, hard links, changed entries and unrelated administrator files are retained. --keep-backup opts out of both versioned and legacy cleanup.

For a local copy created from a vendor-only service, named pam remove first publishes the complete document without the Facelock rule through the existing pam_remove exchange protocol. It then deletes the local override only when the exact two-line Facelock header names the current vendor service. Current vendor resolution reopens the configured later roots in order and stops at the first existing entry; a malformed, linked, unreadable or oversized first entry is a blocker rather than permission to accept a matching lower-priority file. The remaining payload and mode/UID/GID must equal that bounded, regular, single-link vendor file, the vendor file must contain no active Facelock rule, and the complete local bytes must be either the exact header plus the one document emitted by Facelock's insertion or the exact header-plus-vendor no-rule restart shape. The journal backup used by batch cleanup is likewise reopened within its size bound and must retain its full prepared identity before its header is parsed.

The canonical local inode must still have the full identity captured by the removal publication. Facelock moves that exact basename to the derived .facelock-vendor-retire-<transaction> quarantine with a no-replace rename, syncs the directory, and rechecks the quarantined identity, canonical absence, exact emitted shape and ordered current vendor before identity-checked unlink. If the local or vendor check fails while the canonical name remains absent, Facelock restores the exact quarantine with a no-replace rename. A concurrent canonical entry, quarantine collision, reopen failure, identity mismatch or durability uncertainty preserves all available names and durable publication evidence as ambiguous. Every quarantine, restore and unlink boundary is restartable. Both service names are re-resolved beneath already selected PAM roots; the header is a recognition hint, never a path to open. Header, payload, metadata, vendor-source or identity drift preserves the local override after removing its Facelock rule and reports that decision. When no current vendor entry resolves, only a header naming a normalized path derived from a configured later root is recognized, and it causes explicit retention rather than deletion; the header path is not opened or followed. A restart may finish the same exact deletion when the rule-removal exchange completed but the header-bearing local override is still present.

pam remove --all is the config-independent whole-machine cleanup. It ignores [pam].config_dirs and opens the compiled roots /etc/pam.d (writable override), /usr/lib/pam.d (detection-only vendor state), and /etc/authselect (detection-only generated state). Missing or corrupt config, database, model, camera, daemon and ONNX Runtime state cannot redirect or block it. It enumerates already-open directory descriptors and re-resolves each confined basename with directory-relative, no-follow operations. A candidate must remain a regular, single-link file whose bounded bytes and complete identity can be rechecked. Directory contents are detection ground truth; provenance is untrusted ownership evidence, never a target path.

No symlink is followed. A symlink is skipped only when its text is the exact absolute path of the same service basename beneath a later compiled root that this run scans independently. This accounts for stock links such as /etc/pam.d/system-auth -> /etc/authselect/system-auth without trusting the link's contents. Every other symlink, hard-linked, nonregular or unreadable PAM entry that contains or could hide a Facelock reference is an unmanaged blocker. A reference found in the independently scanned /usr/lib/pam.d or /etc/authselect root is likewise an unmanaged read-only or external-root blocker. Structural directories, including authselect profile directories, are not PAM service files and are skipped. Nothing outside the fixed roots is followed or deleted.

A writable direct file with a conventional PAM service basename is recognized as Facelock-owned when every Facelock logical rule uses the exact pre-versioned physical bytes auth sufficient pam_facelock.so. Dot-prefixed names and the administrator/package artifact suffixes .facelock-backup, .pacnew, .pacsave, .pacorig, .rpmnew, .rpmsave, .rpmorig, .dpkg-old, .dpkg-new, .dpkg-dist, .pam-old, and ~ are not conventional legacy candidates. They are considered only when a strict provenance basename exists for that exact confined service and the current complete-file hash equals installed_sha256 in its validated committed pair, or when the regular local file carries the exact Facelock vendor-copy header and matches its current fixed-root vendor source as specified above. This lets remove --all find every arbitrary name accepted by either named writer path without treating an unowned administrator artifact as an active PAM service. Customized controls, options or spacing, corrupt or ambiguous provenance for a candidate, invalid bytes, path escapes, link swaps, identity drift and concurrent edits block the whole run during preflight. Nothing is changed when preflight finds a blocker. A drifted or source-absent local override is not automatically one of those blockers: when its Facelock rule is provenance-owned or the exact canonical line, --all rewrites the file in place, taking the rule out and keeping the file, never retiring it. This is the same drift rule named removal already follows. A committed record whose installed_sha256 no longer matches the file proves only that the file changed after add; a conventional service is then held to the exact canonical rule above, while an artifact name, a candidate only because of its record, stays blocked. An administrator-managed or stale-record blocker names its remedy: facelock pam remove --service <name>, or a hand edit, then retry. An empty scan is an idempotent success. The initial cleanup scan also recognizes that exact unchanged header-bearing vendor override after its Facelock rule is already absent. This is the bounded restart shape of the named removal above; other no-rule files are ignored and preserved. The final active-reference scan excludes this cleanup-only shape.

The whole set is journaled before the first PAM mutation. Version 2 state is strict JSON with unknown fields rejected, regular single-link fixed-state owner and mode 0600, no-clobber publication, a 4 MiB encoded limit and at most 1,024 unique confined services. Its reserved names and exact fields are:

  • .facelock-remove-all-<operation>.json contains exactly version, operation, keep_backup, and targets. Each target contains exactly service, strict backup, original, installed_sha256, and the required boolean delete_override; original contains exactly device, inode, links, sha256, mode, uid, and gid and must describe a regular single-link file.
  • .facelock-remove-all-commit-<operation>.json contains exactly version, operation, journal_sha256, keep_backup, and targets. Each target contains exactly service, strict backup, installed, and the required boolean delete_override, where installed uses the same complete-identity fields and validation.

Version 1 journal and commit files remain recoverable only when every target omits delete_override; version 2 requires it on every target. JSON null is not an absent field. The corresponding journal and commit flags must match.

operation is <seconds>-<exactly-nine-digit-nanoseconds>: seconds parse as u64, nanoseconds are below one billion, and collision allocation is bounded. Only a prefix plus that valid operation grammar is reserved batch state; a prefix-shaped strict provenance basename with another suffix remains ordinary per-service provenance. Both state files and every hash are bounded and validated, and duplicate services invalidate either target list before recovery. A commit pairs with a journal only when operation, keep flag, ordered service/backup set, exact journal hash and planned/committed installed hashes and delete_override flags agree. Multiple, malformed or conflicting reserved entries require manual review.

pam remove --all --dry-run opens an existing backup directory for read-only inspection and requires its owner and mode to be trusted already. It performs no owner/mode repair, directory sync, recovery or write locking; an untrusted directory makes the preview fail closed with its metadata and entries intact.

One fixed-state-directory flock spans recovery of any earlier journal, the authoritative root scan and complete preflight, bounded operation and rollback pair allocation, publication of every #171 backup/provenance pair, journal publication, every PAM exchange, the final fixed-root active-reference scan, commit publication and evidence cleanup. Every per-service rollback pair is durable before the journal, and the complete journal is durable before the first PAM mutation. Each service then uses the #171 intent, publication binding, exact created-identity and RENAME_EXCHANGE protocol while retaining the displaced original inode.

A later failure or a non-empty or unanswerable final scan reverse-exchanges every earlier file in reverse order after complete identity checks. Recovery does the same for a prepared journal without a valid commit. The strict, self-contained commit authenticates the journal bytes and every installed complete identity; once it is durable, recovery finishes per-file publication cleanup and validated backup cleanup instead of rolling the PAM files back. Any mismatch or ambiguity preserves the journal and per-file intent, binding, temp or displaced evidence, including administrator bytes, for review. Files are always published as complete byte sequences and PAM password fallback is unchanged.

Prepared-journal recovery also recognizes the one provably unstarted per-service publication shape: the canonical service still has the journal's full original identity, the exact valid pam_replace intent agrees with the prepared pair's sequence and record hash, and the exact replacement temp and publication binding are both absent. It identity-checks and removes only that intent before continuing rollback. Any mismatch or extra name remains ambiguous. After reverse exchange and identity-checked replacement-temp cleanup, rollback removes the exact publication binding before delegating the remaining base intent to that intent-only recovery. Every boundary is restartable; normal forward publication retains its existing intent-first cleanup order. Rollback-pair cleanup is restart-idempotent across cleanup intent, both quarantine moves and both unlinks. An exact matching cleanup intent is resumed; a target whose canonical pair, quarantine pair and exact cleanup intent are all absent is already clean. Partial, substituted or conflicting pair state is preserved and blocks journal cleanup.

After the batch commit marker is durable and per-file publication evidence is finalized, each target with delete_override = true is unlinked through the writable-root directory descriptor only if its full committed identity still matches. The corresponding journaled original backup must retain the full identity captured by the prepared pair and parse as an exact Facelock-emitted one-rule copy or exact no-rule restart shape. Its line-removed SHA-256 must equal the journal target's installed hash. The header must name the first existing service in the ordered later roots, whose bounded, re-opened regular single-link entry must still match the payload and mode/UID/GID and contain no active Facelock rule. Facelock never opens an arbitrary path from the header. The same no-replace vendor-retirement quarantine protocol above performs the committed deletion. Any mismatch preserves the override and the batch journal/commit evidence. A crash after a checked unlink is restartable: absence is accepted only for a committed delete_override target, while any partial, substituted or unflagged absence remains ambiguous.

On success, default cleanup removes only validated Facelock-owned versioned pairs and exact validated legacy <service>.facelock-backup state for every committed target. --keep-backup instead commits and preserves the new pairs and opts out of legacy cleanup. --json uses the standard single remove document with one committed removed row per target (and a backup path only under --keep-backup); an idempotent no-op has an empty services array. --quiet suppresses that document.

Every shipped uninstall surface delegates to this cleanup before deleting the binary or PAM module. This includes the Arch source and binary packages, Debian prerm, RPM %preun, the Omarchy remover and just uninstall. Booted package coverage exercises direct dpkg/rpm and their apt-get, apt, and dnf frontends for abort retention and blocker-free success. Arch also ships /usr/share/libalpm/hooks/facelock-pam-remove.hook, a package Remove-only PreTransaction hook for target facelock; AbortOnFail runs /usr/bin/facelock pam remove --all before pacman changes the package, and the package scriptlet retains an idempotent second call. Debian and RPM propagate cleanup failure so the package, binary and module remain. Source and Omarchy removal stop before their deletes. The module can be removed only after the cleanup's final compiled-root scan succeeds.

The all-or-nothing guarantee covers direct PAM edits owned and scanned by this transaction and retention of the package/module when that cleanup fails. Debian removal adds a read-only boundary before it: the exact shared-profile probe runs first, followed by pam remove --all --dry-run, then the journaled real cleanup, and only then the generated ordinary-removal service stop. Ordinary removal preserves the unit's enabled state for reinstall; the generated purge path alone retires that state. A selected Facelock pam-auth-update profile blocks removal without changing common-auth, its selection state, direct edits, the service, the binary, or the PAM module. The diagnostic tells the administrator to run sudo pam-auth-update --disable facelock, verify that a real correct password succeeds and a wrong password fails, and retry removal. Unsafe or inconsistent shared-profile state also blocks.

This release deliberately supports no automatic legacy/shared-profile migration or deselection. Older packages persisted no durable fact that can distinguish a package-auto-enabled profile from a later administrator choice; therefore every selected state is administrator-owned and preserved. This is the intentionally deferred legacy ambiguity: a future automatic transition requires exact package provenance recorded before the choice, a byte-and- metadata snapshot of the managed PAM graph, mutation while the old profile and binary still exist, pam-auth-update, reapplication of only provenance-owned direct edits, and real correct/wrong-password validation, with provable restore or retained evidence on failure. An unselected profile needs no graph transition: its Default: no metadata leaves with the package payload after the fixed-root direct cleanup succeeds. Fedora is separate: #226 owns only RPM payload retirement and the read-only upgrade guard. Shared-stack migration, regeneration, editing and rollback are explicitly rejected. remove --all scans /etc/authselect as a detection-only root and never edits generated state.

--json emits exactly one document on stdout and no human text; --quiet suppresses even that, leaving the exit code as the whole answer, as it does for is-enrolled. Diagnostics stay on stderr either way. --json implies --no-confirm: the per-file question is drawn on stderr while a parser waits on stdout, so asking it would block the pipeline. It does not imply --allow-sensitive — that is an authorization, and a machine caller has not given one — so pam add --service system-auth --json still refuses.

On add and remove, a validation failure produces no JSON document: it is reported as text on stderr and the process exits non-zero, matching is-enrolled, whose unanswerable case prints a reason and no payload. The phase that rejects is the phase that would have decided every row, so there is no partial document to emit.

pam status is the other way round and always emits a document: it has no all-or-nothing phase to fail, so a rejected service name — or one whose entry this refuses to follow — becomes an unknown row inside the document (with the reason in error) alongside the rows for every other requested service, and the refusal is also written to stderr for a human. Exit 2 either way.

{
  "command": "add",
  "dry_run": false,
  "services": [
    {
      "service": "sudo",
      "path": "/etc/pam.d/sudo",
      "action": "installed",
      "backup": "/var/lib/facelock/pam-backups/sudo.1770000000-123456789"
    }
  ]
}

pam status --json carries one extra top-level key, module_path: the candidate pam_facelock.so was found at, or null when none was. It is a property of the machine rather than of a service — which is why it is top-level and not repeated in every service object — and it is what tells an integrator that the line is present but names a module at a path nothing looks at. add refuses before writing when the module is missing; remove can clean up stale references without the module installed. Neither write verb carries the key.

pam status --all --json carries a second additive top-level key, directories: every directory searched, in search order, each an object with path and a status of scanned, absent or unreadable, and error on an unreadable one. Only --all carries it, because only --all claims to have looked everywhere; a named request resolves through the search path without enumerating it.

A service object carries shadows when the file it names is a local copy hiding a package's own: the value is the vendor path it hides. The key is absent rather than null when nothing is shadowed, which is every row on a machine with no vendor directory. It is a maintenance fact rather than a state one, since the service is present either way, and it says the copy will not follow the package's updates.

shadows is a property of the row, not of a flag or a verb. It appears on any row whose file hides a vendor one — pam status with --service as well as with --all, and pam add and pam remove alike — because one resolver answers for every verb and a row that knows the fact does not withhold it. On an overridden row it is the vendor file the copy was made from, which is what that row has just started shadowing. The human line gained the same fact at the same time: a configured service whose file shadows a vendor one reads

facelock PAM line present (local override of <path>) instead of

facelock PAM line present, on every form of pam status. Exit codes are unchanged by it.

This shape is a stability contract. An object rather than a bare array so a new top-level field is additive. Field names do not change and are not removed; service, path, action and backup are always present on every service object; error is present when action is failed, cleanup-failed or unknown, and shadows when the file the row names hides a vendor one. error is a diagnostic, not a contract — branch on action, never on error's text. A rejected service name reports the fixed C-locale string invalid service name, a symlinked service entry reports symlinked outside /etc/pam.d — the retained fixed name for the class, whichever directory it was in — and a hard-linked one hard-linked service file, but the OS-level failures (failed on a write, cleanup-failed after the requested PAM state was reached, or unknown on an unreadable file) interpolate a strerror string, which follows the operator's LC_MESSAGES like any other C library message. Nothing else in a --json document is locale-dependent. backup is the newest committed backup path the calling process can validate for the service, falling back only to the exact legacy adjacent <service>.facelock-backup path; it is null otherwise. The root write verbs can inspect the 0700 state directory; an unprivileged pam status normally cannot and may therefore report null even while such a versioned backup exists. This is the documented location change from the pre-0.2 adjacent path, so consumers treat the value as an opaque absolute rollback path. It is always null under --dry-run, which writes none, and normally null after a default remove, which cleans owned state; --keep-backup retains it. It is always null for an overridden service: the copy preserved nothing, so legacy state at the override path is not this run's rollback and is not reported as one. Deleting the override is its undo, and the vendor original is untouched. path on an overridden row is the override that was created, not the vendor file it was read from; on a vendor-only row it is the vendor file, which is the one that exists. path is itself null when action is unknown because the name was rejected: no path was ever resolved, and reporting /etc/pam.d/../escape named a path nothing went near, which reads as one that was acted on. A service rejected for being a symlink does carry a path — the link, which is a real entry this did lstat — and its backup field probes only the exact legacy adjacent name, since a pre-0.2 version may have written through the link. It does not use an untrusted link to select versioned state.

The action vocabulary — new words may be added, so a consumer must tolerate one it does not know rather than treat it as an error:

actionVerbMeaning
installedaddthe line was written (under --dry-run, would be)
overriddenaddthe service resolved only in a vendor directory, so an /etc/pam.d copy carrying the line was created from it (under --dry-run, would be)
vendor-onlyremove, statusthe service resolves only in a vendor directory: nothing was written, and there is no local override to carry a line
removedremovethe line was deleted (under --dry-run, would be)
unchangedadd, removealready in the requested state
absentall threethe service file does not exist
declinedaddthe operator answered no at the per-file confirmation
failedadd, removethe write failed; see error
cleanup-failedremovethe PAM change completed but required rollback-state cleanup failed; see error
presentstatusthe file exists and carries a facelock line
missingstatusthe file exists and carries none
unknownstatusthe file could not be read, the name was rejected, or the entry is a symlink or a hard link; see error

pam status --json is what replaces grep -q pam_facelock.so /etc/pam.d/<service> in an integration script: it answers from the same file, without root, and reports "absent" and "unreadable" as themselves rather than as "not configured".

Repeatable --service. --service a --service b acts on both in one process, one root check and one closing hint. Duplicates collapse. No --service means sudo, which is what bare setup --pam has always meant.

PAM line placement. The direct CLI writer emits exactly this 36-byte literal; the literal itself has no trailing newline:

auth      sufficient pam_facelock.so

The control is frozen to sufficient: a successful face can satisfy the stack, while a non-match or unavailable face path continues under the service-owned rules that follow. Ordinary login and privilege stacks commonly reach their password modules there. Omarchy's face-only context instead continues to pam_deny.so; its password attempt uses a separate PAM context. There is intentionally no --control option. A caller cannot silently substitute required, an extended control, or another stack policy for the line whose behavior downstream consumers and Facelock cleanup rely on.

The line is inserted immediately before the first logical rule whose first ASCII-whitespace-delimited type token is auth, matched ASCII-case-insensitively and with Linux-PAM's optional leading -; authtok_type= is not an auth type. If no auth rule exists and the first physical line is exactly #%PAM-1.0, the line follows that header. Without that exact leading header it starts at byte 0. This header-aware fallback is the post-#192 contract; it supersedes #166's original top-of-file wording.

Omarchy owns this exact backend-neutral omarchy-lock-face skeleton:

#%PAM-1.0
auth       required                    pam_deny.so
account    include                     system-local-login

The direct writer produces this exact stack, leaving Omarchy's face-only lane to reach its plain denial when Facelock does not succeed:

#%PAM-1.0
auth      sufficient pam_facelock.so
auth       required                    pam_deny.so
account    include                     system-local-login

Add idempotency and named pam status recognition are deliberately broader than the emitted bytes. Any uncommented logical rule whose semantic bytes contain the exact, case-sensitive byte sequence pam_facelock.so is an active reference: add does not emit a duplicate, and status reports it present. This is substring recognition, not a PAM module-token-boundary promise. pam remove --all is stricter: a broad active reference is not automatically Facelock-owned. Without validated provenance or the exact vendor-copy shape, only the exact canonical physical line in a conventional service is eligible for cleanup; custom control, spacing, or options block the whole-machine run for administrator review rather than being rewritten.

This emitted-byte contract applies only to direct CLI service-file writes. The packaged Debian pam-auth-update profile is opt-in (Default: no) and intentionally emits [success=end default=ignore] pam_facelock.so. Legacy or administrator-managed Fedora authselect profiles use authselect's generated layout; Facelock RPMs no longer ship or select one. Both shapes remain visible to the same broad add/status active-reference recognition, but neither is required to match the canonical direct-writer bytes.

Service-file edits are byte-preserving. A backslash followed only by spaces or tabs before LF or CRLF continues the same logical PAM rule, so insertion never splits that rule. A # ends the semantic rule even after a continuation; comment and blank physical lines remain untouched.

Removal drops the whole genuine logical Facelock rule. For recovery from older facelock output that inserted the canonical physical line between an administrator's continuation backslash and its following physical line, removal deletes only that injected line. This reconnects the administrator's logical rule instead of deleting it.

The editor never decodes the service file as UTF-8 and never reconstructs unmodified lines. Existing LF or CRLF endings, invalid bytes, and the presence or absence of the final newline survive unchanged; the one inserted line uses the target rule's line ending, falling back to the document's first ending when that target is unterminated. It uses the PAM header's ending when it follows that header, or the document's first ending when it goes at the top. A header with no final newline gains the separator before the inserted rule while the rule itself remains unterminated. A byte-identical no-op writes no file and takes no backup; an in-place add backs up before its real edit. Removal takes no new backup and cleans validated rollback state by default; --keep-backup preserves it. Vendor-override creation has no original at the override path to preserve, as documented above. Golden fixtures pin insertion, removal, invalid-byte, CRLF and no-final-newline behavior.

facelock status Semantics

facelock status renders one Health value twice: as the report a person reads, and — under --json — as one document a script parses. Both renderers are pure functions of that value, and a unit test walks the two outputs of one fixture and fails the build when they disagree about any section's verdict or when a section appears in one and not the other.

It stays root-only (see "CLI Privilege Model" below): every fact in the report comes from root's view — the 0600 database, other users' markers, the daemon's root-only methods — so there is no unprivileged half to split out. The consumers are root-run scripts: test/run-integration-tests.sh waits on the daemon by parsing this document, and a setup script verifies the enumerated PAM state without reading prose.

Exit codes are unchanged and carry no verdict. status exits 0 whenever it produced a report, whatever the report says, exactly as it did before --json existed; a failure to reach the report (not root) is the only non-zero exit. The verdicts are in the document. So --quiet --json prints nothing and exits 0 — --quiet suppresses the payload as it does everywhere, but here the exit code is not an answer, which makes the combination a no-op rather than a terser query.

Every fact is a tri-state. Each section object carries a state of ok, problem or unknown, and a reason string on problem and unknown only. unknown is the report's whole reason for existing: "the database could not be read" is a different answer from "this user has no models", and JSON makes that distinction easier to lose than prose does, because null and false read as answers. So a fact nobody established is never a null and never a false — it is "state": "unknown" with a reason. The nested facts that can be undetermined carry the same three words: camera.device, encryption.embeddings, enrollment.marker and pam.services.

Read state before anything beside it. A section whose state is not ok may omit any of its detail keys, because a probe that did not finish has nothing to report there: on an unreadable database enrollment carries no models key at all. That is deliberate — an empty array would be a known "this user has no faces", which is the collapse this whole document is shaped to prevent — but it means a defaulting read is a bug. jq '(.enrollment.models // []) | length > 0' answers false for a machine whose database could not be opened. Branch on .enrollment.state == "ok" first, then read models.

reason is never catalog output. The document is machine output and does not enter the message seam (message/mod.rs, "What must NOT come through here"), so no reason is ever a translated string: the ones facelock authors are C-locale literals, and the one localized why the health probe produces — the reason every config-dependent fact carries when the file did not parse — is replaced by the literal config not available on the way out.

reason is still not a fixed vocabulary. Some of them embed the diagnostic the probe captured, because that text is the part worth having: enrollment.reason can read database not accessible: <store error>, and enrollment.marker.reason carries the marker file's own read or parse failure. The error fields are the same kind of thing — config.error is the toml parser's message, execution_provider.error the ONNX Runtime's own load failure, and each pam.services.not_checked[].error a listing or read failure. An OS error rendered by the C library follows the operator's LC_MESSAGES like any other strerror string, exactly as pam's error field does. So reason and error are both diagnostics, not contracts: branch on state and on the section's typed words, print reason and error, and match on neither.

daemon carries no diagnostic, on purpose. It is the one probe whose error string is not the transport's own words: the client attaches a localized hint to a D-Bus AccessDenied — advice for a human reading stderr — and rendering that chain yields the hint alone. Forwarding it would put translated text in a payload on exactly the machine this section exists to diagnose, so the field is not emitted. reachability and reason carry the fact; the hint still reaches the person, on the report and on stderr.

The typed words are what a consumer branches on. state says how bad it is; the section's own word says what it is: config.outcome (valid/not_found/invalid), daemon.reachability (responding/not_responding/not_configured), camera.selection (configured/auto_detect), execution_provider.availability (available/not_built_in/unrecognized/unqueryable), encryption.key.method (tpm/keyfile/none), notifications.mode (off/terminal/desktop/both), config.device.selection (configured/auto_detect) and models.files[].purpose (detector/embedder).

Each section's state answers its own question, and some are narrower than the section name suggests. A section that owns a nested fact keeps the broad question for itself and puts the specific one underneath, so reading only the outer state can pass a machine the nested fact condemns:

SectionIts state answersAnd beside it
configdid the file parse
daemondid the bus round trip complete, or was the bus deliberately never asked (reachability: not_configured, which daemon.mode = "oneshot" produces)
oneshot_fallbackare the three files daemon-less auth needs on disk
cameraunder configured, does the node exist; under auto_detect, only that auto-detection is enabledcamera.device — whether a device was actually found and interrogated
modelsis the model directory there, with both configured files
execution_providerCPU is assumed available without loading ORT; for a GPU provider, whether ORT reports it compiled in, not whether drivers or an inference session work
encryptionis usable key material in place — method: none is a problem even though the config asked for nothing, because plaintext storage is a finding rather than a preferenceencryption.embeddings — whether the stored embeddings could be counted
enrollmentdoes this user have at least one modelenrollment.marker — whether the is-enrolled marker agrees with the database
securityare the checks enabled at all
notificationsnever a finding: ok whenever the config was read
pamis pam_facelock.so installedpam.services — what the /etc/pam.d scan found, and whether it could see everywhere

Two of those are worth stating outright, because the obvious read is wrong. Under auto-detection "camera": {"state": "ok"} says detection is on, not that a camera exists — a machine with no camera at all renders exactly that, with camera.device.state reporting problem. And "pam": {"state": "ok"} says the module is installed, not that anything uses it — a machine with the module in place and nothing wired up renders that, with pam.services.configured empty. The human report is equally generous in both cases ([ok] auto-detect enabled, [ok] installed); the nested fact is where the answer is.

The PAM section speaks pam status --json's vocabulary. Each row of pam.services.configured is a {"service", "path", "action"} object with the same action words, plus shadows when the file it names hides a vendor one — the same key, absent rather than null when nothing is shadowed. Only present rows appear, because a service that does not carry the line is not a configured service; backup is not on these rows, because status does not probe for backup files. pam.services.state is ok only when every directory and every service file was read; a single unread place makes it unknown and names each one in not_checked, so an incomplete list is never mistaken for a complete one. What was found is still listed and still true.

Stability tier. Field names do not change and are not removed; new fields and new sections are additive; a removal is breaking. New words may be added to any of the typed vocabularies above, including state, so a consumer tolerates a word it does not know rather than treating it as an error. Key order is not part of the contract — parse the document, do not string-match it.

A conditional field is absent, not null, when it does not apply. The whole list: reason (present unless state is ok), error, shadows, installed_at, device, files, checks, delivery, daemon.reachability, camera.configured_path, camera.present, enrollment.models and enrollment.marker. A consumer must tolerate a section carrying nothing but state and reason, which is what every config-dependent section renders when the config did not parse. There is no roll-up verdict for the machine as a whole: what counts as healthy is the consumer's policy, and the human report does not make that judgment either.

The document for a fully healthy machine, which is the fixture both renderers are tested against:

{
  "config": {
    "state": "ok",
    "path": "/etc/facelock/config.toml",
    "outcome": "valid",
    "device": { "selection": "configured", "path": "/dev/video2" }
  },
  "daemon": {
    "state": "ok",
    "bus_name": "org.facelock.Daemon",
    "reachability": "responding"
  },
  "oneshot_fallback": {
    "state": "ok",
    "auth_bin": "/usr/bin/facelock",
    "binary_present": true,
    "models_present": true,
    "database_present": true
  },
  "camera": {
    "state": "ok",
    "selection": "configured",
    "configured_path": "/dev/video2",
    "present": true,
    "device": {
      "state": "ok",
      "path": "/dev/video2",
      "name": "Integrated IR Camera",
      "ir": true,
      "quirks": []
    }
  },
  "models": {
    "state": "ok",
    "dir": "/usr/share/facelock/models",
    "dir_present": true,
    "files": [
      { "purpose": "detector", "path": "/usr/share/facelock/models/det.onnx", "present": true },
      { "purpose": "embedder", "path": "/usr/share/facelock/models/emb.onnx", "present": true }
    ]
  },
  "execution_provider": {
    "state": "ok",
    "configured": "cpu",
    "availability": "available"
  },
  "encryption": {
    "state": "ok",
    "key": {
      "method": "tpm",
      "sealed_key_path": "/etc/facelock/sealed.key",
      "sealed_key_present": true,
      "tpm_device_path": "/dev/tpmrm0",
      "tpm_device_present": true
    },
    "embeddings": { "state": "ok", "encrypted": 2, "plaintext": 0 }
  },
  "enrollment": {
    "state": "ok",
    "user": "alice",
    "models": [
      { "id": 1, "label": "front" },
      { "id": 2, "label": "side" }
    ],
    "marker": { "state": "ok" }
  },
  "security": {
    "state": "ok",
    "disabled": false,
    "checks": {
      "require_ir": true,
      "require_frame_variance": true,
      "require_landmark_liveness": false,
      "min_auth_frames": 3
    }
  },
  "notifications": {
    "state": "ok",
    "mode": "both",
    "delivery": { "prompt": true, "on_success": true, "on_failure": false }
  },
  "pam": {
    "state": "ok",
    "module_path": "/lib/security/pam_facelock.so",
    "installed_at": "/lib/security/pam_facelock.so",
    "services": {
      "state": "ok",
      "configured": [
        { "service": "sudo", "path": "/etc/pam.d/sudo", "action": "present" },
        {
          "service": "polkit-1",
          "path": "/etc/pam.d/polkit-1",
          "action": "present",
          "shadows": "/usr/lib/pam.d/polkit-1"
        }
      ],
      "not_checked": []
    }
  }
}

A machine whose config did not parse renders all nine config-dependent sections as {"state": "unknown", "reason": "config not available"}, plus whatever that section knows without the config: daemon keeps bus_name (a property of the design, not of the machine) and enrollment keeps user (resolved from argv and the environment, never from the file). The other seven carry state and reason alone. Meanwhile config reports the parse failure itself, and pam, which is config-independent, still answers in full.

facelock capabilities

facelock capabilities answers "what can this build do?" — from the binary's own clap tree and compiled-in constants, without reading a config file, activating the daemon, or opening a camera. It is what replaces facelock setup --help 2>/dev/null | grep -q -- "--no-pam" in a wrapper script: help text is not an API, and a grep against it breaks on a reworded flag description, a line wrap, or a translated help template.

Bare capabilities prints one name per line on stdout. --json prints one document on stdout. Both exit 0 — the command has no failure mode — and --quiet suppresses stdout entirely, leaving the exit code as the whole answer, as it does for is-enrolled. Neither form localizes: a capability name is an identifier, not prose.

The --json document, with the array elided — the names this build emits are the table at the end of this section:

{"version": "0.1.4", "capabilities": ["capabilities", "devices-json", "is-enrolled"]}

version is this binary's own version — byte for byte the one facelock --version prints. capabilities is a sorted, deduplicated array of strings.

Probe by name, not by version. A version comparison is the wrong test twice over: a git or distro build can carry a version that says nothing about what is in it (facelock-git is exactly that case, and is why a downstream package pin cannot express "needs the pam verb"), and a backport can add a feature without moving the number. The name list cannot drift from the binary it came out of: capability_names_are_all_implemented maps every name to the clap argument, subcommand or constant that declares it, and what each surface means is pinned by the section of this document that owns it. version is for humans and bug reports.

A build that predates the command answers by failing: clap's "unrecognized subcommand" error, usage text on stderr, exit 2, nothing on stdout. A caller reads any non-zero exit as "no capabilities at all", which is the true answer for that build.

Stability. The names are a contract of the same kind as the pam --json action vocabulary, one degree stronger:

  • a name, once emitted, never changes meaning
  • names are added; none is ever removed or repurposed
  • version and capabilities are always present, and a new top-level field is additive — a consumer ignores fields it does not know
  • a consumer tolerates a name it does not know rather than treating it as an error
  • key order within the JSON document is not part of the contract — parse the document, do not string-match it

Naming. Lowercase, hyphenated. A bare name (quiet, is-enrolled) means the command or global flag itself exists; <command>-<feature> names one feature of one command, and where the command's own name is hyphenated the suffix simply appends (is-enrolled-json). One name promises one thing: a flag that is not on this list is not being denied, only not yet promised.

NameMeaning
capabilitiesthis command exists, so a consumer's membership test is uniform across every name
config-editconfig edit exists — the verb ADR 009 split out of the old --edit flag
daemon-processfd-session-gatedaemon Authenticate supports the ProcessFD-backed logind remote-session gate documented under IPC Protocol
daemon-restartdaemon restart exists — the verb ADR 009 moved under daemon from the top-level restart
data-purgedata purge exists — the sanctioned path to destroy retained biometric state, which no removal path provides
data-purge-allow-destructiondata purge --allow-destruction exists as its own argument beside --yes, so a caller can verify that prompt suppression is not the authorization
data-purge-dry-rundata purge --dry-run
data-purge-jsondata purge --json
devices-jsondevices --json
is-enrolledis-enrolled exists — the unprivileged enrollment probe whose exit code is the contract
is-enrolled-jsonis-enrolled --json
pam-allow-sensitivepam add accepts --allow-sensitive, the gate on the sensitive services; pam remove does not offer it, because removal is never gated
pam-dry-runpam add/pam remove accept --dry-run
pam-if-presentpam add/pam remove/pam status accept --if-present
pam-jsonpam add/pam remove/pam status accept --json
pam-multi-servicepam add/pam remove/pam status take a repeatable --service — several services in one process, one root check
pam-remove-allpam remove --all exists, conflicts with --service, and uses compiled-root whole-set cleanup
pam-statuspam status exists — the unprivileged /etc/pam.d read (DEC-6 below)
pam-status-allpam status --all exists, and conflicts with --service — the enumerating form, which answers "what is configured on this machine?" rather than "is this name configured?"
quietthe global --quiet
setup-allow-sensitivesetup --pam accepts --allow-sensitive as the explicit sensitive-service authorization; --yes remains prompt suppression only
setup-if-presentsetup --pam --if-present, on add and on --remove alike
setup-no-pamsetup --no-pam
setup-systemdsetup --systemd
status-jsonstatus --json — the machine-readable system report, one key per section. See "facelock status Semantics"
tpm-decrypttpm decrypt exists — the verb ADR 009 moved under tpm from the top-level decrypt
tpm-encrypttpm encrypt exists — the verb ADR 009 moved under tpm from the top-level encrypt
tpm-resealtpm reseal exists — the verb ADR 009 moved under tpm from the top-level reseal

The five names ADR 009 added are the only way a wrapper can tell a build that takes daemon restart from one that still wants restart: the old spellings were deleted rather than aliased, so probing by invocation costs a failed command. Each promises only that the subcommand at that path parses.

CLI Privilege Model (DEC-6)

The CLI is root by default: every subcommand requires root except the six listed below, which are unprivileged by design, not by omission.

CommandWhy unprivileged
facelock is-enrolledAnswers from the caller's own 0600 marker file; the unprivileged integration point (see Exit Codes above). Never probes D-Bus
facelock hyprlock …Edits the user's own dotfile — root would write root-owned files into $HOME, which is wrong, not just unnecessary
facelock pam statusReads 0644 files under /etc/pam.d and writes nothing. Same role as is-enrolled: the probe an integration runs without sudo, replacing a hand-rolled grep -q pam_facelock.so /etc/pam.d/<service>. A file it cannot read reports unknown and exits 2 rather than reporting it as missing
facelock config [show]Reads a 0644 file. The rename split the flag into a verb (ADR 009) and the privilege split survives it exactly: config show, and the bare config that means it, stay unprivileged; config edit is root
facelock capabilitiesReports what the binary can do, derived from its own clap tree and compiled-in constants — no file, no D-Bus, no camera, no per-user state, so there is nothing to protect. Unprivileged because the consumer is a user-level setup script deciding whether to invoke sudo facelock … at all: a probe that needed root to answer "do I need root?" would be useless
--help, --version

Every other command requires root. Two escalation behaviors apply, and each command uses exactly one:

  • Interactive prompt. setup, enroll, test, preview, bench, tpm (including tpm encrypt, tpm decrypt and tpm reseal), daemon restart, config edit, remove, clear, list, status, devices. Run as non-root with a TTY attached, these ask Root required. Re-run with sudo? [Y/n] and re-exec via sudo on yes. Run as non-root with no TTY (scripted, piped, or closed stdin), they hard-error instead — Root required.\n Run: sudo facelock <cmd> — rather than hang waiting for input that will never arrive (ipc_client::require_root).
  • Hard error only. facelock daemon run, facelock pam add, facelock pam remove, facelock audit and facelock data purge never offer the interactive prompt at all, even with a TTY attached — each is typically invoked non-interactively or by a wrapper, where a stray confirmation prompt is a hang, not a convenience (ipc_client::require_root_scripted).

facelock daemon run is in the hard-error class because every shipped service unit invokes it, and a service manager must never be what a confirmation prompt is waiting on. daemon restart keeps the prompt: a human types it. Run by hand as a non-root user, daemon run refuses with the same Run: sudo facelock daemon run hint a unit would get, TTY or not.

facelock pam add|remove are in the hard-error class because the surface they replace was: standalone setup --pam bailed from its own root check rather than prompting. The check runs before --dry-run is honoured, so a dry run still needs root, and pam status is the unprivileged read to reach for instead.

facelock data purge is in the hard-error class for the same reason and one more: it is destructive. Offering to re-exec a purge under sudo would ask a user who has just been told the command needs root to re-authorize destroying their enrollments through a [Y/n] that reads like a convenience. Its root check also runs before --dry-run, before the destruction authorization, and before the confirmation prompt, so a non-root invocation refuses without having read a file, prompted, or touched the daemon.

Authorization is separate from prompt suppression. facelock data purge requires --allow-destruction in addition to root. --yes/--no-confirm suppresses the confirmation prompt and grants nothing; --json suppresses the prompt for the pipeline reason recorded under "CLI Machine Output" and grants nothing either. Neither implies the other, in either direction. This is the same split facelock pam add draws between --yes and --allow-sensitive, and it exists for the same reason: a wrapper that passes --yes to every command so scripts run unattended must not thereby have authorized a security-relevant or destructive action. --dry-run reports and deletes nothing, so it requires root but not the authorization.

facelock auth is not user-facing — PAM spawns it directly, and it is not part of this table.

Ordering guarantee (C6). Every command that prompts for confirmation or runs an interactive question runs its root check first, before that prompt or any other output or side effect. remove and clear both ask a Y/N confirmation before deleting a face model; historically remove's root check ran after that confirmation, so a non-root user would confirm a destructive action and only then discover it was refused — this is fixed.

The check also precedes the config parse. For the commands dispatched through the shared config load (enroll, remove, clear, list, test, preview, devices, bench, tpm …, audit), main runs the root gate (require_root_for) before ConfigLoad::read(), so a non-root caller is refused before the config file is read at all — a missing or broken config answers ("no config file at …") only to a caller that has already passed the root check, never ahead of Root required (issue #191). Commands dispatched ahead of that load (config edit, daemon run, daemon restart, setup, pam add, pam remove) keep the root check as the first statement in the command's entry point, before any println!. status is the one exception to gate placement, not to ordering: it holds the unresolved load so a broken config renders as a finding in its report, and its own root check still runs before its first line of output — the read alone has no user-visible effect ahead of the refusal.

enroll carries the check in both places. main's gate is what enforces the ordering above — root decided before the config parse — and enroll::run re-checks on entry, as a hard error, because setup calls it directly and run_with_plan's precheck is conditional on a base flow running. The second check is unreachable on every path that exists today; it is there so a future setup path that enrolls cannot do so unprivileged by omission (issue #288).

The rows in crates/facelock-cli/tests/cli_smoke.rs pin this ordering per gated command. They run the binary unprivileged even under a root test runner: both CI test jobs are root in a container, and a row that skipped there reported as a pass while asserting nothing (issue #189). They close stdin, so what they witness is that the refusal preceded the output, not which of DEC-6's two escalation classes produced it; require_root and require_root_scripted share one non-interactive branch and are indistinguishable to them. Two commands carry a second row that allocates a real pty and does pin the class: daemon run must never print the prompt with a terminal attached (issue #188), and enroll must always print it (issue #288). enroll's rows also pass --config at a path that does not exist, so its own backstop cannot answer in the gate's place and pass a row that the gate should have failed.

Dropping, never skipping. Every one of those rows drops the process it spawns to uid 65534 under a root runner, the pty rows included: a pty is opened by the parent before the fork, so the child inherits descriptors the kernel has already granted and can still read its own queued input after the drop. The backstop test in commands::enroll calls run in-process, where there is no child to drop, so it re-executes the test binary at itself and drops that. None of these return early under root. A test that skips on the one configuration CI runs is a test that asserts nothing while reporting a pass, which is how this contract lost its coverage twice (issues #189, #303).

AccessDenied hint. A D-Bus AccessDenied reply carries one actionable hint (ipc_client::add_access_denied_hint): root is required. Since almost every D-Bus method is root-only (see IPC Protocol below) and, under ADR 010, the bus admits a non-root caller to Authenticate alone, a denial from the daemon's require_root and a denial from the bus policy have the same fix. There is no group to join (ADR 010).

facelock test Semantics (N11)

facelock test is root-only (issue #96) and, being root, keeps full detail on both transports: on the daemon transport, AuthResult.similarity is redacted to non-root D-Bus callers only (redact_similarity_unless_root) — since test requires root, it always gets the real score. The direct transport never redacts.

test is a separate D-Bus method, not a privileged flavor of Authenticate. On the daemon transport facelock test calls the root-only TestAuthenticate method; Authenticate is real authentication only. The daemon does not infer which it is serving from the caller's UID, and must not: pam_facelock runs inside the PAM stack of the authenticating program, and sudo is setuid-root — as are login, su, and root-run display-manager greeters — so a real failed face authentication at a sudo prompt reaches the daemon as UID 0. A design that exempted root callers from rate-limit consumption therefore left the limit inert on the primary documented PAM target. Intent travels with the method call instead (AuthIntent in facelock_daemon::handler).

Both entry points run the same pre-flight gates — security.disabled, enrollment / suppress_unknown, the rate-limit check, and require_ir — via facelock_daemon::auth::pre_check_audited*. TestAuthenticate differs in exactly two documented ways:

  1. The abort_if_ssh / abort_if_lid_closed gates are skipped (PreCheckContext::test()). Those two exist to stop an attacker's physical-access shortcuts, not to block an admin who is already root (by construction, since test requires root) and is deliberately diagnosing recognition over SSH or with the lid closed on a docked laptop. This is a context flag threaded through pre_check, not a parallel copy of the gate logic (issue #95 was exactly that kind of drift). It applies identically on the direct transport, which calls pre_check_audited_with_context directly — the two transports no longer diverge here, as they did while test had no daemon-side method of its own to carry the context.
  2. A failed attempt consumes no rate-limit budget. The direct transport gets this structurally (direct::authenticate never calls RateLimiter::record_failure); the daemon transport gets it because TestAuthenticate is the entry point that does not charge. Root-only is what makes a budget-free authentication endpoint safe to offer at all — root already owns the database and can clear the limiter directly, so exempting consumption for it costs nothing.

Authenticate charges a failed attempt on every transport and for every caller including root — with one exception, added by ADR 008 §4: an attempt where the camera never saw a face charges nothing (face_detected == false, the -1 wire sentinel). Nobody was there, so no guess was made; a screen locker that starts face auth on every wake, or a laptop opened in front of an empty desk, would otherwise spend the user's whole budget before they sit down. A face that was seen and did not match (-4) still charges. The rule is identical on the daemon and one-shot paths, which share the rate_limit table.

Such an attempt also ends early, at recognition.no_face_timeout_secs (default 2, clamped to timeout_secs, 0 disables) rather than at timeout_secs; the outcome it reports is exactly the one the full timeout reports, so no client gains a case.

The rate-limit check (whether user is already over budget) is unaffected by any of the above and still runs on both methods and both transports: an already-limited user's test run reports "rate limited", exactly like real auth would — surfacing an existing lockout instead of masking it.

Operating Modes

ModeConfigPAM BehaviorCLI Behavior
Daemondaemon.mode = "daemon" (default)D-Bus IPC to daemonUses daemon if available, falls back to direct
Oneshotdaemon.mode = "oneshot"Spawns facelock authOperates directly (no daemon)

Backend-using CLI commands select their transport once. With the default configuration in daemon mode they check whether the bus name has an owner without activating it; when no owner is found they warn on stderr and use direct mode. A later daemon method error propagates instead of triggering a second direct attempt. Oneshot mode selects direct access without a bus probe or fallback warning.

Under a non-default --config in daemon mode, backend selection for enroll, test and the other backend-using commands does not ask the bus and selects direct access, saying so once on stderr (status's own daemon probe still runs). The packaged daemon reads only /etc/facelock/config.toml, so whatever owns the bus name is configured by a file the command is not reading; its answers would be about another store, camera, model set and security policy. This is a selection the operator made, not a degraded state, so it is not the fallback warning. Oneshot mode is unchanged: direct access is already its configuration.

facelock is-enrolled Exit Codes

The exit code is the contract — is-enrolled is designed to drop into a shell one-liner, so integrations should branch on the status, not parse stdout. The name follows systemd's is-* family (systemctl is-active, is-enabled), which is the established idiom for a boolean query whose exit code is the answer; the codes themselves match grep's 0 = match / 1 = no match / 2 = error.

CodeMeaning
0User has a usable enrollment
1Not enrolled / not usable (includes an unreadable or absent marker)
2Error — bad arguments, an unparseable marker, or an I/O failure other than absence or access denial

facelock pam status uses the same 0/1/2 scale for the same reason; see "facelock pam Semantics" above.

Default stdout is enrolled / not-enrolled — the state word, as systemctl is-active prints active. --quiet suppresses stdout and leaves only the exit code; it is the global -q flag, so facelock --quiet is-enrolled is the same invocation. --json emits {"enrolled": bool, "models": N, "updated": "<ISO8601>"}; when the user is not enrolled there is no marker to read a timestamp from, so models is 0 and updated is null.

is-enrolled reads the selected config to derive the enrolled/ directory beside storage.db_path, then answers from the user's marker. An unreadable or invalid config falls back to /var/lib/facelock/enrolled/<user>. It never activates the daemon over D-Bus, never opens a camera, and never reads the database — so it is safe to call repeatedly from a lock screen as an unprivileged user. The marker is a hint that can drift from the database; PAM at auth time remains authoritative and nothing in the auth path consults it.

Markers are written by enroll, remove and clear, and converged from the database by setup, by daemon startup, and by the one-shot facelock auth path. Convergence re-derives markers from the database rather than replaying recorded steps, so it is idempotent and there is no migration state to keep. The scope differs by caller and that difference is contract:

CallerScope
base setup flows, daemon startup (reconcile_all)Every marker: backfills each enrolled user and prunes every marker the database does not account for; standalone setup PAM/systemd actions do not reconcile
one-shot facelock authOne marker — the user being authenticated. It has no reason to read other users' rows and no privileged directory listing to prune with

An install upgraded from a release that predates markers backfills itself on the first daemon start or the first authentication; until one of those happens, is-enrolled reports not-enrolled for a user who is in fact enrolled.

On the one-shot path the convergence point is bounded on both sides, and both bounds are contract rather than convenience. It runs after the pre-flight gates, so an attempt rejected as disabled / SSH / lid / rate-limited / non-IR performs no marker write at all — no attacker-drivable filesystem work from the wrong side of the rate limiter. It runs before the camera is opened, so every later way an attempt can end — a signal, a failed model load, a camera another process is holding, an undecryptable template, the no-face timeout, a plain non-match — leaves the marker already converged. In short: an attempt that reaches the camera has converged the marker, whatever it goes on to decide.

That placement means the one-shot's write only ever converges a marker upward: reaching it requires the enrollment gate to have passed. The downward direction — a marker whose database rows are gone, which a daemonless install has no reconcile_all to prune — is handled at the gate that has the evidence: when the database authoritatively reports zero models for the user, the one-shot deletes any marker claiming otherwise before returning the rejection. That is a removal and nothing else: one unlink(2) on a single validated path component, no temp file, no chown, no rename, and no marker directory created. It is idempotent (a repeat attempt finds nothing to unlink) and it is reachable only when the marker is already false, so it can delete a stale marker and never a correct one.

facelock auth Exit Codes

The exit code is the only thing the PAM module learns from the oneshot fallback, and the two sides upgrade at different moments: a package update replaces /usr/bin/facelock on disk while every long-lived PAM host (a screen locker, sshd) keeps the pam_facelock.so it already loaded. There is no version handshake — the module maps the number and nothing else — so the table is governed by three frozen invariants:

  1. Exit 0, 1 and 2 keep their meanings permanently. 0 = matched, 1 = scanned and not matched, 2 = error / no opinion. They are never redefined or repurposed; a class that leaves one of them moves to a new code, never onto a changed meaning of an old one.
  2. New codes are allocated only from the space an older module already maps to PAM_IGNORE (any code ≥ 3 it does not know). Old module + new binary can therefore only move a class's consequence to PAM_IGNORE — never to PAM_SUCCESS or PAM_AUTH_ERR. For rate limited (3) and suppressed (4), which were exit 2 before the split, that is byte-for-byte the pre-split behavior. For all frames dark (5), moved off exit 1, it is a deliberate semantic change the moment the new binary ships: a dark scan stops failing the stack (PAM_AUTH_ERR) and abstains — the daemon transport's consequence — under every module generation.
  3. The module's arm for unknown codes stays PAM_IGNORE, so new module + old binary is unchanged too. The same arm absorbs a binary killed by a signal (no exit code; the module reads it as 2).

The "new module" column below is the module that ships alongside this table (the same release as the binary emitting codes 3–5); every earlier module maps those codes through its unknown-code arm.

CodeClassPAM code (new module)PAM code (older module)
0Face matchedPAM_SUCCESSPAM_SUCCESS
1Scanned, no matchPAM_AUTH_ERRPAM_AUTH_ERR
2Error / no opinion: disabled, SSH, lid closed, storage, rate-limit check failed, IR required, unverified Y16, internal, cancelled, not enrolledPAM_IGNOREPAM_IGNORE
3Rate limitedPAM_AUTH_ERRPAM_IGNORE
4Suppressed (no enrolled models + suppress_unknown)PAM_AUTHINFO_UNAVAILPAM_IGNORE
5All frames darkPAM_IGNOREPAM_IGNORE

Codes 3–5 exist so the oneshot fallback carries the same PAM consequence per class as the daemon transport: rate limited → PAM_AUTH_ERR, suppressed (-3) → PAM_AUTHINFO_UNAVAIL, dark scan → PAM_IGNORE. Before the split every pre-flight rejection collapsed to exit 2, so daemon unavailability silently softened a rate-limited rejection from PAM_AUTH_ERR to PAM_IGNORE; a dark scan diverged the other way, exiting 1 (PAM_AUTH_ERR) where the daemon abstains. Benign under the recommended sufficient stacking; wrong under required/requisite or an [authinfo_unavail=...] action.

Storage-shaped failures during the attempt — a model list that cannot be read, an embedding set that cannot be loaded or decrypted (a TPM unseal broken by rotated PCRs lands here on every attempt) — exit 2, the storage class, exactly as the daemon's -2 storage reply does. Neither may fold into exit 1: an empty compare set is a guaranteed "no match" that charges the rate limit and, under a required stack, locks the user out with the correct password in hand (the daemon handler refuses the same fold; C3, issue #105).

Residual transport divergence. With the classes above aligned, one divergence remains between a current module's two transports: a non-match where no face was seen (empty chair, recognition.no_face_timeout_secs, nothing above the detector threshold). The daemon reply carries that as -1/no-face and PAM abstains (PAM_IGNORE); the oneshot exit code has no face-seen channel, so the same attempt exits 1 (PAM_AUTH_ERR). Closing it needs another additive code under the same three invariants. During the upgrade window an older module additionally reads codes 3 and 4 as PAM_IGNORE (invariant 2) — the daemon transport under that same older module already carries both classes in-band, so the two transports keep today's divergence until the module updates. Timeouts (PAM_AUTH_ERR), cancellation (PAM_IGNORE), and camera/storage/engine failures (PAM_IGNORE) agree across transports.

The binary's half of the table is pinned in crates/facelock-cli/src/commands/auth.rs (every_rejection_class_pins_its_message_audit_label_and_exit_code, rejection_classes_never_claim_the_match_codes, the_preflight_short_circuit_pins_its_non_error_codes). The module's half lives in crates/pam-facelock/src/oneshot_exit.rs, a dependency-free file that facelock-cli's test suite include!s to pin the two halves against each other class for class (oneshot_exit_codes_map_to_the_daemon_transports_pam_codes) — the module cannot be linked there, so sharing the source is what couples them. The live module is exercised per code by the facelock-map-* fixtures in test/run-container-tests.sh.

Release Channels and APT Paths

dist/release-matrix.json is the checked-in release-target authority. A strict prerelease tag has the form vX.Y.Z-{alpha,beta,rc}.N; it creates a GitHub prerelease and direct artifacts, but it must not publish to stable APT, stable AUR, or production COPR. The staging COPR project exists; the remaining staging infrastructure is owned by issue #236, and neither is modified by the prerelease identity workflow.

A stable vX.Y.Z tag may publish to stable APT and AUR only after validated release metadata classifies it as stable. Production COPR additionally requires a deliberately restored trigger: release job in the stable-tagged Packit configuration. Prerelease-capable configurations keep that job inert. Preflight and CI compare the public tyvsmith/facelock COPR API read-only with the production chroot authority; they never change the project. The required supported production COPR chroots are exactly Fedora 43, Fedora 44, and Fedora 45. Rawhide is the only optional allowed experimental production chroot: its presence or absence is accepted, while any missing supported chroot or any other extra chroot fails closed.

Every Packit copr_build target must be an explicit member of the checked-in allowlist: fedora-43-x86_64, fedora-44-x86_64, or fedora-45-x86_64. Mutable aliases such as fedora-all, fedora-development, and their architecture-suffixed forms are rejected, as is any other undeclared target. Rawhide is not a Packit staging or production release target; both fedora-rawhide and fedora-rawhide-x86_64 fail validation. The prerelease rule is that no alpha may publish to Rawhide. Fedora 43 and Fedora 44 are the required full-lifecycle targets; Fedora 45 is a required build/runtime-smoke target. That evidence is the COPR lane per target described under "Packaging matrix evidence"; a direct-RPM result cannot supply it. Rawhide cannot supply lifecycle, artifact, upgrade, rollback, served-version, or availability evidence; it is limited to best-effort pinned Track D smoke only. It is non-release and non-gating: its absence or a Rawhide-only failure is not alpha-blocking, and its smoke result is not alpha acceptance or release evidence. Promotion requires a separately reviewed amendment and full Fedora gates.

The staging channel is tyvsmith/facelock-testing. .packit.yaml declares exactly one copr_build job for it; a release-triggered staging job is rejected, as is a second one. Its chroots equal the supported set exactly: staging declares no optional experimental chroot, so Rawhide there is drift rather than a permitted experiment.

copr_channels.staging.provisioned and the staging job's trigger are one contract. While provisioned is false the job must carry trigger: ignore and is dispatched by hand; when it is true the job must carry trigger: pull_request. Either value alone fails the release matrix contract, so the project cannot be declared live without the job that builds into it, and the job cannot chase a project that does not exist. The switch is true: the project exists with exactly the declared chroots. Provisioning it took three reviewed edits: the switch, the trigger, and retiring the assertion that held provisioned false until issue #236 created the project. manual_trigger stays true on either setting, so a pull request offers the staging build rather than starting it. While the switch is false, test/check-live-release-channels.py --channel staging reports not provisioned and contacts nothing; while it is true that comparison queries the live project on every pull request and in preflight.

Only an explicit provisioned: false skips a channel. copr_channels.production declares no such switch and must never grow one, so the production comparison always queries its authority.

A channel comparison covers three properties, not one. The live chroot set must match the channel's declared chroots. enable_net must be true, because the RPM builds from source and cargo fetches crates during %build. The project's Packit forge allowlist must contain copr_channels.<channel>.required_forge_project, which is github.com/tyvsmith/facelock for both channels. The packit user's admin permission is outside this contract: COPR serves project permissions only to an authenticated owner, so a public comparison cannot make that claim. It is the grant that failed silently for v0.1.4, which is why the release guide keeps it as a hand-confirmed setup step.

The project's enable_net is only the default for a build that carries no value of its own, and a Packit-submitted build always carries one. Every copr_build job in .packit.yaml must therefore declare enable_net: true as well; Packit defaults it to false, and that default is what the chroot gets no matter what the project says. test/check-release-matrix.py enforces the job half, because it is checked-in state that no public COPR response exposes until a build has already run and already failed (#347).

Those project properties are the shape of a channel, not its contents. A second comparison asks what it serves: test/check-live-release-channels.py --expect-evr <EVR> requires the project's latest succeeded build to carry the expected EVR. The latest succeeded build is what the channel serves; the latest build of any state says only whether the awaited one is running or dead. How strictly the EVR is read is the channel's served_evr_exact, and it tracks whether that channel's Packit job pins the release. Production carries update_release: false and is compared exactly. Staging keeps Packit's 1.{timestamp}.{ref} suffix, which its per-pull-request NVRs need, so it is compared as a prefix ending at a dot: 0.2.0-1 matches 0.2.0-1.20260904220135.v0.2.0 and never 0.2.0-11. The Packit flag and the comparison move together or the release matrix contract fails.

That build's chroot list is reported, never required. It is what the build covered, not what the repository serves, and a single-chroot rebuild becomes the latest succeeded build while an earlier complete one still serves the rest. Which chroots a channel must enable is the project comparison's contract.

Exit status separates a verdict from a wait — 1 for a build of the expected EVR that failed, was canceled, or was skipped; 2 for a build still running, never submitted, or a query that could not be made; only a poller distinguishes them, and every other caller treats both as failure. The release workflow's verify-copr job polls it after publication, because Packit submits the COPR build off the published release event and no job in the release run can observe that submission. just release-preflight runs --expect-predecessor, which resolves the EVR from the pinned predecessor's rpm_evr.

A release that production COPR never received may be recorded as copr_channels.production.served_evr_gap, naming the EVR owed, the EVR served, and the issue that owns it. The record is pinned at both ends: it must excuse exactly the pinned predecessor's EVR, and it stops matching the moment the channel serves anything other than the EVR it names. Both ends are read under the channel's own served_evr_exact, so on production a suffixed rebuild of the EVR the record names no longer matches it.

Only --expect-predecessor consults the record. A gap describes a release that already shipped without reaching COPR, which is preflight's question; verify-copr asks --expect-evr about the release it is publishing, and a record that could answer that would silence the job on the failure it exists to report. The record excuses no other release, and the next predecessor pin fails the matrix contract until it is updated or deleted.

A pre-tag attestation binds the candidate commit to the EVRs each channel serves, the artifact and repository digests, the signing key fingerprints, and how fresh each channel's repository metadata was. scripts/release-attestation.py renders and validates that document. No channel in it may carry the production COPR identity, and a channel carrying the staging COPR identity must serve exactly the declared staging chroots. The expectation file the validator compares against, metadata_max_age_seconds included, is reviewed release input produced with the release, not a value the attested channels supply; the validator checks that it is a positive integer and does not bound how loose a freshness window a reviewer may approve.

Issue #236 owns pre-tag and post-publication proof that optional Rawhide serves no alpha or candidate build. This contract does not provision, publish to, or otherwise mutate COPR or Packit infrastructure.

The public APT base is https://tysmith.me/facelock/apt/. Its stable suite paths and payload identities are:

SuitePublic Release pathArchitecturePackage
trixiehttps://tysmith.me/facelock/apt/dists/trixie/Releaseamd64facelock, TPM enabled
resolutehttps://tysmith.me/facelock/apt/dists/resolute/Releaseamd64facelock, TPM enabled
mainhttps://tysmith.me/facelock/apt/dists/main/Releaseamd64the trixie package, until 0.3.0
legacyhttps://tysmith.me/facelock/apt/dists/legacy/Releaseamd64none, until 0.3.0

The v0.1.4 suite names main and legacy are compatibility suites, not aliases or redirects, and neither ships a package of its own. Only main carries a package: the exact signed trixie package set. legacy, which served the non-TPM build, carries signed empty indexes so that apt update still succeeds. Both are published with every stable release through 0.2.x and both are removed at 0.3.0. dist/release-matrix.json declares them under apt_suites.compat with that retire_at. test/check-release-matrix.py requires their stanzas, the publisher steps, and the migration note while the window is open, and refuses the stanzas and the note from that version on, prereleases included; the publisher runs each step only when its stanza is declared, so retirement is the stanza deletion. Existing source entries must replace that suite with the host operating-system codename before 0.3.0 while keeping the facelock component. Debian-family release support is exactly Debian 13 (Trixie) and Ubuntu 26.04 LTS (Resolute). Bookworm and Noble artifacts may remain in historical releases, but those suites are unsupported and receive no new packages.

Both codenamed suites ship one binary package named facelock with TPM support enabled. There are no legacy/TPM package-name alternatives and the package declares no Provides, Conflicts, or Replaces transition identity. Stable publication consumes exactly two suite manifests, one matching package per suite, and a prerelease or cross-suite version is rejected before signing or repository writes.

Packaging matrix evidence

just release-preflight accepts packaging evidence only in the form test/packaging-evidence.py validates (#313). Each packaging lane writes one record, .packaging-evidence/<lane>.json, from the RESULTS_JSON: line its validator prints. just test-packaging-matrix deletes the previous marker and every record before its first lane, then folds the records into .packaging-matrix-verified:

{"schema": 1, "commit": "<40-hex sha>", "tree_clean": true,
 "started_at": "<ISO 8601 UTC>", "finished_at": "<ISO 8601 UTC>",
 "required_lanes": ["test-arch-pkg", "test-copr-pkg-43", "test-copr-pkg-44",
                    "test-copr-smoke-45", "test-deb-resolute-pkg", "test-deb-trixie-pkg",
                    "test-rpm-pkg-43", "test-rpm-pkg-44", "test-rpm-smoke-45"],
 "lanes": [{"name": "test-deb-trixie-pkg", "target": "debian-trixie", "channel": "apt",
            "build_origin": "container-source-build", "runtime_policy": "bundled-ort",
            "depth": "full", "commit": "<sha>", "models_present": true,
            "pass": 40, "fail": 0, "skip": 0, "allowed_skip": 0, "mandatory_skip": 0,
            "status": "pass"}]}

Lane claims use a fixed vocabulary. channel: apt, direct-rpm, aur, copr. build_origin: container-source-build (the Debian assembler and .dsc rebuild), host-binaries (release binaries staged from target/release), makepkg-source-build, mock-source-rebuild (Packit SRPM rebuilt from source in a mock chroot). runtime_policy: bundled-ort, system-ort. depth: full, smoke, partial. status: pass; partial for an exit 0 with an allowed skip or without models; fail for a non-zero exit, a failed assertion, or a mandatory skip. The counters are assertion counts by class, and skip equals allowed_skip (the FACELOCK_ALLOW_MISSING_MODELS=1 opt-out) plus mandatory_skip. models_present says whether the ONNX models were on hand for every assertion that needs them; a lane with no such assertion (test-rpm-smoke-45, test-arch-pkg) reports true because nothing was withheld.

required_lanes derives from dist/release-matrix.json: one Debian lane per APT suite whose platform row is a non-optional release target, the Arch recipe lane, and two Fedora lanes per fedora.packit_release_targets entry, both at the depth its platform rows declare. A row whose evidence_eligibility.lifecycle is false (Rawhide) contributes nothing.

The two Fedora lanes exist because the matrix declares two Fedora delivery paths and neither proves the other (#230). The direct-RPM lane (test-rpm-pkg-<release> / test-rpm-smoke-<release>) stages host-built binaries and a bundled ONNX Runtime. The COPR lane (test-copr-pkg-<release> / test-copr-smoke-<release>) is what COPR itself would publish: a Packit SRPM rebuilt from source in a mock chroot, installed with dnf so the package's own Requires: onnxruntime resolves against Fedora's system runtime, then booted for the same validation. A COPR lane is required for every Packit release target: test/packaging-evidence.py raises EvidenceError and refuses the aggregate outright if no platform row for that release declares system ORT, rather than treating the lane as optional. The artifact is checked against the COPR channel rules (validate-rpm.sh <rpm> copr), which fail if a bundled runtime rode along. Because the validator compares every lane attribute against what the matrix requires, a direct-RPM record offered as a COPR target's evidence is refused on channel, and a COPR record built from host binaries or run against a bundled runtime is refused on build_origin or runtime_policy.

full depth for an RPM lane is three stages under booted systemd: the RPM service/PAM lifecycle, test/pkg-validate.sh, and the %config(noreplace) upgrade lifecycle. Each is optional by the presence of its script in the image, so test/run-pkg-validate-systemd.sh records depth: partial when a stage is missing. No lane in the release matrix requires partial, so the aggregate refuses such a record instead of accepting a short lifecycle as a full one.

The third stage needs a second, higher-versioned package differing only in the config file. The COPR lane builds that upgrade candidate by repacking the payload mock just produced: binaries taken from the installed package, re-versioned to <mock version>.1, rebuilt through the same dist/facelock.spec with its cargo lines no-oped and bundled_ort still off, and re-checked with validate-rpm.sh <rpm> copr. It is the same trick the direct lane uses for its 0.0.0 to 0.0.1 pair. It is a fixture, not a second COPR build, and it is not byte-identical to one: what it pins is rpm's %config(noreplace) behaviour across an upgrade of a COPR-shaped package, not the reproducibility of a mock rebuild.

The marker is refused, and the aggregate refuses to write it, unless all of the following hold: schema is 1; commit equals HEAD; tree_clean is true; started_at and finished_at are ISO 8601 timestamps with an offset, in order; required_lanes equals the derived list exactly (sorted, no duplicates); every required lane has exactly one record and no record names a lane outside that set; and every record carries schema 1, names HEAD, has models_present true, has fail, skip, allowed_skip and mandatory_skip all 0, has pass of at least 1, has status pass, and carries the target, channel, build origin, runtime policy and depth the matrix requires of that lane. A partial run therefore never produces release evidence, and a record from one channel cannot stand in for another's lane.

The legacy one-line commit marker is refused with a message naming this format. Preflight reads evidence from two places, in order: the packaging-evidence-* artifacts a successful packaging.yml run at HEAD uploaded (packaging-evidence-deb-<suite>, packaging-evidence-rpm-<release>, packaging-evidence-copr-<release> and packaging-evidence-arch, fetched with gh run download and aggregated the same way), then the local marker. .packaging-evidence/ is dot-prefixed, so every upload step sets include-hidden-files: true; without it actions/upload-artifact finds no files and if-no-files-found: error fails the job. A run without those artifacts is not evidence, whatever its conclusion; neither is a pull-request run, which builds the merge commit, nor a run of any other workflow. The copr jobs do not run on pull requests at all, so only an unfiltered run -- the nightly or a workflow_dispatch -- can carry the full lane set. The marker and the artifacts are maintainer-trust records: preflight checks their shape and their binding to HEAD and the matrix, not that a real lane produced them. A forged record is a deliberate act, not the slip this gate exists to catch.

Direct release publication

Builders in .github/workflows/release.yml produce workflow artifacts and never touch the release. They hold contents: read under a deny-all workflow default, and none of them may reference the publication credential or a release-writing step. publish is the only job that writes the release, the only one holding contents: write and RELEASE_PAT, and the only one that compiles nothing. It runs after every builder and validator; a tag has no release until it does.

publish requires all of, in order:

  • The tag exists, equals the tag the validated version derives, and points at the commit the workflow built. An annotated tag is peeled on both sides. A tag carrying a PGP or SSH signature must verify. Publication reads the tag; it never creates, moves, or replaces one, and it never sends a tag name or target commitish.
  • Exactly the canonical assets can be staged out of the builders' artifacts, no more and no fewer:
AssetProduced by
facelock-x86_64-linux-gnubuild
pam_facelock.sobuild
facelock-polkit-agent-x86_64-linux-gnubuild
facelock_<debian-version>_<architecture>.deb, one per published suitebuild-deb
facelock-<rpm-version>-<rpm-release>.fc<N>.x86_64.rpmbuild-rpm
facelock-debuginfo-<rpm-version>-<rpm-release>.fc<N>.x86_64.rpmbuild-rpm
facelock-debugsource-<rpm-version>-<rpm-release>.fc<N>.x86_64.rpmbuild-rpm
apt-repo.tar.gz, stable releases onlypublish-apt
MANIFEST.jsonpublish

The Debian and RPM names come from the validated version, Debian revision, and RPM counter, so an artifact built from any other identity has no canonical name and is never staged. build-rpm selects each of its three packages by that identity and validates the payload package. A duplicate name, an unmatched asset, a canonical name two artifacts claim, or a canonical name no artifact provides each fail closed. A canonically named file in an artifact other than its producer's is a builder's extra output; the release fails closed on it, and the failure names the remedy: fix the builder and re-run all jobs, since re-running only the failed job keeps the artifact. build and build-rpm compile through just build-release, the recipe just install and the packaging lanes use, which carries the tpm feature; build fails if the resulting binary does not link libtss2-esys. build compiles in the pinned trixie image build-deb uses, not on the hosted Ubuntu runner: noble's tpm2-tss 4.0.1 is below the 4.1.3 tss-esapi-sys needs and its just 1.21.0 does not parse the justfile. The job attests that image, the attesting set pins it for the build slot, and the release artifacts contract holds the container image, BUILD_IMAGE, and the trixie matrix image equal.

  • Every staged asset matches the SHA-256 its builder attested. Each builder writes a release-digests-<slot> artifact naming what it produced, the image it produced it in, and the components it consumed. An asset attested by no builder, or by two, fails closed, and so does a build image or component two attestations claim.
  • The attesting set is exactly the slots the workflow uploads: build, onnxruntime, cargo-vendor, one deb-<suite> per published suite, rpm, and, on a stable release, apt. Each artifact holds one document declaring the job that slot belongs to. Anything running in a builder can upload an artifact of its own, so an extra attestation, a missing one, or one claiming another job's identity stops the release rather than being merged into the manifest.
  • Each slot declares exactly the provenance the release expects of it. An attestation is a self-report, so the suite a slot fills, the image dist/release-matrix.json pins for it, and the component names it may carry are held by publish and compared exactly: a swapped image, an invented suite, an added or missing component, or a field the release has no rule for is refused. The matrix is an input publication cannot do without; a matrix that cannot be read, or that names no Debian suite, stops the release rather than shortening the allowlist.
  • Every attestation hashes to the output its job recorded. Artifacts are untrusted until bound to a job output: the artifact store is shared by every job in the run and writable with any job's runtime token, so a builder that runs later can replace an earlier builder's payload and attestation as a matching pair. A job output is recorded by the Actions service under the job that produced it and cannot be rewritten by another job. Each attesting job records the SHA-256 of its digests.json as its attestation output (build-deb records attestation-<suite>, each matrix leg setting only its own); publish reads them through toJSON(needs), passed by environment and file rather than a shell line, and refuses by slot any attestation whose bytes differ from the recorded value or whose job recorded no output, before anything in the document is read. verify-digests and manifest share that loader, so neither can skip it.
  • The tag has no published release. A release already published is refused before anything is written; the draft an interrupted run left behind is reused, so a failed run can be re-run. An asset on that draft whose canonical name changed in between, after a Debian revision or RPM counter bump, is refused as unexpected and must be deleted from the draft by hand. Two releases for one tag are refused with the gh api --method DELETE command that removes the extra one.

The workflow runs once at a time per tag: concurrency is keyed by the ref and never cancels the run in progress, so a second run queues rather than passing the checks above beside the first. build-nix gates publication through its flake evaluation; its nix build step is advisory.

The draft is created with those assets and the validated prerelease flag, and is flipped to published only after the draft's asset list is read back from the API and held to the allowlist a second time, MANIFEST.json now included, and each published asset's size, and digest where the API exposes one, is held to MANIFEST.json, the uploaded manifest to the file the job wrote.

MANIFEST.json covers the release: the tag, version, commit, and prerelease flag; the source tarball URL and digest; the pinned build-image digests; the reviewed ONNX Runtime and Cargo-vendor component digests; and the name, size, and SHA-256 of every other asset. It replaces the SHA256SUMS file that covered three binaries and was written before the packages existed. facelock-bin takes its per-binary checksums from it.

Debian source and binary package contract

Trixie package builds use the official Trixie Backports cargo and rustc; Resolute uses its native distro packages. Both must satisfy the workspace and debian/control minimum of Rust 1.88. No rustup toolchain participates in Debian source builds.

The Debian source package contains the exact tagged main upstream tarball, the reviewed ORT component, the deterministic Cargo-vendor component, and the Debian quilt delta. For upstream U, Debian version V, and architecture A, the release manifest lists exactly these eight files in canonical order:

facelock_U.orig.tar.gz
facelock_U.orig-onnxruntime.tar.gz
facelock_U.orig-cargo-vendor.tar.xz
facelock_V.debian.tar.xz
facelock_V.dsc
facelock_V_A.buildinfo
facelock_V_A.deb
facelock_V_A.changes

The Cargo component is bound to the exact Cargo.lock, contains only regular normalized files plus its lock hash, bytewise manifest, and generated legal inventory, and is used through the package-only Cargo source replacement. The inventory covers every exact vendored crate and records its path, name, version, declared license or license-file, available authors/upstream metadata, and every referenced license material that exists in the component. The ORT component contains the reviewed library, license, third-party notices, version, commit, provenance, manifest, and checksums. Neither component is added to the tagged main archive.

Complete .dsc rebuilds run with network denied and empty Cargo/Rustup caches. The build uses only the extracted source components and declared distro build dependencies, with Cargo locked and offline. The clean rebuild must produce the same package identity, resolved dependencies, installed path set, and installed file hashes as the release build. Fresh installation leaves facelock-daemon.service disabled and inactive; D-Bus activation remains available after explicit setup. Reinstall and upgrade restart the daemon only when it was already active, including an active D-Bus-activated instance; they preserve both enabled and disabled state and leave every inactive instance inactive. The post-install convergence still removes the retired facelock group, fixes ADR 010 ownership, leaves every legacy /etc systemd/D-Bus copy untouched, asks the bus to reload policy, and registers the opt-in PAM profile without selecting it. Exact known legacy-copy migration belongs to facelock setup --systemd; package configuration never overwrites an administrator-owned shadow. Package validation requires the installed TPM command surface and the suite-native libtss2 dependency closure.

Two lanes read the same declared Depends. One resolves the exact candidate on a pristine suite base and requires every declared dependency to be installed there. The other installs it into the booted harness image, which also carries systemd, a C toolchain, and a software TPM, and requires every declared dependency to be shipped by the suite base, named by the harness exemption list with its reason, or resolved by the runtime transaction itself. Both directions of that list are enforced: an unlisted dependency the harness satisfies fails the gate, and so does a listed pattern that no longer matches one. The suite-base record that verdict rests on is taken before its stage installs anything, held there by a static read of the harness Containerfile, and compared at run time against that stage's own package database. Supplying an exact package to a harness image that carries no lifecycle script is a failure, not a skipped lane.

Release validation runs lintian on every built binary package and fails on error-severity tags. Deliberate deviations are suppressed in .github/workflows/scripts/validate-deb.sh, each with a recorded reason; warning and lower severities are reported without gating.

Compat 13's generated dh_installtmpfiles post-install snippet is the sole install-time tmpfiles activation and invokes systemd-tmpfiles for facelock.conf only. The source postinst never runs a global tmpfiles create, so another package's configuration cannot be activated by a Facelock transaction.

Trixie's debhelper 13.24 omits its remove-only service stop when dh_installsystemd --no-start is used, while Resolute's debhelper 13.31 emits it. The package build therefore appends debhelper's canonical prerm-systemd-restart template for the exact Facelock unit only when that stop is absent. This compatibility path is idempotent: both suites produce exactly one stop after successful PAM cleanup, neither starts or enables the daemon on installation, and only the generated purge path retires enabled state.

ONNX Runtime Trust and Fedora RPM Modes

ONNX Runtime (ORT) is executable code loaded into the daemon, the PAM-spawned oneshot helper, and other privileged Facelock processes. A runtime must therefore be selected deterministically and validated before it is mapped. Loading a bare libonnxruntime.so.1 through the dynamic linker's ambient search path and inspecting it afterward is forbidden: ELF constructors may already have executed before any post-map rejection.

Deterministic candidate order

The resolver considers candidates in this order and stops at the first one that passes the applicable trust checks and initializes ORT:

  1. A non-empty ORT_DYLIB_PATH, only in an unprivileged process.
  2. Trusted system locations for the configured GPU provider, or for auto, which searches every one of them since it does not yet know which provider it will pick. ROCm and auto first check libonnxruntime.so.1 beneath /usr/lib64/rocm/lib, then /usr/lib/rocm/lib; any non-CPU provider, auto included, then checks the configured-GPU compatibility name libonnxruntime.so beneath /usr/lib64, then /usr/lib.
  3. Package-manager stable-SONAME candidates /usr/lib64/libonnxruntime.so.1, then /usr/lib/libonnxruntime.so.1.
  4. Facelock package-owned stable-SONAME candidates beneath /usr/lib64/facelock, then /usr/lib/facelock, followed by the existing package-owned unversioned Debian compatibility names in those same roots.

The CPU provider skips step 2. A system runtime therefore precedes a bundled CPU fallback even in a direct package. A missing or rejected candidate advances to the next fixed candidate; no other directory is searched.

A process is privileged when its real or effective UID or GID is 0, its real/effective UID or GID differs, the kernel marks it AT_SECURE, or the calling thread has any inheritable, permitted, effective, or ambient Linux capability. Capability inspection reads /proc/thread-self/status, never the thread-group leader's status; an unreadable file or a missing, duplicate, empty, or malformed capability field fails closed as privileged. Every such process ignores ORT_DYLIB_PATH entirely and has no /usr/local candidate. The explicit override is an unprivileged caller choice: it is still opened and checked as a bounded ELF with the required architecture and SONAME before mapping, but it does not claim package-manager root ownership.

Privileged pre-map validation

Every privileged system or bundle candidate has a fixed approved trust root and a normal relative path beneath it. One descriptor-held component walker is used on every kernel, with no alternate or weaker kernel-version path. The loader:

  • resolves a symlinked trust root by path before any open — merged-/usr distributions ship /usr/lib64 as a link to lib — following only root-owned, single-link symlinks with confined relative targets; an absolute or escaping target is rejected even when it names an approved location
  • requires the resolved trust root, each ancestor, and every traversed directory to be root-owned and not group- or world-writable; the resolved root is opened and retained with O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_NONBLOCK, still refusing a link at open time
  • inspects every relative component and link through a held parent descriptor using O_PATH|O_NOFOLLOW|O_NONBLOCK; directory links, absolute targets, non-normal targets such as . or .., and paths that escape and later return beneath the root are rejected
  • follows only a root-owned, single-link, relative package SONAME chain beneath the held root; every link target is decomposed and walked again from held descriptors rather than resolved by an ambient pathname lookup
  • opens the final object through its held parent with O_RDONLY|O_NOFOLLOW|O_NONBLOCK, then requires a bounded regular file with exactly one hard link, root ownership, no group/world write bits, no setuid/setgid bits, and no security.capability xattr
  • requires device, inode, link count, size, UID/GID, mode, modification time, and change time to remain stable across component inspection, the final open, the bounded read, and the last pre-map check
  • requires a 64-bit ELF for the running architecture, SONAME exactly libonnxruntime.so.1, and no RPATH/RUNPATH entry except exactly $ORIGIN or ${ORIGIN}

Only after every check passes is the same held read descriptor mapped (for example through /proc/self/fd/<fd>). No pathname is reopened after validation.

If every candidate is missing, rejected, or fails ORT initialization, model loading fails and authentication degrades through its existing password fallback. Authentication never downloads a runtime or model.

Fedora package modes

dist/facelock.spec has two mutually exclusive ORT modes:

RPM channelSpec modeRuntime payload and dependency contract
GitHub direct RPM (Fedora 44)--with bundled_ortInstalls the pinned CPU runtime as %{_libdir}/facelock/libonnxruntime.so.1.20.1 with a package-owned libonnxruntime.so.1 symlink; carries no BuildRequires or Requires on Fedora onnxruntime
Packit/COPR (Fedora 43/44/45)default %bcond_with bundled_ort disabledContains no bundled ORT library or bundle metadata; BuildRequires and Requires Fedora's runtime-only onnxruntime package, with onnxruntime-devel absent

The COPR %check constructs a real ORT session from the checksum-pinned minimal model in test/fixtures/; finding a library or running facelock --version is not a substitute. The two RPM validators independently reject a direct RPM with a system-ORT dependency and a COPR RPM with bundled payload, and reject the inverse missing dependency/payload.

Track D validates only direct/COPR build success, real ORT runtime initialization, and the intended payload and dependency policy. It supplies no clean-install, upgrade, erase, rollback, served-repository, availability, alpha-acceptance, or release evidence. The two-Fedora-lanes contract under "Packaging matrix evidence" above owns exact-artifact package lifecycle proof; issue #236 owns staging and production repository publication and served-version proof.

Optional experimental Rawhide may attempt only the separately digest-pinned, best-effort system-ORT build/session smoke. It is non-release and non-gating, must not publish or modify a COPR channel, and cannot substitute for any supported Fedora result or any lifecycle, artifact, served-version, availability, alpha-acceptance, or release evidence.

Reviewed direct-bundle identity

The direct RPM bundle is exactly:

FieldReviewed value
Version1.20.1
Upstream archivehttps://github.com/microsoft/onnxruntime/releases/download/v1.20.1/onnxruntime-linux-x64-1.20.1.tgz
Archive SHA-25667db4dc1561f1e3fd42e619575c82c601ef89849afc7ea85a003abbac1a1a105
Upstream commit5c1b7ccbff7e5141c1da7a9d963d660e5741c319
Library SHA-256a5faaf78a37590d3fe640f887620e74f6022d34550172b91ad2131bf0ad77d64
License identityMIT

The release network stage downloads the archive to a file and verifies the archive digest before extraction. It then verifies VERSION_NUMBER, GIT_COMMIT_ID, and the library digest against the reviewed values. Streaming an unverified response into tar is forbidden.

The prepared bundle contains the exact library plus upstream LICENSE, ThirdPartyNotices.txt, VERSION_NUMBER, and GIT_COMMIT_ID, and generated PROVENANCE.md, manifest.json, and SHA256SUMS. The checksum file covers the library and every listed metadata/provenance file except itself. Direct RPM assembly requires and re-verifies the complete prepared bundle.

Before creating the source archive or any rpmbuild tree, the whole assembly enters .github/workflows/scripts/run-networkless.sh. That wrapper uses util-linux enosys as a fail-closed seccomp boundary: it denies socket creation/connection and message syscalls plus io_uring_setup, closes every inherited non-stdio file descriptor, and requires a socket probe to fail with ENOSYS before it invokes the assembly command. Cargo offline mode remains defense in depth; it is not the network-isolation boundary.

The installed libonnxruntime.so.1.20.1 bytes must retain the exact reviewed library digest above. Fedora strip/debug/post-processing must not rewrite the pinned runtime; bundled mode disables the modifying strip hook, and validation extracts the final RPM member and checks its digest. The RPM also ships the license, notices, version, commit, checksums, provenance, and component manifest under its documentation/license directories.

Those metadata files are inputs for later SBOM, release-manifest, attestation, and signing work. Their presence does not claim that Track D generated or signed a final SBOM/manifest, signed the RPM, or published a release. Issue #235 owns native signing and final immutable direct-artifact publication.

RPM tmpfiles transaction

The RPM transaction creates Facelock's runtime directories through the package-scoped %tmpfiles_create facelock.conf invocation. It must not run a global systemd-tmpfiles --create or otherwise process unrelated packages' tmpfiles configuration. Package validation observes the directories created by the actual install transaction and does not manufacture them with a later global tmpfiles command.

Package Lifecycle Ownership

This is the Wave 0 ownership freeze for issue #232. It defines what package lifecycle work is allowed to remove, and the Debian post-removal script implements the bounded purge described below. Ordinary removal is not data deletion. Ordinary remove remains preservation-only: removing the package must leave a machine reinstallable without losing its biometric or operational state.

facelock data purge is the explicit authorized destruction path, and it is not a removal path. It does not weaken the rule above; it is the reason the rule can hold. Because ordinary removal preserves biometric state, a user who genuinely wants that state gone needs somewhere to say so, and this is the only place. The two are separated on every axis: removal is triggered by a package transaction, purge by a person typing a verb; removal preserves, purge destroys; removal needs no authorization beyond the package manager's, purge needs root and --allow-destruction. No removal path, on any package family, may invoke it, imply it, or acquire its authorization on the user's behalf, and no packaging script may recommend recursive deletion of retained state in its place — reject_state_purge_command in test/lifecycle-ownership-contract.sh fails the build if one does. The sanctioned answer to "how do I delete my face data" is this command, and nothing else.

The ownership classes are deliberately separate:

ClassExamplesOrdinary removal
Package-owned static integrationbinaries and shared libraries, systemd/OpenRC/runit/s6 units, D-Bus policy and activation, tmpfiles configuration, shipped quirks, PAM/authselect profiles, translations, bundled runtime librariesRemove through the package manager. These files can be recreated byte-for-byte by reinstalling the package
Administrator configuration/etc/facelock/config.toml and the package manager's saved replacement for an administrator-modified copyApply the native package-family rules below. Do not treat administrator configuration as biometric state or as disposable static integration
Biometric and operational statethe database and its WAL/SHM sidecars, encryption keys and sealed keys, downloaded models, enrollment markers, setup state, audit logs, and snapshots under the compiled rootsPreserve all of it. A reinstall reuses it; ordinary removal never interprets absence of the package as consent to discard it
PAM integration and provenancea pam_facelock.so rule, a Facelock-created local override and its provenance header, legacy <service>.facelock-backup files, and rollback state under /var/lib/facelock/pam-backupsAttempt safe cleanup while the binary is still present. Delete provenance only after the corresponding PAM cleanup is proven complete
Externally configured stateany database, model directory, key, sealed key, audit log, or snapshot path configured outside the compiled Facelock rootsNever package-owned. Leave it untouched and report it as an external remnant

PAM provenance and rollback files are not biometric state. They exist to explain or reverse an authentication-stack edit, so retaining all of them forever makes an otherwise successful uninstall look incomplete. Conversely, deleting them before the PAM edit is known to be gone destroys the evidence and rollback path for a service that still references a removed module.

Preserve PAM provenance when cleanup is incomplete; remove it only after successful cleanup. Successful cleanup means the service file was safely resolved inside /etc/pam.d, its Facelock rule was removed (or was already absent), and any candidate override or backup was proven to be Facelock-created and no longer needed. A Facelock-created override may be deleted to reveal its vendor file only when it has no administrator changes. Never restore a backup over a newer service file merely because the backup exists. An unreadable, unwritable, wrong-owner, non-regular, changed, linked, or mount-separated service file makes cleanup incomplete: preserve its override, provenance header, and .facelock-backup, and report the exact remnant. Cleanup of one service does not authorize deleting provenance for a different service. The final Debian postrm never interprets rollback schemas. If /var/lib/facelock/pam-backups is nonempty after the earlier binary-backed cleanup, it retains and reports the entire opaque subtree; only a trusted empty directory is eligible for ordinary fixed-root removal.

Native configuration lifecycles

The package families reach the same ownership result through different native mechanisms:

Family and operationAdministrator-configuration contract
Debian remove/etc/facelock/config.toml is a Debian conffile and remains at its installed path. Biometric and operational state also remains
Debian purgedpkg removes the conffile, and the post-removal purge may then remove only safe remnants inside the compiled roots. Unsafe and external remnants are retained and reported
RPM erase/etc/facelock/config.toml is RPM %config(noreplace). An unmodified copy is removed outright and leaves nothing behind. An administrator-modified copy leaves config.toml and is retained, byte for byte, as config.toml.rpmsave. A .rpmsave is retained state, not evidence of a failed erase and not something a Facelock script deletes
RPM upgradeAn unmodified /etc/facelock/config.toml is replaced in place by the new packaged file, with no .rpmnew and no .rpmsave. An administrator-modified one is kept byte for byte at its own path and the new packaged file arrives as config.toml.rpmnew, which Facelock never activates or deletes. .rpmnew is written only when the packaged config actually changed between the two versions
Arch package removalthe backup entry follows pacman's native saved-configuration behavior (including .pacsave when applicable). Facelock lifecycle code does not bypass it
just uninstallno package manager owns the config, so the source-install uninstall preserves /etc/facelock with the biometric and operational state

Source-install daemon lifecycle

The privileged install and uninstall entrypoints invoke absolute /usr/bin/sudo, /usr/bin/env, and /usr/bin/just paths with PATH=/usr/bin:/bin. The install-files and uninstall-files recipes use the exact #!/usr/bin/bash -p interpreter, set that fixed path before their first operation, and reject caller-supplied Bash startup hooks through the structural gate. Directly executed production helpers also use /usr/bin/bash -p and pin their production path. The root-owned checkout and another actor with root or equivalent filesystem authority remain trusted inputs.

just install-files serializes its protected interval with /run/facelock/lifecycle.lock. It safely creates or validates /run/facelock as root:root mode 0755 (the fixture-root identity in offline tests), then captures the never-unlinked lock as a root:root mode 0600, zero-byte, single-link regular file without following the public path. The lock stays held through manager/D-Bus restoration, migration publication, signal cleanup, and descriptor release. This is the canonical cross-entrypoint lifecycle lock path; other lifecycle entrypoints join it in their owning work.

Before its first replacement write, the installer records exactly one service load, active, unit-file, fragment, effective command, and drop-in state. It admits only loaded active/inactive service state or a genuine first install. Probe failures, transitional/failed states, inconsistent manager/disk state, untrusted effective assets, a concurrent lifecycle holder, and malformed or duplicate properties abort before mutation. Persistent and runtime ordinary unit paths are snapshotted independently. An admitted path is absent, an exact administrator /dev/null or empty-file mask, or a bounded trusted regular override retained by descriptor, metadata, and digest. Systemd precedence is preserved; linked, multiply linked, writable, or changing state fails closed.

Every admitted online state receives an owned activation barrier at /run/systemd/system.control/facelock-daemon.service. The no-clobber-created, descriptor-held root:root mode 0600 empty regular file is manager-proved as the effective mask before the daemon is stopped and before any install write. The helper also proves the active system bus, its standard service-directory topology, selected Facelock activation definition, policy-only includes, bus owner state, and systemd delegation. Custom or unreadable activation topology, direct D-Bus execution, or a higher-priority control conflict aborts. The source installer never changes enablement and restores only the active/inactive state captured at entry.

The source recipe writes service assets only to the canonical /usr paths. It plans all three historical /etc public/quarantine pairs before the protected writes, including exact known copies, interrupted exact quarantines, administrator files, and systemd masks. After the canonical writes, it moves only reviewed exact historical copies to fixed same-parent quarantines with no-replace semantics. Trusted modified administrator files and masks remain at their exact recorded identities; a selected administrator D-Bus activation definition must still delegate to systemd. Unknown, linked, wrongly owned, wrongly moded, changing, or ambiguous state fails closed.

Staging is not publication. Quarantines remain rollback-capable while later source writes and permission fixes run. A recipe failure or HUP, INT, or TERM restores every newly staged public name in reverse order before manager and D-Bus restoration; a pair that was already an interrupted quarantine is restored to that same pre-run state. The lifecycle treats the staging child and its exact parent-side identity record as one signal-critical operation. The child also traps exit and caught signals to reverse its locally known prefix; parent cleanup independently reconciles unchanged or staged names against the complete preplan even when the record was interrupted. Any mixed pair that cannot be restored without replacement remains preserved and keeps activation barred. Normal completion reloads and proves the canonical winners while the activation barrier is still effective, then revalidates and deletes the exact quarantines. Only after that commit and another topology proof may cleanup quarantine/remove the barrier and restart an initially active daemon. A pre-publication commit failure rolls back, reloads, and proves the original winners while still barred. A collision, partial publication, incomplete rollback, or unprovable manager/D-Bus state retains the safest provable barrier and suppresses restart.

Barrier cleanup itself uses held descriptors, fixed same-parent quarantine names, no-replace recovery, bounded retries, and repeated disk/manager/D-Bus proof. An inactive or first-install daemon remains inactive. The initiating failure or signal remains the process result, and signal cleanup is non-reentrant until restoration and lock/descriptor release finish.

Ordinary source install fails closed without systemd. The sole exception is the checked-in test/Containerfile offline image step, selected by its exact mode and marker. It authenticates the copied lifecycle/migration helpers and digest manifest, takes the same canonical lock inside the image, and proves no manager, bus, activation asset, installed Facelock indicator, or unreadable process identity exists before allowing writes. Distro maintainer scripts own their separate native lifecycle. The mocked/static gate is just test-source-install-daemon-lifecycle; the mount-free PID-1 gate is just test-source-install-daemon-lifecycle-systemd.

Debian postrm purge is self-contained. It never invokes the already-removed facelock binary. By the time postrm runs, package payloads cannot be treated as available cleanup tools. The bounded purge makes its decisions from fixed constants and the remaining filesystem state and uses the Essential perl-base interpreter available throughout the package lifecycle; it cannot delegate safety checks or deletion to the CLI it is purging. The supported Debian-family artifacts are explicitly Architecture: amd64. For quarantine and recovery, the helper therefore invokes x86-64 renameat2(RENAME_NOREPLACE) directly through Perl's syscall; an unsupported architecture, kernel, or filesystem fails closed and retains the entry. It never falls back to an absence check followed by a replacing rename.

The post-removal script classifies the six supported configured path fields while the conffile is still available during remove, and repeats that report at purge when a configuration file remains. Its bounded TOML classifier recognizes canonical section assignments plus dotted and quoted key or table components with their TOML table scope intact. If a valid representation is outside that bounded grammar, including a multiline string, the script emits a controlled warning that the configuration could not be fully classified. An external value is reported but never opened, removed, or used as a traversal root. A configuration object that cannot be safely inspected and is reported as retained is protected from the later generic walk. The report therefore survives dpkg's normal ordering, in which it can remove the conffile before the final postrm purge call, without turning configuration into deletion authority.

RPM and Arch have no Debian-style second purge phase. Their ordinary erase therefore removes static integration and safely cleaned PAM provenance, while preserving biometric state and whatever administrator-configuration artifact their package manager retained.

CLI lifecycle exclusion lease

Destructive CLI maintenance (the facelock data purge work, #233) has no package transaction to own its exclusion interval, so the facelock binary establishes one itself through an RAII lifecycle lease. Acquisition takes the canonical /run/facelock/lifecycle.lock — the same never-unlinked lock the source install holds — with a non-blocking exclusive flock on a validated mode 0600 zero-byte single-link regular file opened without following the public path; refuses to proceed without a booted systemd; proves every existing org.facelock.Daemon.service activation definition delegates through SystemdService=facelock-daemon.service, because a unit mask cannot inhibit direct D-Bus execution; and records the unit's active and enabled state, admitting only loaded or not-found, active or inactive state. It then creates the owned activation barrier — an empty mode 0600 regular file at /run/systemd/system.control/facelock-daemon.service, the same mechanism the source install uses — reloads the manager and proves LoadState=masked before stopping the daemon and proving both ActiveState=inactive and that nothing owns org.facelock.Daemon on the system bus. A masked unit without a stale barrier, transitional or failed state, a foreign object at the control path, and a non-delegating activation definition all fail closed before any mutation, and a rejected acquisition rolls back whatever it had staged.

Release restores exactly the recorded state in reverse order — barrier removal proven against the held descriptor, manager reload with an unmasking proof, restart only if the daemon was active, then lock release by closing the descriptor — on normal completion, error return, panic unwind, and HUP/INT/TERM, with non-reentrant signal cleanup. A signal-triggered restore raises the lease's interrupt flag and then waits, bounded, for the operation's acknowledgement that nothing is touching the filesystem any more; only then does it unmask and restart. If the acknowledgement never arrives, the wait expires and the process dies with activation still barred — the recoverable state below — rather than restarting the daemon under an operation that may still be mutating the purge roots. An explicit barred release instead keeps the barrier and the stopped daemon for a caller that uninstalls next. Enablement is never changed on any path.

SIGKILL is the bounded failure mode: the barrier file survives, so face authentication stays masked (password PAM fallback is unaffected) while the flock dies with the process. Both the lock and the barrier live on tmpfs, so a reboot fully recovers on its own. Before reboot, the next lease acquisition adopts a control-path file matching the exact barrier identity (empty, mode 0600, single-link, expected owner) as a stale barrier and removes it on restore; any other object at that path is preserved and reported. Manual recovery is removing the barrier file and running systemctl daemon-reload.

Fedora authselect retirement boundary

The RPM does not ship or select an authselect profile, does not edit system-auth or password-auth, and has no runtime or scriptlet dependency on authselect. Fresh installation is PAM-inert. The supported opt-in is an explicit, named leaf service through facelock pam add --service <name> or its setup --pam --service <name> alias. That operation edits only the resolved leaf service plus Facelock's fixed backup state; the selected authselect profile and shared generated files remain byte-for-byte unchanged.

An incoming RPM upgrade runs the source-controlled facelock-authselect-retirement-guard from %pre, while the old payload is still installed. A fresh transaction is an immediate no-op. An upgrade also succeeds without authselect installed or when the fixed selection-state file /etc/authselect/authselect.conf is absent.

An already-installed v0.1.4 RPM cannot be retroactively guarded: direct uninstall runs only that installed release's unguarded scriptlets. Administrators must install a guarded release before a later uninstall so the upgrade guard can first retire the old authselect payload safely.

When that file exists, the guard reads no other authselect path and invokes no authselect command. It requires a root-owned, root-group, regular, single-link 0644 file of at most 16 KiB, compares the first line's original bytes with its shell-decoded value so no NUL or other control byte can be discarded, and then accepts only the profile grammar used by authselect: one confined profile identifier, custom/<identifier>, or @system-default. A malformed, linked, oversized, control-bearing, or wrong-metadata file is untrusted and blocks the package transaction without changing it.

The exact retired profile identifier facelock also blocks upgrade. The diagnostic requires the administrator to inspect the active identity provider and features, select an appropriate supported profile while asking authselect to create a backup, and retry the RPM transaction. Facelock does not guess a replacement or migrate generated PAM state. A different valid identifier, including the separately administrator-owned custom/facelock, is preserved unchanged and does not block the upgrade.

The booted Fedora lifecycle test uses the released 0.1.4 RPM to prove fresh, unselected, selected-retired, custom-profile, malformed-state, and authselect-absent upgrade cases. It also proves correct and wrong password fallback through the real selected profile, and the real RPM package test proves that service-scoped setup and removal leave the selection and shared generated files unchanged. Neither test mutates the host PAM stack.

Fixed-root purge boundary

The only purge roots are the compiled Facelock roots: /etc/facelock, /var/lib/facelock, and /var/log/facelock. /etc/pam.d is a separate, fixed root for the narrow PAM cleanup above; it is never a recursive purge root. The nonempty /var/lib/facelock/pam-backups subtree is an opaque exception inside the state root because any remaining entry is unresolved PAM cleanup evidence. A configured path that remains within a compiled Facelock root is eligible for a later Debian purge only under the same safety checks as every other descendant.

Configured paths outside those roots are external remnants. This includes external values of daemon.model_dir, storage.db_path, encryption.key_path, encryption.sealed_key_path, audit.path, and snapshots.dir. Removal and purge must leave them untouched, report that they were refused as external, and must not claim that all Facelock data is gone. A path becoming external through configuration does not expand package ownership.

The purge operates from fixed path constants and examines each entry without trusting path traversal. A root and every traversed directory must be a root-owned, non-group/world-writable directory on the root's device. Deletable leaves are single-link regular files with the same ownership and write-mode constraint. The only ownership exception is an owner-only regular file that is one of the direct children of /var/lib/facelock/enrolled; those enrollment markers are deliberately owned by the enrolled user. Other wrong-owner, non-regular, or multiply-linked objects remain in place.

The helper pins every component from the fixed prefix through each purge root with O_DIRECTORY|O_NOFOLLOW descriptors, opens regular candidates with O_NONBLOCK|O_NOFOLLOW, and operates through /proc/self/fd/<fd> rather than reopening public pathnames. It revalidates the complete fixed chain and every opened descendant immediately before and after a removal quarantine. Mount IDs from /proc/self/fdinfo are combined with /proc/self/mountinfo to detect bind mounts even when their device number is unchanged; every descendant's st_dev must also equal the opened root's device.

A safe regular file or empty descendant directory is first moved within its trusted, non-writable parent by a descriptor-anchored, atomic no-replace quarantine operation. A colliding quarantine name is preserved and reported; the helper may try a later bounded candidate but never replaces the collision. The helper reopens the admitted quarantine name and proves the original device, inode, type, link count, ownership, mode, mount, and fixed-chain identity before unlink or rmdir. Recovery uses the same atomic no-replace operation, so a replacement at the public name preserves both public and quarantine remnants. A failed unlink or directory removal restores the proven quarantine identity with no-replace recovery when possible instead of stranding it under a hidden name. After regular-file unlink, the still-open inode must reach link count zero; otherwise an external hard-link remnant is reported without claiming the data was fully removed. A directory with any refused child is never moved. The helper never removes the three compiled root directories because their parents are outside the recursive purge boundary; safely admitted contents and empty descendant directories are removed, while a root may still contain reported refused or opaque remnants. After the helper returns, dpkg may remove an empty /etc/facelock directory as part of native conffile purge. The helper must never cross a mount point. It must never follow a symbolic link, act through a hard-linked object, or recurse through an object whose ownership cannot be proven safe.

The trusted-parent mode is also the purge concurrency boundary. Linux has no inode-conditional unlinkat or rmdirat: after the quarantine identity's final proof, deletion assumes no concurrent process with root or equivalent mutation authority is changing names in that root-owned, non-group/world-writable parent. The package transaction stops the service and owns this interval; ordinary unprivileged users cannot enter it. A user-invoked CLI purge has no package transaction and establishes the same interval itself through the CLI lifecycle exclusion lease above. A separate same-authority process that deliberately mutates those parents is outside the package-purge contract, because no maintainer script can both delete a pathname and preserve an object that such a process substitutes at the deletion syscall itself.

Traversal is iterative and bounded independently for each compiled root: at most 64 descendant-directory levels and 10,000 inspected entries. A directory beyond the depth limit remains as a reported subtree while safe siblings may still be cleaned. Reaching the node limit stops further cleanup of that root and reports the whole root subtree as retained rather than implying that only the first unseen entry is unsafe. A root or descendant that fails any other check remains in place and is reported. A safe root may still be cleaned around an unsafe child, but the final report must name every remnant rather than describe the root as removed. /etc/pam.d is not among these recursive roots, so purge cannot erase an incomplete PAM edit or its rollback provenance.

Safety refusals and external remnants must not strand package-manager state. In particular, Debian purge reports and preserves an unsafe object but returns success after reporting safety refusals, so a link, mount, wrong-owner object, or partial unlink failure cannot leave the package permanently half-purged. This is not a broad recursive-delete contract: the script enumerates the bounded roots and rejects anything it cannot prove is inside them.

Finally, filesystem removal does not promise secure erasure. Unlinking files does not guarantee that data is absent from SSD flash translation layers, snapshots, backups, journal history, or remapped blocks. Lifecycle messages may say which names were removed and which remnants remain; they must not describe purge as forensic destruction of biometric data.

The facelock data purge entrypoint

Two callers implement this envelope: the Debian postrm helper, and facelock data purge. They share the envelope and differ only in who owns the exclusion interval — the package transaction for the first, the CLI lifecycle exclusion lease above for the second.

The CLI entrypoint composes the two in a fixed order, and the order is the contract. The root check runs first, ahead of --dry-run, the authorization, the confirmation prompt, and any output or read. A dry run then short-circuits to the report-only pass: it deletes nothing, so it takes no lease and must not stop the daemon, because a preview with side effects is not a preview. A real purge acquires the lease, runs the traversal with the lease's interrupt flag, and acknowledges completion the instant the engine returns, before rendering anything — a signal-triggered restore blocks on that acknowledgement, so any work inserted between the two is time a signal handler is held off and, if it outlasts the bound, a process that dies with activation barred. Release then either restores the recorded daemon state or, with --leave-activation-barred, keeps the barrier for a caller that uninstalls next.

The engine is infallible by construction — every refusal is a reported remnant rather than an error — so no failure path exists between acquisition and acknowledgement. A panic between them is covered by the lease's Drop, which raises the acknowledgement itself before restoring.

What the report may not claim. The completeness verdict is the engine's PurgeReport::is_complete() verbatim, never an inference from "deletion did not error". A run that retained anything — a remnant inside the roots, a configured path outside them, an unclassifiable configuration, or an interrupt — states in the negative that Facelock data was not completely destroyed, and names every remnant with its reason and every external path with its configuration field. Every completed run, complete or not, also states that removing a name is not erasure. The --json document carries complete for a script to branch on, plus a constant secure_erasure: false so no consumer has to infer it.

Exit status is not the answer. The command exits 0 whenever the purge ran and the lifecycle was restored, whatever the purge could not remove, for the same reason the Debian purge returns success after reporting safety refusals: a safety refusal is a reported outcome, not a crash, and an install must not be strandable in a half-purged state by a caller that treated a refusal as a failure. Callers branch on the report, or on complete in the document.

A failed lifecycle restore is reported before it is returned. The destruction is irreversible and the report is its only record, so a release that cannot restore the recorded daemon state renders the full report first — remnants, external paths, erasure caveat, or the complete --json document — and only then returns the error. Discarding the report to propagate the restore failure would leave a caller unable to distinguish "the purge never ran" from "the purge ran and the daemon did not come back", after their biometric state was already destroyed. The document therefore carries lifecycle_restored and lifecycle_error beside the purge result, and its mere presence means a pass ran. The nonzero exit that follows means the lifecycle needs attention, not that nothing happened.

A dry run gives no completeness verdict. Report mode classifies the configured paths and never opens the compiled roots, so it has examined nothing that a completeness claim would be about. It states its scope instead, and its document reports mode: "dry-run", roots_examined: false and complete: null — never true, which a reader on a machine full of enrollments could take for "my biometric data is already gone". That inference from absence of evidence is exactly what this section forbids, and it is forbidden of the preview as much as of the purge.

An interrupted run reports nothing, and that is the contract rather than a defect to work around. The report is rendered after the lease is released, because the document must carry the lifecycle outcome; release joins the signal thread, and that thread terminates the process as soon as its restore completes, so a signalled purge exits before it renders. Deletions already made stand. Repetition is safe by construction, so a caller learns what remains by running the command again, and no partial-report guarantee is offered or implied.

Repeating a purge is safe by construction, and the report is what makes that useful: every refusal leaves the object in place with a stated reason, so a second run after resolving an ownership or link problem picks up exactly what the first could not prove safe.

Filesystem Paths

PathOwnerModePurpose
/etc/facelock/config.tomlroot:root644Configuration
/var/lib/facelock/root:root711State dir. Traversable by every local user, listable by root only: users can open known names subject to each file's permissions (models/ is itself 0755 and listable — public data)
/var/lib/facelock/facelock.dbroot:root600Face embeddings. Read by the daemon (root) only; user-run PAM stacks request authentication through the daemon, they never read templates
/var/lib/facelock/models/root:root755ONNX models — public, SHA256-verified downloads
/var/lib/facelock/enrolled/root:root711Enrollment markers; traversable by all, listable by root only
/var/lib/facelock/enrolled/<user><user>:<user>600{"models": N, "updated": "<ISO8601>"} — a hint for is-enrolled, never authoritative
/var/lib/facelock/pam-backups/root:root700Fixed-root PAM rollback state; not affected by [pam].config_dirs or storage.db_path
/var/lib/facelock/pam-backups/<service>.<seconds>-<nanoseconds>root:root600Original PAM service bytes; nanoseconds are exactly nine digits
/var/lib/facelock/pam-backups/<service>.<seconds>-<nanoseconds>.jsonroot:root600Strict versioned provenance for the adjacent rollback bytes
/var/lib/facelock/pam-backups/.facelock-remove-all-<operation>.jsonroot:root600Strict prepared whole-set PAM removal journal
/var/lib/facelock/pam-backups/.facelock-remove-all-commit-<operation>.jsonroot:root600Strict self-contained whole-set commit and recovery marker
/var/log/facelock/root:root700Log dir — per-user auth history and raw face snapshots are root-only
/var/log/facelock/audit.jsonlroot:root600Structured audit log
/var/log/facelock/snapshots/root:root700Auth snapshots (raw face images)
/usr/bin/facelockroot:root755CLI binary
/lib/security/pam_facelock.soroot:root755PAM module
/usr/share/locale/<lang>/LC_MESSAGES/facelock.moroot:root644Compiled CLI catalog. Present only for languages po/ carries, which today is none
/usr/share/locale/<lang>/LC_MESSAGES/pam_facelock.moroot:root644Compiled PAM module catalog. A separate gettext domain from the CLI's, never merged with it

Config-described data and model paths are overridable as documented by their schema fields. /var/lib/facelock/pam-backups is deliberately fixed for the PAM writer and shared state layout. Neither [pam].config_dirs nor storage.db_path redirects it: the former selects PAM service roots and the latter relocates biometric database state only. FACELOCK_CONFIG is honored for unprivileged processes, but every effective-UID-0 process ignores the environment and uses either an explicit --config path or /etc/facelock/config.toml. Runtime-created DB sidecars (-wal, -shm), audit logs, and snapshots are created with explicit restrictive modes. The packaged systemd unit also sets UMask=0027.

Traversal for everyone, listing for nobody (ADR 010)

The state directory and enrolled/ are 0711 root:root: any local user may enter them, nobody but root may list them. That is the whole grant. Every entry below is locked down in its own right — 0600 root:root database and sidecars, 0600 <user>:<user> markers, and a 0700 root:root PAM-backup directory containing 0600 root:root state files — and models/ carries group/other read bits because its contents are public, SHA256-verified downloads. No path relies on membership in a dedicated facelock group (ADR 010).

D-Bus is required for user-run screen lockers (hyprlock/swaylock) and the polkit agent — their PAM stack runs as the user, and nothing makes the 0600 root:root database or encryption key readable to them — and the bus admits their Authenticate call without any group (see IPC Protocol). Root-invoked PAM (sudo, login, sshd) can also use the oneshot fallback, which reads the files directly as root.

Known residual: any local user can stat a path it can guess by name — facelock.db (size, mtime) or enrolled/<user> (existence) — because traversal permits exactly that. Closing it would mean denying the traversal that is-enrolled and model loading depend on. Accepted; before ADR 010 the same residual existed for facelock group members.

Historical pre-ADR-010 contract change: permissions tightened (no paths moved)

The default paths are unchanged — the database stays at /var/lib/facelock/facelock.db and the models at /var/lib/facelock/models; no data moves on upgrade. What changed are modes and ownership, recorded here per the repo rule that path and permission contracts live in this file:

PathWasNow
/var/lib/facelock/750 root:facelock710 root:facelock
/var/lib/facelock/facelock.db (+-wal/-shm)640 root:facelock600 root:root
/var/lib/facelock/models/755 root:root755 root:root (unchanged)
/var/lib/facelock/enrolled/— (new)710 root:facelock
/var/log/facelock/750 root:facelock700 root:root
/var/log/facelock/audit.jsonl640 root:facelock600 root:root
/var/log/facelock/snapshots/750 root:facelock700 root:root

The group loses direct reads of the database, the audit log (per-user auth history) and the snapshots (raw face images) — all strictly more sensitive than anything the group needs, since every group operation goes through the daemon. For an existing install the entire on-disk change is a chmod/chown of the paths above plus mkdir enrolled/ — idempotent, applied by packaging (tmpfiles, install scriptlets) and by the setup, daemon-start and direct/auth state-layout paths; none of it touches the data itself.

Contract change: traversal opened to every local user (ADR 010)

No paths move. The two directories that carried a group grant drop it:

PathWasNow
/var/lib/facelock/710 root:facelock711 root:root
/var/lib/facelock/enrolled/710 root:facelock711 root:root

Everything else in the table above is unchanged. For an existing install the on-disk change is a chmod/chown of those two directories — idempotent, applied by packaging (tmpfiles, install scriptlets), setup and the runtime state-layout paths (ensure_state_layout on daemon start, best-effort on the auth path). sudo facelock setup, just install-files and the package scriptlets remove a leftover facelock group best-effort; sudo groupdel facelock if it lingers.

Audit Log Entries

audit.jsonl is JSONL; each line carries timestamp, user, result (success, failure, error, rate_limited, suppressed, cancelled) and, when known, source (daemon, oneshot, test), similarity, frame_count, duration_ms, device, model_label, error.

cancelled (ADR 008 §5) is an attempt that was abandoned, not answered: the caller's bus connection went away, the system suspended, ReleaseCamera arrived, or a one-shot process was signalled. It is deliberately not a failure — no comparison reached a verdict, so it charges no rate-limit budget. The entry carries frame_count and duration_ms (how far the attempt got) and no similarity.

source names the code path that produced the entry — daemon (the Authenticate D-Bus method), oneshot (the facelock auth helper PAM spawns), or test (facelock test, on either transport: the daemon's TestAuthenticate method or the in-process direct loop). It records the enforcement path, not the caller's identity: daemon and oneshot are fully-enforced authentications whose failures count against the rate limit, while test skips the SSH/lid physical-presence gates and charges nothing. So a success stamped test is a recognition result, not a policy-approved authentication — and a real authentication is never stamped test, whatever privilege its caller holds. The field is absent on entries written before it existed.

Config Schema

TOML format. All keys optional — camera auto-detected, sensible defaults for everything.

Sections

SectionKey fields
[device]path (Option), max_height, rotation, warmup_frames, dark_threshold, dark_pixel_value, ir_emitter, camera_release_secs, camera_release_after_success_secs
[recognition]threshold, timeout_secs, no_face_timeout_secs, detector_model, detector_sha256, embedder_model, embedder_sha256, threads, execution_provider
[daemon]mode (DaemonMode enum), model_dir, idle_timeout_secs
[storage]db_path
[security]disabled, suppress_unknown, require_landmark_liveness, require_ir, require_frame_variance, frame_variance_max_similarity, ir_texture_min_stddev, min_auth_frames, bind_templates_to_device, device_match_granularity, bind_legacy_templates, bind_device_aad, allow_plaintext, abort_if_ssh, abort_if_lid_closed, pam_policy, rate_limit
[notification]mode (off/terminal/desktop/both), notify_prompt, notify_on_success, notify_on_failure
[snapshots]mode (off/all/failure/success), dir
[encryption]method (keyfile/tpm/none — default keyfile), key_path, sealed_key_path
[audit]enabled, path, rotate_size_mb
[tpm]seal_database, pcr_binding, pcr_indices, tcti
[polkit]face_eligible_actions
[pam]config_dirs

[polkit].face_eligible_actions is the allowlist of polkit action_ids for which the face authentication agent may offer face auth. Default: ["org.freedesktop.login1.lock-sessions"]. Any action not in the list is declined by the agent. An empty list disables face for all actions. High-risk actions (pkexec, PackageKit, udisks mount, accounts-service) are excluded by default.

Scope: this allowlist governs the agent model only. Under the PAM model (pam_facelock.so as auth sufficient in /etc/pam.d/*, the common Howdy-style deployment that also covers sudo), the list is ignored: face is attempted for every action in that PAM stack, always with password fallback because the line is sufficient, never required. See docs/security.md §7a/§7b for the two models.

NOTE (agent model only): polkit registers a single authentication agent per session and does not chain agents. When this agent declines a non-allowlisted action it returns an error, which — depending on the desktop's agent registration — may present as an authorization denial rather than a fallthrough to a password dialog. The intended UX (non-eligible actions handled by the desktop's normal password agent) is unverified pending live-desktop testing and may require a design change. Behavior here is fail-closed: a non-eligible action is never face-authorized.

[pam].config_dirs is where named facelock pam add | remove | status looks for PAM service files, in search order — Linux-PAM's own precedence, earliest wins. facelock pam remove --all always uses the compiled /etc/pam.d, /usr/lib/pam.d, and detection-only /etc/authselect roots and cannot be redirected by configuration. Default: ["/etc/pam.d", "/usr/lib/pam.d"]. The first entry is the override directory: every write lands there and every later entry is read-only, so a service that resolves only in a later one is copied into the first before the line is inserted. Setting a package-owned directory first would make facelock edit package files. An empty list is treated as the default rather than as a request to disable the writer, and so is any list with a non-absolute entry — a relative first entry would resolve the write target against the invoking shell's working directory — and any list whose first entry is also one of the later ones, spelled twice or reached through a symlink, which would collapse the override layer onto a read-only one. pam is dispatched before the process-wide config parse, so a missing or broken config yields the default list rather than an error — editing /etc/pam.d must not be blocked by an unrelated config mistake. FACELOCK_CONFIG is ignored in a privileged process, so the environment cannot redirect where a root pam add writes; the global --config flag is a process override and is honoured under sudo, which is root naming a different file on purpose rather than the environment doing it behind root's back. The one process it cannot reach is the packaged daemon, whose unit passes no --config; that is why setup --systemd refuses under a non-default value and why enroll and test go direct there (see "facelock setup Flag Composition"). See "facelock pam Semantics" above for the resolution rules themselves.

Encryption defaults (Plan 04). encryption.method defaults to keyfile: face templates are encrypted at rest by default. method = "none" (plaintext) is refused at enrollment unless security.allow_plaintext = true. Auth always degrades to password on a decrypt failure — never a lockout.

Encryption key creation (#231). The keyfile is created at mode 0600 when it is absent and the database holds no encrypted template, and never otherwise. Encrypted is decided by each blob's version byte, not the sealed column. A store that cannot be queried counts as encrypted rows existing, and so does one that cannot be locked: the row check and the key write run inside one exclusive store transaction, which serializes them against the single transaction an enrollment commits its template in. That ordering is the whole guarantee — it does not invalidate a key a running daemon has already cached in memory, so --generate-key against a live daemon is a known limit, not closed by this gate (follow-up). Automatic creation happens only for method = "keyfile"; --generate-key is the explicit way to mint a key before switching the method to it. Creation is an O_EXCL | O_NOFOLLOW write to a temporary beside the key, flushed, then renameat2(RENAME_NOREPLACE) onto the key path with the parent directory flushed: concurrent creators resolve to exactly one key, an existing key is never truncated, and the key path never holds a partial file — true of the create path (create_key_file_exclusive). The replace path (generate_key_file, what --generate-key calls) truncates the key file in place instead. A symlink at the key path is refused by both the creating and the reading path; the reader opens O_NOFOLLOW. The same gate governs every writer of that key — the daemon, the one-shot commands, facelock setup's automatic encryption policy, and facelock tpm encrypt with and without --generate-key; --generate-key replaces a live key and is refused over encrypted rows, naming facelock clear as the deliberate destructive step.

A refusal fails enrollment closed and leaves authentication alone: the auth path reads raw rows, serves the templates it can decrypt, reports the missing key rather than store corruption for those it cannot, and still falls through to the password prompt. Restoring the key artifact lifts the refusal at the next authentication or enrollment attempt without restarting the daemon.

Sealing key identity and the keyring (#354). Every model row written from this version records the key id of the key that sealed it. The daemon and the one-shot path load the configured method's key as primary and, best-effort, the other method's artifact as secondary (keyfile under tpm; sealed key under keyfile when built with tpm). A secondary that fails to load is logged and ignored, never fatal, and never mints a key. A row naming a key id decrypts only under that key; if it is not loaded the load fails naming the other artifact. A pre-V7 row (NULL key id) is tried under the primary then each secondary. New enrollments always seal under the primary. facelock tpm encrypt sets key id; facelock tpm decrypt clears it. tpm seal-key, unseal-key, and reseal never touch rows.

Enrollment atomicity (#308). An enrollment writes to the store exactly once, at the end: after every accepted embedding has passed the minimum-capture and angle-diversity gates and, with encryption on, been sealed, one transaction removes the previous model under the same user and label and inserts the new model with all of its embeddings. Until that commit the accepted embeddings exist only in memory, inside the wiping guard, and every exit observed before the store write (a cancellation, including one that lands on the final frame; the deadline passing with too few frames; a rejected set; a sealing error; a storage error; a daemon crash) leaves the store exactly as it was: no model, no embeddings, and a previous same-label template still in place and still authenticating. A model row written by this version or later is therefore a complete template, and facelock list and Authenticate can never observe an attempt that did not finish; the store itself refuses to commit a model with fewer than MIN_EMBEDDINGS_PER_MODEL (3) embeddings, the same floor the capture loop enforces. Both are pre-commit guarantees. The cancel token is checked once more immediately before the store write, after sealing, so a caller that departs during finalization still gets Cancelled and an unchanged store; the commit is the one residual window. A cancellation or crash observed during or after the commit can leave the new model stored while the reply is lost (the daemon killed, the bus connection dropped, a client timeout between commit and reply), so a caller may find a valid template it reported as failed, whereas a caller that cancelled before the write is never surprised by one. No migration inspects rows written by earlier versions: a partial model the old flow left behind stays until facelock remove or facelock clear deletes it.

Camera hold semantics (ADR 008). device.camera_release_secs (default 3) is the number of seconds the daemon keeps the camera streaming after a failed authentication — the one ending a retry plausibly follows — so that retry skips the reopen cost. A success releases the camera immediately unless device.camera_release_after_success_secs (default 0) is greater than zero, in which case a success holds for that many seconds instead; it is an opt-in for repeated privileged actions with no authentication caching in front of them, and at its default nothing about a success changes. Cancellation and every error (including a capture failure or an all-dark scan) always release immediately, whatever both keys say: the interaction is over, and on IR hardware the emitter LED goes out with it. camera_release_secs = 0 means never hold after a failure; it previously fell back to 5 seconds. Enrollment follows the same rule as authentication, on both keys. Preview frames are exempt: each one extends the hold to max(camera_release_secs, 2s) so a ~10 fps preview never reopens per frame, and the CLI still calls ReleaseCamera on exit. The hold deadline is absolute and polled every 250 ms. One-shot mode (facelock auth) never holds — process exit is the release — and ignores both keys. Changing either value needs no daemon restart: they are read per request.

Hard device binding (opt-in). security.bind_device_aad = true folds the enrolling camera's device_id into the AES-GCM AAD, so a template cannot be decrypted under a different camera. Default false (fails closed on unstable ids). Complements the advisory device coupling of Plan 02. The contract (#312):

  • Enrollment with the flag on and no non-empty canonical device_id is refused before the first model write, in the daemon Enroll path and the direct path (through Config::ensure_enrollment_binding_allowed), and again inside the enrollment loop (Config::require_device_aad). The error names the key and the remedy. The flag is inert under encryption.method = "none": no refusal, no classification.
  • Authentication derives the AAD from each row's own device_id (SecurityConfig::device_aad). A row with a NULL or empty device_id decrypts with no AAD and is classified LegacyUnbound; it authenticates as before, provided every other row in the user's store decrypts (the first failing row fails the whole load; the unbound diagnostic is logged before decryption). The daemon logs a warning naming such model ids at each compare-set load; facelock list renders unbound (re-enroll to bind) in the Camera column; facelock status renders #N: label, unbound (re-enroll to bind). The --json payloads are unchanged (device_id is "" for such a row): a consumer cannot tell an unbound row from a pre-coupling one without consulting the flag itself. A dedicated field is a follow-up, not part of this contract.
  • With the flag off, templates are sealed with no AAD. An absent AAD and an empty AAD are the same to the cipher; that equivalence is the ordinary-encryption contract.
  • Turning the flag off (or disabling encryption) over a store sealed under it makes every hard-bound template fail to decrypt; list/status still report them bound and facelock tpm decrypt fails on the first such row. The decrypt error names the way back (re-enable, or re-enroll). facelock tpm encrypt refuses to run while the flag is on, since it re-seals rows without their device ids.

TPM sealed-key format & unseal semantics (Plan 04). The sealed-key blob is versioned: 0x01 = no PCR policy; 0x03 = PCR-bound, and self-describes its PCR index list. A PCR-bound object is created with userWithAuth = false, and unseal starts a real policy session and replays PolicyPCR — so a changed bound PCR makes unseal fail (finding #5). facelock tpm reseal re-seals the key under the current PCRs (recovery path).

Camera Auto-Detection

When device.path is omitted:

  1. Enumerate /dev/video0 through /dev/video63
  2. Filter to VIDEO_CAPTURE devices
  3. Classify every node's IR provenance from queried evidence: a quirks force_ir match (authoritative by USB vendor:product ID; a name-only match only when corroborated by a real USB identity or the node's own mono-format evidence), otherwise a node whose queried formats are mono-only/IR-typical (GREY/Y8/Y10/Y12/Y16, with no color format mixed in). The device name never classifies a node on its own. Node-level disambiguation for multi-node USB devices: when several nodes share one quirk-matched VID:PID and at least one has an IR-typical format (GREY/Y8/Y10/Y12/Y16), only the format-bearing node(s) are IR. A quirk's format_preference counts as node-level IR evidence only when it is itself IR-typical and the node actually advertises it
  4. Exclude devices that advertise no decodable pixel format (GREY/Y16/YUYV/NV12/MJPG) — e.g. raw Bayer sensor nodes (Intel IPU6/IPU7). This filter runs after step 3 and never feeds back into it: it changes which node is selected, never whether a node counts as IR. The IR-typical list (step 3) and the decodable list are deliberately different sets — a node whose only IR evidence is Y8/Y10/Y12 is IR and undecodable, and is excluded here with a syslog warning naming its path and formats
  5. Among the remaining nodes, prefer a quirks-confirmed IR node with a native IR format, then any quirks-confirmed IR node, then an evidence-classified IR node (breaking ties toward one whose name also carries an ir/infrared token — a hint only, never a promotion of a node that lacks format evidence)
  6. Fall back to first decodable device; if none, error listing every detected device and its formats

Opening a device (auto-detected or explicit device.path) negotiates a format in priority order quirk format_preference > GREY > Y16 > YUYV > NV12 > MJPG and fails if the device advertises none of them (no silent fallback to an undecodable format).

A quirk's format_preference is compared whitespace-trimmed and is dropped with a warning if it names a format facelock cannot decode, rather than winning negotiation and then failing every capture.

On a selected or negotiated Y16 stream, authentication requires verified scale provenance. A valid matched quirk y16_bit_depth in 8..=16 produces VerifiedY16 { bit_depth } and pins the session shift to bit_depth - 8; a missing or invalid value produces UnverifiedY16. Scene-derived calibration may pin conversion for non-auth preview, enrollment, or benchmarking, but it never upgrades provenance and is never accepted by the absolute IR texture check.

Interrogation derives the expected state from the normalized FourCC selected by the same quirk preference and negotiation priority used at open. Authentication rejects known UnverifiedY16 before opening the camera. After VIDIOC_S_FMT, the opened camera recomputes state from the actual normalized negotiated FourCC; negotiation drift to unverified Y16 skips warmup/calibration capture and receives the same rejection before comparison. An actual GREY stream is NotY16 and preserves the existing 8-bit behavior, even if that device also advertised Y16.

The rejection class is ErrorKind::Y16BitDepthRequired with the stable rendered message Y16 IR texture scale is unverified; authentication requires a verified y16_bit_depth (8..=16) quirk. It uses the daemon’s in-band -2 error sentinel and oneshot exit 2, both mapping to PAM_IGNORE so password fallback remains available without an auth success. This gate applies regardless of security.require_ir; disabling IR enforcement cannot bypass unknown Y16 scale. It never reclassifies or downgrades Y16 to RGB and never skips texture enforcement.

Open also rejects a padded stride: for GREY/NV12 (bytesperline == width) and Y16/YUYV (bytesperline == 2 * width), a device reporting anything else errors at open instead of decoding sheared frames. Compressed formats (MJPG) are exempt — their bytesperline is not a row size.

FourCC normalization. V4L2 pads FourCCs to four characters with trailing spaces ("Y16 "). Facelock strips that padding at every ingest point — device enumeration (query_device) and quirks-file parsing — so DeviceInfo.formats carries the unpadded spelling ("Y16", not "Y16 ").

The only machine-readable surface that changes is facelock devices --json on the direct backend, which is where format detail exists at all: the D-Bus DeviceInfo does not carry formats, so under the daemon backend --json reports "formats": [] and there is no spelling to change (BackendCaps::device_formats, false for BackendKind::Daemon). The human-readable facelock devices table already trimmed.

Database Schema

SQLite with WAL mode and foreign keys:

CREATE TABLE face_models (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user TEXT NOT NULL,
    label TEXT NOT NULL,
    created_at INTEGER NOT NULL,
    embedder_model TEXT NOT NULL DEFAULT '',  -- V5: embedder that produced the embeddings
    device_id TEXT,                           -- V6: enrolling camera fingerprint "vid:pid:serial" (NULL = legacy/uncoupled)
    key_id TEXT,                              -- V7: truncated SHA-256 id of the sealing key (NULL = pre-V7 or plaintext)
    UNIQUE(user, label)
);

CREATE TABLE face_embeddings (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    model_id INTEGER NOT NULL REFERENCES face_models(id) ON DELETE CASCADE,
    embedding BLOB NOT NULL,  -- 512 x f32 = 2048 bytes (or encrypted blob)
    sealed INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE rate_limit (
    user TEXT NOT NULL,
    attempt_time INTEGER NOT NULL
);

Only failed authentication attempts are recorded in rate_limit, and only those where a face was actually detected (ADR 008 §4 — see §facelock test Semantics for the full charging rule). Daemon mode and oneshot mode share the same SQLite-backed window, so daemon restarts do not clear lockout state.

Schema version is tracked in schema_version; migrations are additive and forward-only. Current version: 7. Migration V7 adds the nullable face_models.key_id column, recording the truncated SHA-256 fingerprint of each sealing key; NULL indicates a pre-V7 row or plaintext embedding. Migration V6 adds the nullable face_models.device_id column (Plan 02 device coupling); pre-V6 databases open cleanly, keep their rows, and leave device_id NULL. NULL rows are governed by security.bind_legacy_templates (default allow-with-warn), so upgrades never lock a user out.

device_id is the canonical fingerprint ("vid:pid:serial") of the camera that enrolled the template. It is model-granularity at best and forgeable by a programmable USB device — advisory defense-in-depth, NOT attestation. See docs/security.md §Device Coupling.

Enrollment precondition (#309). Every non-NULL device_id matches its own enrolling camera at the security.device_match_granularity in force when it was enrolled (a model row does not match after switching to unit; re-enroll). Under model, a camera missing either the vendor or the product id is stored NULL (legacy-governed). Under unit, a camera with no non-empty serial, or with no full vendor:product identity, is refused: a unit enrollment never stores a NULL row, which would bind to nothing. With bind_templates_to_device = true and bind_legacy_templates = false, a camera with no usable identity is refused at any granularity, since the NULL row it would store could never authenticate. The check (Config::ensure_enrollment_binding_allowed, which delegates the coupling half to SecurityConfig::ensure_enrollment_binding_allowed) runs once per enrollment, after the camera is open and before the first model write, in both the daemon Enroll path and the direct path; a refusal is an ordinary enroll error naming the key and the remedy, and leaves no row behind. It never re-judges an existing template, so it can refuse a new enrollment but never lock an authentication out.

Upgrade and downgrade contract

An upgrade preserves everything the previous release wrote and changes only the schema version and the columns a migration adds. Specifically, across a native package upgrade:

  • Embeddings keep their bytes. A row encrypted by an earlier release decrypts to the same plaintext under the same key. The upgrade never re-encrypts, re-seals, or rewrites a row.
  • Key artifacts are never replaced. encryption.key and encryption.key.sealed keep their bytes, mode and owner. No maintainer script creates, reseals or reads a key, and none changes PCR selection.
  • A missing key over encrypted rows is fatal, never replaced. When the key artifact is gone and the database still holds encrypted rows, the daemon and the one-shot path refuse to write a replacement and say why. Enrollment is refused; authentication keeps falling through to password. Writing a fresh key there would make a later restore of the real key useless.
  • The default key is created only for a plaintext-only legacy database with no key artifact. Creation is O_CREAT | O_EXCL | O_NOFOLLOW with the file and its parent directory flushed, so concurrent starts resolve to exactly one key and a symlink at the key path is refused rather than followed.
  • Config, models and audit data are untouched. Administrator edits to /etc/facelock/config.toml survive.
  • Enrollment markers keep their owner and mode, and their content is reconciled. The marker is the one piece of state an upgrade is supposed to rewrite: a marker that disagrees with the database is rewritten at daemon startup, so facelock is-enrolled cannot answer "not enrolled" for a user whose templates are present (#137). Byte identity is deliberately not the contract here, because it would preserve a marker that lies.
  • Modes converge to ADR 010 without content changing, and the retired facelock group is removed if an older install left one.
  • PAM is never activated by an upgrade. An existing service file keeps its bytes; the packaged Debian profile is registered but not selected, and no authselect profile is reinstated on Fedora.

Migration failure never resets the database. A migration that cannot finish (full filesystem, interrupted process, corrupt page) leaves the database exactly as it was and reports the failure. Recreating a database it could not migrate would destroy every enrollment on the machine and look like a clean first run.

Downgrade is supported back to v0.1.4, with one limit. Migrations are additive and forward-only: there is no down-migration from V7, and a package downgrade leaves schema_version at 7. What is guaranteed is that the older release still opens that database, reads its rows, and decrypts what it encrypted, because V7 only adds a nullable face_models.key_id column that older queries never name, and V6 only adds a nullable face_models.device_id column that older queries never name. What is not guaranteed is that rows written by the newer release carry data the older one understands: a key_id recorded after the upgrade is invisible to an older release, so a template enrolled on the newer version and then rolled back decrypts under the single key that older release loads if that key is the one that sealed it.

just test-upgrade-v014 is the gate for all of the above. It runs both halves against the real published v0.1.4 artifacts; see docs/releasing.md.

IPC Protocol

D-Bus system bus (org.facelock.Daemon). Only used in daemon mode.

The daemon registers on the system bus via D-Bus activation.

  • Bus name: org.facelock.Daemon
  • Object path: /org/facelock/Daemon
  • Interface: org.facelock.Daemon

Methods

Authenticate, TestAuthenticate, Enroll, ListModels, RemoveModel, ClearModels, PreviewFrame, PreviewDetectFrame, ListDevices, ReleaseCamera, Ping, Shutdown

Method authorization contract (updated under DEC-6/N13 — the CLI's root-by-default privilege map left no unprivileged consumer for most of these, so tightening them to root-only closes the per-frame similarity hill-climbing oracle by construction rather than by redacting fields):

  • Authenticate: root or the matching Unix user. The one user-scoped method — screen lockers run their PAM stack as the user, so this is architecture, not policy. It is real authentication: a failed attempt that saw a face consumes biometric-guess budget, whatever the caller's UID; no-face attempts do not (see "facelock test Semantics"). When security.abort_if_ssh = true, an authorized non-root caller must also prove a live local process/session identity as described below.
  • TestAuthenticate: root only. Same arguments and same AuthResult reply as Authenticate, and the same gates except that it skips the SSH/lid physical-presence aborts and charges no rate-limit budget on failure (see "facelock test Semantics" above). It exists so the daemon never has to infer a caller's purpose from their privilege; root-only is what makes a budget-free endpoint safe to expose.
  • Every other method — Enroll, ListModels, RemoveModel, ClearModels, PreviewFrame, PreviewDetectFrame, ListDevices, ReleaseCamera, Ping, Shutdown — is root only. The bus policy (dbus/org.facelock.Daemon.conf) grants root the whole interface and every local user exactly Authenticate (ADR 010) — so adding a root-only method needs no policy edit, and a future user-scoped method needs one deliberately; there is no group policy, and signals are root-only. The per-method root/user-scoped decision is the in-daemon check on the caller UID from GetConnectionUnixUser, keyed by a table-driven scope (authorize_method in facelock_daemon::server) so a new method is root-only by default until deliberately opened up.

Daemon ingress ordering is part of the authorization contract. The server resolves and authorizes the caller/target pair before refreshing daemon activity or touching handler reload, SQLite, audit, or capture state. Every authorized non-root Authenticate then spends one token from a daemon-local, per-caller-UID availability bucket before any of that shared work: capacity 10, one token restored per monotonic second. Saturation is an in-band recoverable AuthResult (model_id = -2, label = "rate limited"), so PAM continues to the password. The charge applies even when the request later finds no model, fails another preflight gate, or meets a busy capture. UID 0 is trusted and exempt from this ingress bucket; TestAuthenticate remains root-only and ingress-budget-free. This availability budget is distinct from security.rate_limit: the latter remains the persistent, per-target-user biometric-guess limiter and still charges root Authenticate failures under the rules in "facelock test Semantics".

The remote-session gate is bound to the D-Bus caller rather than the daemon's environment. For an authorized non-root Authenticate with security.abort_if_ssh = true, the daemon asks org.freedesktop.DBus.GetConnectionCredentials for the message sender's unique bus name and requires the returned ProcessFD. It derives the PID from that pidfd's kernel metadata, checks that the pidfd is live both before and after org.freedesktop.login1.Manager.GetSessionByPID, and reads the selected session's Remote property. It never trusts the credentials' numeric ProcessID, never classifies the caller from a security label, and rejects a PID-reuse race because a dead original pidfd invalidates the intervening logind answer.

A remote session, omitted or invalid ProcessFD, dead caller, unavailable or inconsistent logind answer, cancellation, or expiry of the four-second provenance deadline (covering credentials, ProcessFD validation, and logind) all fail closed as the same out-of-band org.freedesktop.DBus.Error.AccessDenied message: Authenticate requires a live local caller process. The privileged daemon journal keeps the detailed cause; the unprivileged wire does not distinguish remote from unverifiable provenance. Credentials and login1 are queried asynchronously without retaining the handler mutex. Caller departure, ReleaseCamera, suspend, and shutdown cancel a pending query, so a stalled system-bus reply cannot delay later handler operations or daemon shutdown. This check runs after method authorization, non-root ingress charging, the early busy check, and live config reload, but before handler preflight or capture admission. UID 0 bypasses only this remote-session provenance check: ordinary authorization and the persistent biometric-guess limiter remain unchanged. TestAuthenticate remains its separate root-only diagnostic method. When abort_if_ssh = false, the daemon performs no ProcessFD, PID, logind, or session lookup at all. The one-shot transport continues to enforce SSH provenance from the PAM-sanitized SSH_CONNECTION / SSH_TTY environment instead of D-Bus.

Ingress buckets are process-local, are discarded after enough idle monotonic time to refill the full burst, and are capped at 1024 UID entries with least-recently-seen eviction. Daemon restart therefore resets this availability budget but not the SQLite-backed biometric-guess budget. The cap bounds memory, not aggregate admission across many distinct local UIDs. If the ingress-state mutex is poisoned, every later non-root admission fails closed as the same recoverable in-band rate-limit result until daemon restart. Poisoned state is neither read nor mutated.

For an admitted request, a changed config mtime is claimed before rebuilding the handler. A failed rebuild keeps the existing handler and is not retried for that same mtime; a later mtime permits one new attempt. A completed rebuild is generation-checked before installation: if a newer mtime was claimed while it was building, the stale handler is discarded rather than reactivating older security configuration. The nested lock order is config_mtime then handler; no path acquires them in the opposite order.

Raw camera frames require privilege. Both PreviewFrame and PreviewDetectFrame are root-only, so a non-root caller is denied with AccessDenied before either method touches the camera. On top of that denial the daemon strips jpeg_data from any non-root reply, so raw camera/IR imagery cannot reach an unprivileged caller even if the authorization table were ever to regress.

Method timeouts: Enroll runs synchronously inside the method call for up to Config::enroll_timeout_secs() seconds server-side (3 × max(recognition.timeout_secs, 5) seconds — i.e. minimum 15s). Clients MUST use a method timeout greater than this deadline plus startup/inference margin for Enroll (the CLI uses deadline + 15s); the shared 15-second client timeout applies to every other method. A client timeout at or below the server deadline aborts the call while the daemon is still enrolling.

Enrollment behavior is mode-independent: oneshot (facelock enroll in direct mode) and the daemon's Enroll method run the same capture loop, so the quality gate and the angle-diversity check apply in both.

Capture concurrency: Authenticate, TestAuthenticate, Enroll, PreviewFrame, and PreviewDetectFrame are serialized by an in-flight capture guard. While one capture is in progress, a concurrent call to any of these methods fails immediately with an org.freedesktop.DBus.Error.Failed error whose message contains daemon busy (no queuing behind the internal handler lock). An authorized non-root Authenticate spends its ingress token before this busy rejection.

Authenticate and TestAuthenticate run their camera-independent audited preflight on one locked handler generation before claiming the capture guard. A preflight rejection such as no enrolled models therefore never occupies the global capture slot. The handler stays locked across a successful preflight and capture admission so live reload cannot turn the split into a fail-open configuration race. A suspend, ReleaseCamera, or shutdown cancellation that lands during preflight is carried across admission and prevents the delayed request from opening the camera.

Clients (PAM included) must treat a busy error like any other daemon error — degrade to the next auth mechanism (password), never a lockout.

Signals

  • AuthAttempted(user: s, matched: b) — emitted best-effort after each successful method return from Authenticate and TestAuthenticate, including in-band preflight rejections where no camera opened; out-of-band D-Bus errors emit none. The payload intentionally carries no similarity score (the raw biometric score is an information leak / spoof-tuning oracle). The system bus policy (dbus/org.facelock.Daemon.conf) denies signal reception from the daemon by default; only root may receive it (ADR 010: no group policy).

Response types

The D-Bus methods return these values directly; the daemon's internal response enum variants are not wire wrappers:

MethodReturn value
Authenticate, TestAuthenticateAuthResult { matched, model_id, label, similarity }
Enroll(model_id: u32, embedding_count: u32)
ListModelsarray of ModelInfo { id, user, label, created_at, embedder_model, device_id }
PreviewFrameJPEG byte array
PreviewDetectFrame(JPEG byte array, array of PreviewFaceInfo { x, y, width, height, confidence, similarity, recognized })
ListDevicesarray of DeviceInfo { path, name, driver, is_ir }
Pingstring
RemoveModel, ClearModels, ReleaseCamera, Shutdownno return fields

ModelInfo.device_id is the enrolling camera's canonical fingerprint; D-Bus has no Option type, so an empty string is the NULL sentinel for legacy/uncoupled templates. created_at is a Unix timestamp in seconds.

Authenticate error encoding

Authenticate returns AuthResult (matched: b, model_id: i, label: s, similarity: d). TestAuthenticate returns the same type with the same sentinels — one encoding, so the two cannot drift. Sentinel model_id values (only meaningful with matched == false):

model_idMeaning
>= 0Matched model id (with matched == true)
-1No match, and no face was detected (also: no enrolled faces, and the pre-camera gates)
-2Recoverable daemon error; label carries the error message (rate limited, IR required, camera/storage failure)
-3Suppressed: no enrolled models and security.suppress_unknown = true
-4No match, and the detector did see a face

Recoverable errors travel in-band (model_id -2), not as D-Bus errors, so clients can distinguish "the daemon decided auth cannot proceed" from "the daemon is unavailable". D-Bus errors remain for authorization failures, daemon-busy, transport problems, and the non-root ProcessFD/session gate above. In particular, a rate-limited state is a daemon decision and must never make the PAM client retry via a root oneshot.

-4 exists because similarity cannot carry "was a face seen?": the score is redacted to 0.0 for every non-root caller, so a user-run locker (hyprlock) could not tell a genuine face-seen non-match from an empty frame and abstained (PAM_IGNORE) for both. It is a detector signal — a face was present, never how close it came to an enrolled template — so unlike similarity it is not a hill-climbing oracle and is not redacted.

A PAM module older than -4 decodes it as an ordinary non-match (its sentinel match falls through to the same arm as -1), so a daemon newer than the installed module degrades to the previous behavior rather than breaking. In the other direction, a -1 reply carries no face-seen signal at all, and the module falls back to the score test it used before.

Rejection classes (AuthOutcome::Error)

The class of a rejection is carried as a type (facelock_daemon::auth::ErrorKind), not inferred from its message. The audit result label, the oneshot exit code, and the message itself all derive from it; ErrorKind::render is the only place any of these sentences is written. The wire has no field for the class, so the CLI's D-Bus client reconstructs it with ErrorKind::classify, the exact inverse of render.

Three rendered messages are frozen protocol because the PAM module matches them to choose its return code, and it cannot link the daemon crate to share the type (its dependency ceiling is libc/toml/serde/zbus):

Substring PAM matchesClassPAM code
rate limitedRateLimitedPAM_AUTH_ERR
IR camera requiredIrRequiredPAM_IGNORE
cancelled (matched exactly)AuthOutcome::CancelledPAM_IGNORE

Y16BitDepthRequired is a stable recoverable class even though PAM does not substring-match it: the daemon’s -2 encoding maps it to PAM_IGNORE, and oneshot exit 2 maps it to the same code. Its full rendered message is pinned by ErrorKind::classify and tests.

Changing any of these strings is a protocol break.

cancelled is not an ErrorKind. A rejection class is a statement about this user's face; a cancellation is the absence of one, so it is its own AuthOutcome variant (facelock_daemon::auth::CANCELLED_MESSAGE) that reuses the recoverable-error encoding to cross a wire with no field for it. PAM abstains on it: the attempt was abandoned, so the daemon has no opinion and the password modules run. It is matched exactly rather than as a substring, so an arbitrary error message that happens to mention cancelling cannot claim the row.

auth_attempted and a cancelled attempt. The signal carries only user and matched, and its signature is frozen; a cancelled attempt therefore emits auth_attempted(user, false), indistinguishable on the signal from a non-match. The audit log is where the two are told apart (cancelled vs failure).

They are pinned byte-exactly in crates/facelock-daemon/src/auth.rs (renderer, including the frozen cancellation string) and crates/facelock-daemon/tests/server_authz.rs (wire), and every class's message, audit label and exit code are pinned together in crates/facelock-cli/src/commands/auth.rs.

Daemon peer verification (PAM client)

Before trusting an Authenticate reply, the PAM module resolves the owner of org.facelock.Daemon (GetNameOwner, activating the service first if needed), requires the owner UID to be 0 (GetConnectionUnixUser), and pins the method call to the owner's unique bus name. A non-root owner is refused: the module falls through (oneshot fallback / password), never PAM_SUCCESS.

PAM Semantics

OutcomePAM Code
Face matchedPAM_SUCCESS (0)
No match, face seen (model_id -4)PAM_AUTH_ERR (7)
No match, no face seen (model_id -1)PAM_IGNORE (25)
Rate limited (daemon, model_id -2)PAM_AUTH_ERR (7) — no oneshot fallback
IR required / unverified Y16 texture scale / internal daemon error (model_id -2)PAM_IGNORE (25) — no oneshot fallback
Suppressed (model_id -3)PAM_AUTHINFO_UNAVAIL (9)
Daemon unavailable / untrusted (non-root) peeroneshot fallback, else PAM_IGNORE (25)
Oneshot fallback (and mode = "oneshot")per the facelock auth exit-code table above: 0 → PAM_SUCCESS, 1/3 → PAM_AUTH_ERR, 4 → PAM_AUTHINFO_UNAVAIL, 2/5/unknown/signal death → PAM_IGNORE
Config missing, unparseable, or untrusted (not root-owned / group- or world-writable, incl. parents)PAM_IGNORE (25)
Timeout (structured zbus timeout or overall deadline)PAM_AUTH_ERR (7)

PAM module never blocks indefinitely. All operations have timeouts, including D-Bus connection establishment (overall deadline on a worker thread).

The oneshot fallback spawns facelock auth with a sanitized environment: env_clear() plus an allow-list of SSH_CONNECTION, SSH_TTY, and a pinned PATH=/usr/bin:/bin. No other variables (LD_*, XDG_*, DBUS_*, ...) are inherited. Stdin is /dev/null.

Syslog Format

pam_facelock(<service>): <result> for user <username>

Polkit Agent Semantics

The facelock-polkit-agent offers face authentication for polkit actions, but scoped to an allowlist — face is not a universal key for every privileged action.

OutcomeAgent behavior
action_id not in polkit.face_eligible_actionsDeclines (returns org.freedesktop.DBus.Error.Failed) — see fallthrough-vs-denial caveat below
Allowlisted action, face matchesResponds success to polkit authority
Allowlisted action, no match / daemon errorDeclines (same caveat)
Username cannot be resolved to a uidRefuses to respond; never sends UID 0 for an unresolved name

NOTE (agent model only): polkit registers a single authentication agent per session and does not chain agents. When this agent declines, the decline returns an error, which — depending on the desktop's agent registration — may present as an authorization denial rather than a fallthrough to a password dialog. The intended UX (non-eligible actions handled by the desktop's normal password agent) is unverified pending live-desktop testing. Behavior here is fail-closed: a non-eligible action is never face-authorized. Does not apply to the PAM model, which always falls through to the password prompt.

A decline never fails open to root, and never causes this agent itself to grant authorization it should not — but see the caveat above on whether polkit treats a decline as a fall-through to another agent or as an outright denial.

Anti-Spoofing

DefenseConfigDefault
IR camera enforcementsecurity.require_irtrue
Frame variance checksecurity.require_frame_variancetrue
Frame variance cutoffsecurity.frame_variance_max_similarity0.985
IR texture cutoff (raw frame)security.ir_texture_min_stddev10.0
Landmark livenesssecurity.require_landmark_livenessfalse
Minimum auth frames (= variance window size)security.min_auth_frames3
Frame variance default constDEFAULT_FRAME_VARIANCE_MAX_SIMILARITY0.985

IR classification is derived from queried device evidence: a node is IR when its enumerated pixel formats are mono-only/IR-typical (GREY/Y8/Y10/Y12/Y16, with no color format mixed in), or when a quirks force_ir entry matches (authoritative by USB vendor:product ID; a name-only match requires corroborating format evidence or a real USB identity). The free-text device name never classifies a device on its own, and a GREY/Y16 format offered alongside a color format is not treated as IR. A force_ir quirk is device-level ("this USB device has an IR sensor"): when the device exposes multiple capture nodes and at least one has an IR-typical format, only the format-bearing node(s) classify IR. A quirk's format_preference participates in that decision only when the preferred format is itself IR-typical and actually advertised; an RGB preference such as MJPG cannot exempt an RGB sibling from demotion (see docs/security.md §A).

Y16 texture enforcement is separately fail-closed on scale provenance: only a valid verified y16_bit_depth quirk permits authentication; absent/invalid depth rejects recoverably before auth capture, including when security.require_ir = false. GREY is already 8-bit and is unaffected. Facelock does not introduce a scene-derived or scale-invariant authentication metric in this alpha.

Frame variance is passive anti-photo only (does not stop video replay); it is evaluated over a sliding window of the most recent min_auth_frames matched frames (see docs/security.md §B), with a 0.985 cutoff rejecting truly static input (≳0.999) with margin; the field-measured frozen-human band is 0.98–0.995, and the default sits inside it — a fully frozen user recovers via the sliding window as soon as they move slightly. IR texture is measured on the raw frame, never CLAHE. These defaults must not be weakened without security review.

Models

ModelFileSizeDefault
SCRFD 2.5Gscrfd_2.5g_bnkps.onnx~3MBYes
ArcFace R50w600k_r50.onnx~166MBYes
SCRFD 10Gdet_10g.onnx~17MBOptional
ArcFace R100glintr100.onnx~249MBOptional

Configurable via recognition.detector_model and recognition.embedder_model. Bundled model filenames are verified against the manifest hash at load time. Custom model files require matching recognition.detector_sha256 or recognition.embedder_sha256.