Introduction

Facelock is a modern face authentication system for Linux PAM. It provides Windows Hello-style facial authentication with IR-required capture and layered static-presentation checks, configurable as a persistent daemon or daemonless one-shot. Inference runs locally; model download occurs during setup, while the authentication path makes no network request and sends no telemetry.

Quick Start

The latest stable release is v0.2.0, which provides direct Debian 13, Ubuntu 26.04, and Fedora 44 packages and is served by the AUR entries, both APT suites, and the production COPR. See Quick Start for the exact package filenames, the APT source entry, and the current channel status.

just build
target/debug/facelock --help

Install the distro-specific native build dependencies first; Rust and the dynamically loaded ONNX Runtime are separate prerequisites. just build does not install the binary, and --help does not test inference or load ONNX Runtime. Camera-facing development uses the explicit built path and --config "$PWD/dev/config.toml"; management commands remain root-gated and root ignores FACELOCK_CONFIG. See Quick Start before enrolling or changing host authentication.

Operating Modes

ModeConfigHow it worksLatency
Daemonmode = "daemon" (default)PAM connects via D-Bus, persistent daemonfastest: no model load, no reopen when warm
D-Bus activationsystemd + D-Bus servicesystemd starts daemon on demand+ daemon start on the first call
Oneshotmode = "oneshot"PAM spawns facelock auth subprocess+ model load on every call

Daemon latency depends on camera state: a cold attempt pays a camera reopen, a retry within device.camera_release_secs of a failed attempt does not. That reopen cost is a property of your camera and driver, not a number to quote from someone else's laptop -- measure it with sudo facelock bench camera-reopen, which prints the open / STREAMON / warmup split.

The CLI works in all modes -- it connects to the daemon if available, otherwise operates directly.

Architecture

facelock (unified binary)
├── facelock setup          Download models, validate systemd, configure PAM
├── facelock enroll         Capture and store a face
├── facelock test           Test recognition
├── facelock list           List enrolled models
├── facelock preview        Live camera preview
├── facelock daemon         Run persistent daemon
├── facelock auth           One-shot auth (PAM helper)
├── facelock devices        List cameras
├── facelock tpm status     TPM status
└── facelock bench          Benchmarks

pam_facelock.so (PAM module)
├── daemon mode → D-Bus IPC to daemon
└── oneshot mode → fork/exec facelock auth

Crates

CrateTypePurpose
facelock-corelibConfig, types, errors, D-Bus interface, traits
facelock-cameralibV4L2 capture, auto-detection, preprocessing
facelock-facelibONNX inference (SCRFD detection + ArcFace embedding)
facelock-storelibSQLite face embedding storage
facelock-daemonlibAuth/enroll logic, liveness, audit, rate limiting, request handler
facelock-clibinAll CLI commands, daemon runner, direct mode, benchmarks
facelock-benchbinStandalone benchmark and calibration utility
pam-facelockcdylibPAM module (libc + toml + serde + zbus only)
facelock-tpmlibOptional TPM-bound encryption for embeddings at rest
facelock-polkitbinPolkit authentication agent for face auth
facelock-test-supportlibMock camera/engine for testing

Face Recognition Pipeline

Camera Frame → SCRFD Detection → 5-point landmarks
  → Affine Alignment → 112x112 face crop
  → ArcFace Embedding → 512-dim L2-normalized vector
  → Cosine Similarity vs stored embeddings → MATCH / NO MATCH

Configuration

All keys are optional. Camera is auto-detected if device.path is omitted. See the Configuration chapter for full reference.

[device]
# path = "/dev/video2"     # auto-detected if omitted (prefers IR)

[recognition]
# threshold = 0.80         # cosine similarity threshold

[daemon]
# mode = "daemon"          # "daemon" or "oneshot"

[security]
# require_ir = true        # refuse auth on RGB cameras
# require_frame_variance = true  # reject photo attacks

Installation

See Quick Start for full instructions.

Privacy & Security

Privacy: Facelock is 100% local. Face detection and recognition run entirely on your hardware via ONNX Runtime. No images, embeddings, or metadata are ever sent to any external server. There is no telemetry, no analytics, no phone-home behavior. Models are downloaded once during setup -- after that, Facelock never touches the network.

Security:

  • IR camera enforcement on by default (anti-spoofing)
  • Frame variance checks reject static photo attacks
  • Constant-time embedding comparison via subtle crate
  • AES-256-GCM encryption at rest with optional TPM-sealed keys
  • Model SHA256 verification at every load
  • D-Bus system bus policy
  • PAM audit logging to syslog
  • Rate limiting (5 face-detected authentication failures/user/60s by default)
  • systemd service hardening

See Security for the full threat model.

License

Dual-licensed under MIT or Apache 2.0, at your option.

The ONNX face models used by Facelock are licensed separately under the InsightFace non-commercial research license.

Quickstart

Published packages

As checked on 2026-09-06, v0.2.0 is the latest stable release. Every stable channel serves it: the three AUR entries, both APT suites, and the production COPR.

The following SHA256 values are pinned from the release's MANIFEST.json. Each command chain verifies the downloaded bytes before invoking the package manager; stop if verification fails. These checks establish integrity against the reviewed release, not an independent signing or build attestation.

For Debian 13 amd64, install the published package directly:

curl -fLO https://github.com/tyvsmith/facelock/releases/download/v0.2.0/facelock_0.2.0-1.deb13u1_amd64.deb &&
printf '%s  %s\n' '2554a1dcd4eca7bb1e3c2f2fbafbffa31d2c6a84210d32e9e221ece9e6e8dace' 'facelock_0.2.0-1.deb13u1_amd64.deb' | sha256sum --check - &&
sudo apt install ./facelock_0.2.0-1.deb13u1_amd64.deb

For Ubuntu 26.04 amd64, use the corresponding suite build:

curl -fLO https://github.com/tyvsmith/facelock/releases/download/v0.2.0/facelock_0.2.0-1.ubuntu26.04.1_amd64.deb &&
printf '%s  %s\n' 'ee4bc06963752bf39e888c2d10ba5229f256978d0b14b134463e19525b323d79' 'facelock_0.2.0-1.ubuntu26.04.1_amd64.deb' | sha256sum --check - &&
sudo apt install ./facelock_0.2.0-1.ubuntu26.04.1_amd64.deb

The dots in those download filenames are GitHub-safe stored names. MANIFEST.json records the native names facelock_0.2.0-1~deb13u1_amd64.deb and facelock_0.2.0-1~ubuntu26.04.1_amd64.deb, and Debian reports the installed versions 0.2.0-1~deb13u1 and 0.2.0-1~ubuntu26.04.1. The bytes and the checksum are the same either way. Fedora 44 x86_64 users can install the direct release RPM:

curl -fLO https://github.com/tyvsmith/facelock/releases/download/v0.2.0/facelock-0.2.0-1.fc44.x86_64.rpm &&
printf '%s  %s\n' '65cf7d3167979daa8d5af6f0e5c96c25d21c6b27b07a47d8dd212007fab725c5' 'facelock-0.2.0-1.fc44.x86_64.rpm' | sha256sum --check - &&
sudo dnf install ./facelock-0.2.0-1.fc44.x86_64.rpm

The separately downloadable facelock, PAM module, and polkit-agent binaries are release components, not a complete installation. They do not install the configuration, models, service and D-Bus policy, PAM layout, or required shared libraries; use a native package or the source installation path instead.

Arch users can install the stable source-build AUR package. facelock-bin is the prebuilt alternative and facelock-git follows development; all three AUR entries served version 0.2.0-1 on 2026-09-06, and each declares the onnxruntime dependency the binary loads at runtime.

yay -S facelock

Debian 13 (trixie) and Ubuntu 26.04 LTS (resolute) on amd64 are the 0.2.0 stable APT targets. Both suites are published at https://tysmith.me/facelock/apt and served 0.2.0 when checked on 2026-09-06:

Supported targetSuiteArchitectureRequired capability
Debian 13trixieamd64TPM
Ubuntu 26.04resoluteamd64TPM

Install the archive keyring, write a source entry naming your codename, then install the package:

sudo curl -fsSL https://tysmith.me/facelock/apt/tysmith-archive-keyring.gpg \
  -o /usr/share/keyrings/tysmith-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/tysmith-archive-keyring.gpg] https://tysmith.me/facelock/apt trixie facelock" | sudo tee /etc/apt/sources.list.d/facelock.list
sudo apt update
sudo apt install facelock

On Ubuntu 26.04, use resolute instead of trixie in the source line. The keyring holds one rsa4096 key, Ty Smith (Package Signing) <packages@m.tysmith.me>, fingerprint E7F8A4C424C6D59BD38536B536A81FCD934C17CE, checked on 2026-09-06. Confirm it with gpg --show-keys before trusting the source; signed-by scopes that key to this repository alone, and apt update rejects the suite if the archive signature does not match.

Existing v0.1.4 entries naming main or legacy keep working until 0.3.0: main maps to the Trixie package set and legacy serves signed empty indexes. At 0.3.0, apt update fails until the entry is removed. Rewrite those entries to your operating system's codename now.

The production COPR targets Fedora 43, 44, and 45 only. It served 0.2.0-1 on all three when checked on 2026-09-06. The older 0.1.3 build stays in the repository; dnf resolves to the newest, so no pinning is needed. tyvsmith/facelock-testing is a staging/candidate project, not a stable channel. RHEL is not in the supported matrix. Fedora's dnf copr command comes from dnf5-plugins; installing it is idempotent, and minimal installations may not include it.

sudo dnf install dnf5-plugins
sudo dnf copr enable tyvsmith/facelock
sudo dnf install facelock

After any package install, keep a separate root shell open and run the wizard:

sudo facelock setup
sudo facelock test

The wizard offers camera selection, model download, encryption, daemon setup, enrollment, and PAM configuration. If enrollment is selected there, do not run a redundant initial facelock enroll. facelock test may exit zero when no scan ran or when a completed scan did not match; verify the printed result, then test sudo from a new terminal before closing the recovery shell.

Build from source

Source-build prerequisites

Facelock's minimum supported Rust version is 1.88+. rust-toolchain.toml selects Rust 1.95 when Cargo is a rustup proxy; distro-provided Cargo does not interpret that file and uses its installed compiler instead. The target distro versions below all satisfy the 1.88 floor. ONNX Runtime is a separate runtime shared library: compiling the Rust crate does not install it.

On Arch Linux, the rust package provides Cargo. Use the exact CPU runtime package, onnxruntime-cpu; onnxruntime is a virtual provide shared by the CPU, CUDA, and ROCm variants.

sudo pacman -Syu --needed base-devel git rust just clang gettext pkgconf pam v4l-utils wayland libxkbcommon tpm2-tss onnxruntime-cpu

On Debian 13, add deb http://deb.debian.org/debian trixie-backports main to a root-owned file under /etc/apt/sources.list.d/. Trixie's native Rust 1.85 is below the project floor; install both Rust packages explicitly from backports.

sudo apt update
sudo apt install build-essential git just clang gettext pkg-config libpam0g-dev libv4l-dev libwayland-dev libxkbcommon-dev libtss2-dev
sudo apt install -t trixie-backports rustc cargo

Ubuntu 26.04's native Rust satisfies the floor:

sudo apt update
sudo apt install build-essential git rustc cargo just clang gettext pkg-config libpam0g-dev libv4l-dev libwayland-dev libxkbcommon-dev libtss2-dev

Fedora 43/44/45 provides both the compiler dependencies and a system ONNX Runtime. These are the names used by the RPM build, plus just for this repository's commands:

sudo dnf install git rust cargo just gcc gcc-c++ clang-devel gettext pkgconf-pkg-config pam-devel libv4l-devel wayland-devel libxkbcommon-devel tpm2-tss-devel onnxruntime

Debian 13 publishes libonnxruntime1.21, and Ubuntu 26.04 publishes libonnxruntime1.23. Their multiarch library paths and versioned SONAMEs (libonnxruntime.so.1.21 and libonnxruntime.so.1.23) are incompatible with Facelock's current trusted loader, which requires libonnxruntime.so.1 as the SONAME and searches fixed runtime directories rather than multiarch paths. A source build can compile and run non-inference commands such as --help, but enrollment, authentication, preview, and inference benchmarks require a separately installed compatible ONNX Runtime 1.20+ in a trusted runtime location. Merely adding a symlink does not repair a mismatched SONAME. The published .deb packages bundle the compatible CPU runtime 1.20.1; this is not supplied by Cargo.

just build
target/debug/facelock --help

just build does not install anything or put facelock on PATH; use the explicit target/debug/facelock path. target/debug/facelock --help does not load ONNX Runtime and is not an inference check.

For camera development, first provide the checksum-verified model files and use the development configuration explicitly:

just link-models
sudo target/debug/facelock --config "$PWD/dev/config.toml" devices
sudo target/debug/facelock --config "$PWD/dev/config.toml" enroll --skip-setup-check
sudo target/debug/facelock --config "$PWD/dev/config.toml" test

These commands are privileged because the management CLI enforces its normal root gate even with dev/config.toml. FACELOCK_CONFIG cannot replace the explicit flag under sudo: all effective-UID-0 processes ignore that variable. The development configuration uses direct/oneshot operation and temporary state; it is not an installed PAM setup.

On Arch, after installing onnxruntime-cpu, a source-based system install is:

just install
sudo facelock setup
sudo facelock test

just install builds as the invoking user and prompts for sudo only for the file installation. It installs the binary, PAM module, service units, D-Bus policy, configuration and supporting assets, but does not edit any PAM service. Only the later wizard or facelock pam add does that. This source installer uses the Arch/Debian /lib/security PAM layout. Use the distro package rather than this installer on Fedora, and do not treat a Debian/Ubuntu source install as inference-capable until a trusted ONNX Runtime has been installed separately.

NixOS source-tree module

Facelock is not in nixpkgs and Nix is not a published package channel or a row in the supported release matrix. The repository does ship a flake and NixOS module under dist/nix. Release CI gates flake evaluation, while the actual Nix build remains advisory and the derivation disables its test phase. The flake has no checked-in flake.lock, so evaluation resolves network inputs and is not a locked, reproducible package publication.

The source-tree module exports nixosModules.default. Its public options are services.facelock.enable, services.facelock.package, and services.facelock.config; the last maps directly to Facelock's TOML configuration. When enabled, it installs the selected package, writes the configuration, enables the daemon, and adds Facelock to the sudo PAM service.

This interface is experimental and is not currently a usable authentication installation. The derivation places ONNX Runtime under its Nix store output, but privileged Facelock processes search only the trusted /usr/lib and /usr/lib64 roots and intentionally ignore ORT_DYLIB_PATH. The module also does not provision the model files or the encryption key needed for enrollment. Do not enable its PAM rule on a system that depends on it until those gaps are fixed and the complete NixOS path has been validated.

Explore safely

After system installation and setup:

sudo facelock devices
sudo facelock list
sudo facelock preview --json
sudo facelock status
sudo facelock bench camera-reopen

Camera commands touch real hardware. See Testing Safety before changing PAM, and Developer Commands for the full validation inventory.

Package lifecycle and retained data

For a source installation only, use the source checkout's removal recipe:

just uninstall

For a native package installation, remove Facelock through the same package manager that installed it; do not use the source uninstaller to delete package-owned files behind the package manager.

Ordinary package removal and just uninstall preserve the face database, encryption keys, models, enrollment markers, logs, snapshots, and setup state. Debian purge removes only provably safe entries under the compiled Facelock roots; safety refusals are reported without stranding package-manager state. Unsafe or externally configured remnants are retained for manual review. To inspect the supported bounded erasure path while Facelock is still installed:

sudo facelock data purge --dry-run
sudo facelock data purge --allow-destruction

Purge admits only safe entries inside the three compiled Facelock roots. It does not follow links, cross mounts, remove unsafe/wrong-owner objects, or chase configured paths outside those roots. Its report may therefore list remnants that require manual review; it never promises whole-disk erasure. See Package Lifecycle Ownership.

Configuration highlights

The installed file is /etc/facelock/config.toml; source development uses dev/config.toml. The default encryption method is keyfile, IR is required, and plaintext enrollment is refused unless explicitly enabled. See the Configuration Reference.

Configuration Reference

Facelock reads its configuration from /etc/facelock/config.toml. FACELOCK_CONFIG overrides it only when the effective user is non-root. Every effective-UID-0 process ignores the environment; use an explicit --config where supported, or the default path.

All settings are optional. Facelock auto-detects the camera and uses sensible defaults. The annotated config file at config/facelock.toml in the repository serves as the canonical example.

[device]

Camera settings.

KeyTypeDefaultDescription
pathstring (optional)Auto-detectCamera device path (e.g., /dev/video2). When omitted, Facelock auto-detects the best available camera, preferring IR over RGB.
max_heightu32480Maximum frame height in pixels. Frames taller than this are downscaled to improve processing speed.
rotationu160Rotate captured frames. Values: 0, 90, 180, 270. Useful for cameras mounted sideways.
warmup_framesu322Frames to discard immediately after opening the camera to let exposure and gain stabilize. Device quirks may override this.
dark_thresholdf320.6Fraction of pixels that must be darker than dark_pixel_value before the frame is treated as unusably dark.
dark_pixel_valueu810Pixel brightness cutoff used by the dark-frame check.
ir_emitterboolfalseAttempt to enable a controllable IR emitter when the camera opens. Only needed for hardware that does not auto-enable its IR LED.
camera_release_secsu323Daemon only. Seconds to keep the camera streaming after a failed authentication so an immediate retry skips the reopen cost. Cancellation and errors release the camera at once, and so does a success unless camera_release_after_success_secs is set. 0 disables the hold entirely (it used to be silently substituted with 5).
camera_release_after_success_secsu320Daemon only. Seconds to keep the camera streaming after a successful authentication too. 0 (the default) releases it immediately — the interaction is over, and on IR hardware the emitter LED goes out with it. Set it only where privileged actions repeat with no authentication caching in front of them (sudo with a zero timestamp_timeout, a polkit action without auth_admin_keep), so each one is a fresh authentication that would otherwise pay a camera reopen. Failures still use camera_release_secs; cancellations and errors always release at once.

[recognition]

Face detection and embedding parameters.

KeyTypeDefaultDescription
thresholdf320.80Cosine similarity threshold for accepting a face match. Must be between 0.0 and 1.0. Higher values are stricter. See the range guide below.
timeout_secsu325Maximum seconds to attempt recognition before giving up. Must be > 0.
no_face_timeout_secsu322Seconds to keep scanning when no face at all has been detected. Once a face is seen, timeout_secs takes over — "seen, not matched yet" is the case worth waiting out. An empty-chair attempt ends early and charges no rate-limit budget. Clamped to timeout_secs (never an error); 0 disables the early exit.
detection_confidencef320.5Minimum confidence for the face detector to report a detection. Lower values detect more faces but increase false positives.
nms_thresholdf320.4Non-maximum suppression threshold for overlapping detections.
detector_modelstring"scrfd_2.5g_bnkps.onnx"ONNX detector model filename. Must exist in daemon.model_dir. Bundled models are verified against the manifest; custom models require detector_sha256.
detector_sha256string (optional)unsetRequired digest for a custom detector; bundled models use the manifest digest.
embedder_modelstring"w600k_r50.onnx"ONNX embedder model filename. Must exist in daemon.model_dir. Bundled models are verified against the manifest; custom models require embedder_sha256.
embedder_sha256string (optional)unsetRequired digest for a custom embedder; bundled models use the manifest digest.
execution_providerstring"cpu"ONNX Runtime execution provider. Values: "cpu", "cuda", "rocm", "openvino". GPU providers require a GPU-enabled ONNX Runtime package installed on the system.
threadsu324Number of CPU threads for ONNX inference.

Threshold range guide (ArcFace cosine similarity)

RangeDescription
0.30 -- 0.50Very loose -- high false accept rate, not recommended
0.50 -- 0.65Loose -- convenient but may accept similar-looking people
0.65 -- 0.80Balanced -- good for most setups, low false accept rate
0.80 -- 0.90Strict -- rarely accepts wrong person, may reject on bad angles
0.90+Very strict -- may require near-ideal lighting and pose

Run sudo facelock test to see your similarity scores, then set the threshold below your typical match score with some margin. Exit zero alone is not a match verdict; inspect the output.

Model tiers

TierDetectorEmbedderTotal sizeNotes
Standardscrfd_2.5g_bnkps.onnx (3MB)w600k_r50.onnx (166MB)~170MBFast, good accuracy (default)
Balancedscrfd_2.5g_bnkps.onnx (3MB)glintr100.onnx (249MB)~252MB~15-30ms slower, better recognition
High accuracydet_10g.onnx (17MB)glintr100.onnx (249MB)~266MB~40-50ms slower, best accuracy

Run sudo facelock setup to select a model tier interactively and download the required models. If you point detector_model or embedder_model at a custom file, you must also set the matching SHA256 so the daemon can verify it at load time.

[daemon]

Controls how the PAM module reaches the face engine.

KeyTypeDefaultDescription
modestring"daemon""daemon" connects to a persistent daemon via D-Bus system bus (models stay loaded; only a cold attempt pays a camera reopen -- measure it with sudo facelock bench camera-reopen). "oneshot" spawns facelock auth per PAM call (slower: model load on every call, no background process).
model_dirstring"/var/lib/facelock/models"Directory containing ONNX model files.
idle_timeout_secsu640Shut down the daemon after this many idle seconds. 0 means never. Useful with D-Bus activation.

[storage]

KeyTypeDefaultDescription
db_pathstring"/var/lib/facelock/facelock.db"SQLite database for face embeddings. File permissions should be 600, owned by root:root.

[security]

KeyTypeDefaultDescription
disabledboolfalseDisable face authentication entirely. PAM returns IGNORE, falling through to the next auth method.
abort_if_sshbooltrueRefuse face auth when connected via SSH (no camera available).
abort_if_lid_closedbooltrueRefuse face auth when the laptop lid is closed (camera blocked).
require_irbooltrueRequire an IR camera for authentication. RGB cameras are trivially spoofed with a printed photo. Only set to false for development/testing.
require_frame_variancebooltrueRequire multiple frames with different embeddings before accepting. Defends against static photo attacks.
frame_variance_max_similarityf320.985Maximum similarity between consecutive matched frames in the variance window. Passive anti-photo check only; it does not stop video replay.
ir_texture_min_stddevf3210.0Minimum raw-grayscale standard deviation for the IR texture check.
require_landmark_livenessboolfalseRequire landmark movement between frames to pass liveness check. Detects static images by tracking facial landmark positions across frames. Experimental; off by default.
landmark_displacement_pxf321.5Minimum pixel displacement for a landmark to count as "moving" between frames. Only used when require_landmark_liveness is true.
landmark_min_movingu323Number of facial landmarks (out of 5) that must show movement to pass the liveness check. Only used when require_landmark_liveness is true.
suppress_unknownboolfalseSuppress warnings for unknown users (users with no enrolled face).
min_auth_framesu323Minimum number of matching frames required before accepting. Only applies when require_frame_variance is true.
bind_templates_to_devicebooltrueSkip templates enrolled on a camera that does not match the live camera at the configured granularity. Advisory, not device attestation.
device_match_granularitystring"model""model" compares VID:PID; "unit" also requires a stable serial.
bind_legacy_templatesbooltruePermit older templates without a device identity, with a re-enrollment warning.
bind_device_aadboolfalseOpt-in cryptographic camera binding for encrypted templates; requires re-enrollment and a usable device identity.
allow_plaintextboolfalsePermit encryption.method = "none"; without it, plaintext enrollment is refused.

[security.rate_limit]

KeyTypeDefaultDescription
max_attemptsu325Maximum face-detected authentication failures per user per window; successful and no-face attempts do not consume this budget.
window_secsu6460Rate limit window in seconds.

[security.pam_policy]

KeyTypeDefaultDescription
allowed_serviceslist of strings[]If non-empty, only these PAM services may use facelock.
denied_serviceslist of strings[]PAM services that must always skip facelock, even if otherwise allowed.

[notification]

Controls how authentication feedback is delivered.

KeyTypeDefaultDescription
modestring"terminal"Notification mode. "off" -- no notifications. "terminal" -- PAM text prompts only. "desktop" -- desktop popups only (via D-Bus/notify-send). "both" -- terminal and desktop.
notify_promptbooltrueShow prompt when scanning starts ("Identifying face...").
notify_on_successbooltrueNotify on successful face match.
notify_on_failureboolfalseNotify on failed face match.

[snapshots]

Save camera snapshots on auth attempts for debugging or auditing.

KeyTypeDefaultDescription
modestring"off""off" -- never save. "all" -- every attempt. "failure" -- failed auth only. "success" -- successful auth only.
dirstring"/var/log/facelock/snapshots"Directory for snapshot JPEG images.

[encryption]

Controls how face embeddings are encrypted at rest.

KeyTypeDefaultDescription
methodstring"keyfile""keyfile" -- AES-256-GCM with a root-only key file. "tpm" -- AES-256-GCM with a TPM-sealed key. "none" requires security.allow_plaintext = true.
key_pathstring"/etc/facelock/encryption.key"Path to AES-256-GCM key file for keyfile method.
sealed_key_pathstring"/etc/facelock/encryption.key.sealed"Path to TPM-sealed AES key for tpm method.

With method = "tpm", the 32-byte AES key is sealed by the TPM at rest. At daemon startup, the key is unsealed and held in memory. Embeddings use the same AES-256-GCM format as keyfile — no re-encryption needed when migrating between methods. The root-gated migration commands are sudo facelock tpm seal-key (keyfile → tpm) and sudo facelock tpm unseal-key (tpm → keyfile). seal-key requires the plaintext key to exist and refuses to overwrite a sealed key; unseal-key requires the sealed key and refuses to overwrite a plaintext key. Each command updates encryption.method only after writing the destination key.

[polkit]

KeyDefaultDescription
face_eligible_actions["org.freedesktop.login1.lock-sessions"]Action IDs the optional agent may handle; it declines all others.

[pam]

KeyDefaultDescription
config_dirs["/etc/pam.d", "/usr/lib/pam.d"]Lookup order. Only the first directory is writable; later entries are vendor roots.

[audit]

Structured audit logging of authentication events.

KeyTypeDefaultDescription
enabledboolfalseEnable structured audit logging to JSONL file.
pathstring"/var/log/facelock/audit.jsonl"Path to the audit log file.
rotate_size_mbu3210Rotate the log file when it exceeds this size (in MB).

[tpm]

TPM 2.0 settings for sealing the AES encryption key. These settings apply when encryption.method = "tpm".

KeyTypeDefaultDescription
seal_databaseboolfalseSeal the SQLite database file with the TPM key in addition to the encryption key.
pcr_bindingboolfalseBind sealed key to boot state (PCR values).
pcr_indiceslist of u32[0, 1, 2, 3, 7]PCR registers to verify on unseal.
tctistring"device:/dev/tpmrm0"TPM Communication Interface.

GPU Acceleration

GPU support in Facelock is runtime-only -- Facelock itself needs no rebuild. Install an ONNX Runtime built with the matching execution provider, satisfy the vendor driver/runtime requirements, and set execution_provider in the configuration. GPU paths are configuration-supported but are not part of the release package validation matrix.

Setup

1. Install a GPU-enabled ONNX Runtime

GPU VendorArch Linux PackageOther Distros
NVIDIAonnxruntime-opt-cudaInstall a compatible NVIDIA driver and ONNX Runtime with CUDA support
AMDonnxruntime-opt-rocmInstall the ROCm stack and ONNX Runtime with ROCm support
Intelnone packagedBuild ONNX Runtime with the OpenVINO provider and install its required OpenVINO runtime

On Arch Linux, these are official Extra repository packages as checked on 2026-09-05. Each provides and conflicts with the virtual onnxruntime dependency, so the variants do not install side by side. If another variant is installed, pacman prompts to remove it; review and accept that transaction to switch providers:

sudo pacman -S onnxruntime-opt-cuda      # NVIDIA
sudo pacman -S onnxruntime-opt-rocm      # AMD

Arch packages no OpenVINO build of ONNX Runtime, in the repositories or the AUR as checked on 2026-09-05. Build ONNX Runtime with the OpenVINO execution provider yourself to use execution_provider = "openvino". Debian 13 publishes libonnxruntime1.21, and Ubuntu 26.04 publishes libonnxruntime1.23 plus libonnxruntime-providers, as checked on 2026-09-05. Their packaged provider modules are CPU/oneDNN rather than CUDA, ROCm, or OpenVINO. They also install in the multiarch library directory with versioned SONAMEs that Facelock's current trusted runtime loader does not accept, so they cannot serve as its runtime. Facelock's published .deb packages bundle a compatible CPU-only runtime and therefore do not enable a GPU provider. Fedora's COPR package depends on Fedora's CPU-only onnxruntime package.

2. Set the execution provider

Either let setup detect it:

sudo facelock setup --execution-provider=auto

auto asks the installed ONNX Runtime which providers it was built with and selects cuda > rocm > openvino > cpu, printing what it found either way. See --execution-provider=auto in the CLI reference.

Or set it yourself in /etc/facelock/config.toml:

[recognition]
execution_provider = "cuda"    # or "rocm" or "openvino"

3. Restart the daemon

sudo facelock daemon restart

4. Verify

sudo facelock status
sudo facelock bench warm-auth

facelock status must report that the configured provider is built into the installed ONNX Runtime. Then compare the benchmark with execution_provider = "cpu"; timing alone is not proof that the GPU provider loaded.

How it works

Facelock uses the ort crate with the load-dynamic feature. It accepts ONNX Runtime 1.20+ from fixed, root-owned system and Facelock package directories; privileged commands ignore ORT_DYLIB_PATH. For a configured GPU provider it prefers the trusted system runtime over Facelock's bundled CPU runtime. The execution_provider config selects which provider to register.

If the configured provider is not built into the installed runtime, status and daemon startup warn about the mismatch. ONNX Runtime may fall back to CPU, so do not infer GPU use merely from a successful authentication.

Supported providers

ProviderConfig valueStatus
CPU"cpu"Default; covered by package validation
CUDA (NVIDIA)"cuda"Config supported, requires CUDA-enabled ORT; not release-matrix tested
ROCm (AMD)"rocm"Config supported, requires ROCm-enabled ORT; not release-matrix tested
OpenVINO (Intel)"openvino"Config supported, requires a custom OpenVINO-enabled ORT; not release-matrix tested

systemd note

The systemd service has MemoryDenyWriteExecute=yes commented out because GPU inference runtimes (CUDA, TensorRT) use JIT compilation which requires writable+executable memory pages. If you are using CPU-only, you can re-enable this directive for additional hardening.

Troubleshooting

  • "Failed to load execution provider": The GPU-enabled ONNX Runtime package is not installed or libonnxruntime.so does not include the requested provider.
  • Slower than CPU: Ensure the GPU driver is loaded (nvidia-smi for NVIDIA, rocm-smi for AMD). Small models like SCRFD 2.5G may not benefit from GPU due to transfer overhead.
  • Daemon exits during startup: Check journalctl -u facelock-daemon and diagnose the reported error; runtime loading, provider initialization and device-memory failures require different remedies.

Architecture

Overview

Facelock is a face authentication system for Linux PAM. It detects faces via SCRFD, extracts embeddings via ArcFace, and matches against stored models using cosine similarity. Authentication and inference are local: they make no network calls and use no cloud service or telemetry. The separately invoked setup flow may download model files.

System Diagram

┌──────────────────┐     ┌──────────────────────────────────────┐
│  sudo / login    │     │  facelock CLI                          │
│  (PAM stack)     │     │  (enroll, test, list, preview, ...)  │
└────────┬─────────┘     └───────────────┬──────────────────────┘
         │                               │
         │                               │ direct mode (fallback)
         │                               │ or IPC to daemon
    ┌────▼────────────┐                  │
    │  pam_facelock.so  │──────────────────┤
    │  (~2MB cdylib) │                  │
    │                 │                  │
    │  daemon mode:   │                  │
    │  → D-Bus IPC    │          ┌───────▼──────────────┐
    │                 │          │  facelock daemon        │
    │  oneshot mode:  │          │  (persistent process) │
    │  → facelock auth  │          │                       │
    └─────────────────┘          │  ┌─────────────────┐  │
                                 │  │ V4L2 Camera     │  │
                                 │  │ (auto-detected) │  │
                                 │  └────────┬────────┘  │
                                 │           │           │
                                 │  ┌────────▼────────┐  │
                                 │  │ SCRFD Detection  │  │
                                 │  │ → Alignment     │  │
                                 │  │ → ArcFace Embed │  │
                                 │  └────────┬────────┘  │
                                 │           │           │
                                 │  ┌────────▼────────┐  │
                                 │  │ SQLite Store    │  │
                                 │  │ (embeddings)    │  │
                                 │  └─────────────────┘  │
                                 └───────────────────────┘

Crate Dependencies

facelock-core (config, types, IPC, traits)
    ├── facelock-camera (V4L2, auto-detect, preprocessing)
    ├── facelock-face (ONNX: SCRFD + ArcFace)
    ├── facelock-store (SQLite)
    ├── facelock-tpm (optional TPM encryption)
    └── facelock-test-support (mocks, dev-only)

facelock-daemon (auth/enroll logic, liveness, audit, rate limiter, handler)
    └── depends on: core, camera, face, store, tpm

facelock-cli (unified binary)
    └── depends on: core, camera, face, store, daemon, tpm

facelock-polkit (polkit agent)
    └── depends on: core

pam-facelock (PAM module)
    └── depends on: libc, toml, serde, zbus ONLY (no facelock crates)

Mermaid Diagrams

The diagrams below render in GitHub, mdBook, and any Mermaid-capable viewer. They cover the same information as the ASCII diagrams above but add external integrations and the authentication data flow.

Crate Dependency Graph

graph TD
    subgraph Workspace Crates
        core[facelock-core<br/><i>config, types, errors,<br/>D-Bus interface, traits</i>]
        camera[facelock-camera<br/><i>V4L2 capture, preprocessing</i>]
        face[facelock-face<br/><i>ONNX: SCRFD + ArcFace</i>]
        store[facelock-store<br/><i>SQLite embeddings</i>]
        tpm[facelock-tpm<br/><i>TPM / AES-256-GCM</i>]
        test[facelock-test-support<br/><i>mocks, fixtures</i>]
        daemon[facelock-daemon<br/><i>auth, enroll, rate limit,<br/>liveness, audit</i>]
        cli[facelock-cli<br/><i>unified binary</i>]
        polkit[facelock-polkit<br/><i>Polkit auth agent</i>]
        pam[pam-facelock<br/><i>PAM module cdylib</i>]
    end

    camera --> core
    face --> core
    store --> core
    tpm --> core
    test -.-> core
    polkit --> core

    daemon --> core
    daemon --> camera
    daemon --> face
    daemon --> store
    daemon --> tpm

    cli --> core
    cli --> camera
    cli --> face
    cli --> store
    cli --> daemon
    cli --> tpm

    pam -. "no facelock crates<br/>(libc, toml, serde, zbus)" .-> pam

    style pam fill:#f9f,stroke:#333
    style core fill:#bbf,stroke:#333
    style daemon fill:#bfb,stroke:#333
    style cli fill:#bfb,stroke:#333

System Data Flow and IPC

flowchart LR
    subgraph Clients
        login[sudo / login<br/><i>PAM stack</i>]
        cliclient[facelock CLI]
    end

    subgraph IPC
        dbus[[D-Bus<br/>system bus]]
    end

    subgraph Daemon["facelock daemon"]
        direction TB
        cam[facelock-camera<br/><i>V4L2 capture +<br/>preprocessing</i>]
        det[facelock-face<br/><i>SCRFD detection +<br/>alignment</i>]
        emb[facelock-face<br/><i>ArcFace embedding</i>]
        st[facelock-store<br/><i>load stored<br/>embeddings</i>]
        match[facelock-core<br/><i>constant-time<br/>cosine match</i>]

        cam --> det --> emb --> st --> match
    end

    subgraph External Systems
        v4l2[(V4L2<br/>camera)]
        onnx[(ONNX<br/>Runtime)]
        sqlite[(SQLite)]
        tpmd[(TPM)]
        sysd[systemd]
        syslog[syslog]
        polkitd[polkit]
    end

    login -->|pam_facelock.so| dbus
    cliclient -->|zbus client| dbus
    dbus --> Daemon

    cam ---|capture| v4l2
    det ---|inference| onnx
    emb ---|inference| onnx
    st ---|query| sqlite
    Daemon ---|optional key sealing| tpmd
    Daemon ---|service activation| sysd
    Daemon ---|audit logging| syslog

    polkitagent[facelock-polkit] --> polkitd
    polkitagent --> dbus

    style dbus fill:#ff9,stroke:#333
    style Daemon fill:#eef,stroke:#339
    style match fill:#bfb,stroke:#333

Face Recognition Pipeline

Detection (SCRFD)

  • Input: grayscale frame after CLAHE enhancement
  • Output: bounding boxes + 5-point landmarks (eyes, nose, mouth corners)
  • Confidence threshold: recognition.detection_confidence (default 0.5)
  • NMS threshold: recognition.nms_threshold (default 0.4)

Alignment

  • Affine transform from 5 landmarks to canonical positions
  • Output: 112x112 aligned face crop
  • Uses Umeyama similarity transform

Embedding (ArcFace)

  • Input: 112x112 RGB face crop
  • Output: 512-dimensional L2-normalized float32 vector
  • Cosine similarity = dot product (since L2-normalized)

Matching

  • Compare live embedding against all stored embeddings for the user
  • Accept if best similarity >= recognition.threshold (default 0.80)
  • Frame variance check: multiple frames must show different embeddings (anti-photo)

Auth Flow

1. Pre-checks (disabled? SSH? lid closed? has models? rate limit? IR?)
2. Load user embeddings from store
3. Capture loop (until deadline):
   a. Capture frame
   b. Skip if dark
   c. Detect faces
   d. For each face: compute best_match against stored embeddings
   e. Track matched frames for variance check
   f. If variance passes (or disabled): return match
4. If timeout: return no_match

Operating Modes

Daemon Mode

The daemon (facelock daemon) runs persistently, holding ONNX models and camera resources in memory. The PAM module and CLI connect via D-Bus system bus. Benefits:

  • No per-authentication model load, and no camera reopen when the stream is already warm. The reopen a cold attempt pays is hardware-specific — measure it with sudo facelock bench camera-reopen, which splits it into open, STREAMON and warmup
  • Camera stays warm after a failed attempt for device.camera_release_secs (default 3), so the retry a miss invites skips the reopen. Success, cancellation and errors release it at once — on IR hardware the emitter LED goes out with the interaction (ADR 008)
  • Single point of resource management

Oneshot Mode

The PAM module spawns facelock auth --user X for each auth attempt. The process loads models, opens camera, runs one auth cycle, and exits. Benefits:

  • No background process
  • Does not require systemd; it still requires the documented Linux-PAM, V4L2, model, and runtime prerequisites

Direct CLI Mode

The CLI detects whether the daemon is available over the system bus. If it is, the CLI uses IPC; if daemon mode is configured but unavailable, it warns and falls back to direct access (opening the camera and loading models inline). Configured oneshot mode uses direct access without the degraded-fallback warning. A non-default explicit --config also forces direct access, without probing a daemon that may use different state or security settings. Commands such as benchmarking, TPM maintenance and oneshot authentication are always direct; unprivileged capability and enrollment-marker probes use no backend.

Security Layers

  1. IR enforcement: Only IR cameras allowed by default (prevents RGB photo attacks)
  2. Frame variance: Multiple frames must show micro-movement (prevents static photo)
  3. Rate limiting: 5 face-detected authentication failures per user per 60 seconds by default; successful and no-face attempts do not consume this budget
  4. Model integrity: SHA256 verification at every load
  5. D-Bus security: System bus policy restricts daemon access
  6. Audit trail: All auth events logged to syslog
  7. Process hardening: systemd service runs with ProtectSystem=strict, NoNewPrivileges, etc.

CLI Reference

All commands are subcommands of the facelock binary.

Global flags

The following flags are accepted by every subcommand (declared global = true):

FlagDescription
-c, --config <PATH>Override the config file path. Takes precedence over FACELOCK_CONFIG. The packaged daemon reads only the default file, so under a non-default path enroll and test use direct camera access and setup --systemd refuses, except --disable, which stays allowed since stopping the packaged unit reads no config file; a symlink or .. spelling of the default counts as the default (see facelock setup).
-q, --quietSuppress informational stdout and machine payloads where supported. Prompts, required notices, diagnostics and exit codes are unaffected; exceptions are listed below.
-v, --verboseRaise diagnostic verbosity on stderr, one level per repeat. The CLI starts at warn, daemon run at info. RUST_LOG overrides it.

Diagnostics default to warn, so a command prints warnings and errors on stderr and nothing quieter. The setup wizard's questions and the status report are readable again, rather than interleaved with timestamped log lines. -v raises the level one step per repeat; facelock daemon run keeps info, because it writes to the journal, where nothing competes with it. RUST_LOG outranks both, and the level changes output volume only: exit codes and stdout payloads are identical at every level.

--quiet and -v are separate knobs on separate streams, so --quiet -v is a real combination (silent report, loud diagnostics) rather than a contradiction.

--quiet suppresses ordinary informational output for commands using the message seam: setup, enroll, test, remove, clear, is-enrolled, capabilities, pam, and the --json payloads of list, devices and status. Seven still write human text straight to stdout and stay noisy under it until #140 is finished: status, bench, tpm (every verb, encrypt/decrypt/reseal included), config, daemon restart, hyprlock and audit, as do the human tables of list and devices — so status --json --quiet is silent while a bare status --quiet is not. preview --json is on neither list: its frame stream is stdout by design and --quiet is documented not to reach it.

Required notices on the human setup/PAM paths also remain on stdout, including rollback guidance and the edit context shown before a confirmation.

Privilege model

Enrollment, model management, camera inspection/preview, system status, the unified benchmarks, TPM operations, and audit access use protected system state and require root. Many interactive management commands offer to re-execute via sudo; examples without an explicit sudo rely on that terminal prompt. Scripts and redirected/non-interactive calls must provide the required privilege themselves. daemon run, PAM writes, data purge, and all audit access refuse non-root callers without an elevation prompt.

is-enrolled, capabilities, config show, and pam status are deliberately unprivileged reads. hyprlock is user-owned and refuses root. auth is the direct, one-shot PAM helper: --user is required and it needs access to the protected database and camera; it does not connect to the daemon or offer elevation. The daemon separately restricts non-root D-Bus authentication callers to their own accounts. Command-specific exceptions and hard-root behavior are stated below.

Machine-readable output

Every command whose output a script would parse takes --json, and spells it exactly that — one flag family, no short letter, no --output json. It is not offered everywhere: a command gains it when it has a named consumer, which today means facelock is-enrolled, facelock capabilities, facelock list, facelock devices, facelock preview, facelock status, facelock pam add, facelock pam remove, facelock pam status and facelock data purge. Each payload is described in that command's section below; the rule behind the flag, and the promise each payload carries, are in contracts.md under "CLI Machine Output".

The payload goes to stdout and nothing else does — diagnostics are on stderr whatever RUST_LOG says — so facelock devices --json is safe to pipe at any log level. --quiet suppresses the payload on every one of these except preview, whose frame stream runs until interrupted and would otherwise become a command that prints nothing forever. What that leaves behind depends on the command: where the exit code is the answer it is the whole answer, but status exits 0 whenever it produced a report, so status --quiet --json leaves nothing at all. This changed: list --json --quiet and devices --json --quiet used to print their payload and now print nothing; the exit code is unchanged.

facelock setup

Interactive setup wizard. Walks through camera selection, model quality, inference device, model downloads, encryption, the daemon, enrollment and PAM configuration. Every step can also be answered, or declined, from the command line.

The daemon is configured before enrollment on purpose. enroll and test select their transport once, when they start, so on a first install a daemon configured after them would never be the one they used: enrollment would fall back to direct camera access and the recognition test would validate a transport no later authentication takes. The step starts the daemon, or restarts it if one is already running, because the daemon reads the encryption method, the model preset and the inference device once at startup: on a re-run of setup an untouched daemon would hold the answers from before the wizard. A restart interrupts any authentication that daemon is mid-way through, so a sudo prompt waiting on a face in another terminal falls back to a password that once.

--systemd is not supported under a non-default --config. The unit runs bare facelock daemon, which reads only /etc/facelock/config.toml, so the daemon it enables would not use the file setup just configured. facelock --config /etc/facelock/scratch.toml setup --systemd --enroll exits non-zero before it writes anything or calls systemctl, and says what to do instead: copy the file to /etc/facelock/config.toml and re-run without --config, or re-run without --systemd to enroll with direct camera access under /etc/facelock/scratch.toml. The wizard skips the daemon question under such a --config and says why. --systemd --disable still runs, with a note that the unit it stops reads the default file; a symlink or .. spelling of the default path counts as the default.

facelock setup                          # interactive wizard
facelock setup --non-interactive        # base setup, no prompts, no PAM/systemd/enroll
facelock setup --systemd                # validate installed assets, reload and enable
facelock setup --systemd --disable      # disable systemd units
facelock setup --pam                    # install to /etc/pam.d/sudo
facelock setup --pam --service polkit-1 # install to a specific service
facelock setup --pam --remove           # remove the PAM line
facelock setup --pam --service hyprlock --if-present  # a missing service file is success
facelock setup --pam --remove --if-present  # ...on removal too
facelock setup --pam --service sshd -y --allow-sensitive  # suppress the prompt and authorize the sensitive write
facelock setup --no-pam                 # wizard, but never touch /etc/pam.d
facelock setup --camera /dev/video2     # answer step 1 from the command line

Three rules generate the whole flag list. Supplying a value answers that question and therefore replaces its prompt, which is why there is no --skip-<x>-prompt family. A --no-<action> flag declines an action outright; declining is not defaulting. And auto means re-derive from the hardware, since omitting a flag already gives the default.

Modes

FlagMeaning
(none)Full interactive wizard. Falls back to the non-interactive flow when stdin is not a terminal.
--non-interactiveNo prompts. Choices resolve to config-or-default. Runs the base setup only: directories, model download and verification, encryption, path permissions. No PAM, no systemd, no enrollment unless asked for explicitly.
-y, --yes (alias --no-confirm)Suppress ordinary confirmation prompts. Does not authorize a sensitive PAM edit.

--yes and --non-interactive suppress the per-file "Proceed?" confirmation; neither unlocks the sensitive-service gate. The shared auth stacks common-auth, password-auth, password-auth-ac, system-auth, system-auth-ac and system-login, plus login and sshd, require --allow-sensitive. Thus even facelock setup --pam --service sshd --yes refuses. Locking yourself out of a machine takes two independent decisions: whether to skip the prompt, and whether to authorize the sensitive write.

Choice flags

Precedence for all four: CLI flag > config file > built-in default. Supplying the flag suppresses the corresponding wizard step and writes the value back to the selected config file (normally /etc/facelock/config.toml). An unavailable explicit camera or TPM choice is fatal: --camera /dev/video9 on a machine without that node aborts, and --encryption tpm with no usable TPM aborts. Provider selection has its own fallback behavior, described below.

FlagValuesWhat auto doesWizard step
--camera <PATH|auto>a /dev/video* path, or autoRe-classifies the attached devices and picks the single IR-capable node that advertises a format Facelock can decode. Zero usable IR devices and more than one are both errors that list what was found; an IR node excluded for formats such as Y8/Y10/Y12 is reported with its path and formats.1
--models <standard|balanced|high>three presetsno auto: quality is a preference, not something the machine can report2
--execution-provider <cpu|cuda|rocm|openvino|auto>provider nameAsks the installed ONNX Runtime which providers it was built with and takes the best, in the order cuda > rocm > openvino > cpu. Availability is a property of the runtime build, not of the hardware, and the choice is always printed.3
--encryption <tpm|keyfile|none|auto>methodUses the TPM when a working TPM 2.0 is present, otherwise a software keyfile.5

When security.require_ir = true and the wizard detects IR-classified nodes but all of them advertise only unsupported formats, camera selection is a fatal refusal. It lists every excluded IR path and format and does not present or default to an attached RGB camera. With require_ir = false, decodable RGB cameras remain available as explicit wizard choices.

Model presets:

PresetDetectorEmbedder
standardscrfd_2.5g_bnkps.onnxw600k_r50.onnx
balancedscrfd_2.5g_bnkps.onnxglintr100.onnx
highdet_10g.onnxglintr100.onnx

Action flags

PairWithout either flag (wizard)Without either flag (--non-interactive)
--pam / --no-pamprompt (step 9)off
--systemd / --no-systemdprompt (step 6)off
--enroll / --no-enrollprompt (step 7)off; enrollment needs a human in front of the camera

Each pair is a clap override pair, so a later flag wins over an earlier one: --pam --no-pam declines PAM, --no-pam --pam installs it. That matters when a wrapper appends an override to a command line it did not construct. --no-pam means nothing under /etc/pam.d is read, backed up or written; it is not "use the PAM default".

--pam inside the wizard configures exactly one service, --service defaulting to sudo, and does not apply the multi-select's pre-checked candidates. --enroll answers the "enroll a face now?" confirmation as well as forcing the step, so it runs unattended.

Action modifiers

FlagRequiresMeaning
--service <NAME>--pamTarget PAM service. Default sudo.
--remove--pamRemove the facelock PAM line instead of adding it.
--if-present--pamTreat an absent service file as success rather than an error, on the add side as well as --remove. Read, parse and write failures stay fatal. Without it, a service that is not there is a hard error.
--allow-sensitive--pam addExplicitly authorize adding Facelock to common-auth, login, password-auth, password-auth-ac, sshd, system-auth, system-auth-ac, or system-login. Does not suppress the confirmation prompt and conflicts with --remove.
--disable--systemdDisable and stop the units without changing installed assets.

The parser enforces these, so facelock setup --remove is an error naming the missing --pam rather than a silently ignored flag.

How setup flags compose

--pam and/or --systemd on their own perform just that action and touch nothing else. 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, and the requested actions run in addition. -y on its own does not force it, so facelock setup -y --pam is still PAM-only. When both run the order is base setup, then systemd, then PAM.

--pam is an alias onto facelock pam add | remove, which is the primary spelling and the one that takes several services in one process. Existing setup --pam invocations keep parsing. Sensitive additions now use the same explicit --allow-sensitive authorization as facelock pam add, while -y only suppresses the prompt.

Eight services are gated: the shared auth stacks common-auth, password-auth, password-auth-ac, system-auth, system-auth-ac and system-login, plus login and sshd. facelock setup --pam --service login refuses until --allow-sensitive is added, even when -y is present.

--execution-provider=auto

auto inspects the execution providers compiled into the installed ONNX Runtime and selects the first available provider in this order: CUDA, ROCm, OpenVINO, CPU. It does not probe the GPU or install a provider. An ONNX Runtime built with CPU support only therefore resolves auto to cpu, even on a machine with a supported GPU. Setup prints the resolved provider before it writes the configuration. If the runtime cannot be queried, auto warns and selects cpu.

The interactive inference-device prompt runs this same probe and highlights the provider it finds — an explicit GPU provider already in the config wins over detection — annotating CUDA, ROCm and OpenVINO as available or not in the installed build. Nothing is written until a selection is confirmed.

An explicit provider name is written without proving it is usable. CUDA gets driver/runtime presence warnings, but setup does not install those dependencies; inference may still fail or fall back to CPU later.

Both base setup flows reconcile the per-user enrollment markers behind facelock is-enrolled against the database, which is what backfills users who enrolled before markers existed. Standalone PAM or systemd actions do not perform this reconciliation.

facelock is-enrolled

Report whether a user has a usable face enrollment. Unprivileged and cheap enough to call repeatedly from a lock screen: it reads the selected config to derive the enrolled/ directory beside storage.db_path, then reads one marker. An unreadable or invalid config falls back to /var/lib/facelock/enrolled/. It never activates the daemon, opens a camera, or reads the face database.

No group is involved (ADR 010): the marker sits under two 0711 root:root directories, so any local user can open its own marker by name. A missing or unreadable marker (ENOENT or EACCES) is reported not-enrolled rather than as an error.

facelock is-enrolled                    # prints enrolled / not-enrolled
facelock is-enrolled -u alice           # specific user (-u is short for --user)
facelock is-enrolled --json             # machine-readable
facelock is-enrolled --quiet            # no stdout; the exit code is the answer

The exit code is the contract — branch on it rather than parsing stdout:

CodeMeaning
0the user has a usable enrollment
1not enrolled; an absent or unreadable marker reports this way
2error — an invalid --user, an unparseable marker, or an I/O failure other than absence or access denial

--json emits one object and does not change the exit code:

{"enrolled":true,"models":2,"updated":"2026-08-12T00:00:00Z"}

models is 0 and updated is null when the user is not enrolled. The error case prints its reason on stderr and no payload at all.

The marker is a hint for deciding whether to offer a face-auth affordance; PAM at authentication time remains authoritative and nothing in the auth path consults it. See contracts.md, "facelock is-enrolled Exit Codes", for the stability promise and for how markers are reconciled with the database.

facelock capabilities

Report what this build can do, as capability names. Unprivileged: it answers from the binary's own clap tree and compiled-in constants, reading no config file, activating no daemon and opening no camera. It is what replaces grepping --help in a wrapper script.

facelock capabilities                   # one name per line
facelock capabilities --json            # {"version", "capabilities"}

With the name array elided:

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

Both forms exit 0 — the command has no failure mode — and --quiet suppresses stdout, leaving the exit code as the whole answer. A build that predates the command answers by failing: clap's unrecognized-subcommand error 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.

Probe by name, never by version. The names this build emits, what each one promises, and the stability rules that govern them are in contracts.md, "facelock capabilities".

facelock enroll

Capture and store a face model.

facelock enroll                         # current user, auto-label
facelock enroll -u alice                # specific user (-u/--user)
facelock enroll -l "office"             # specific label (-l/--label)
facelock enroll --skip-setup-check      # enroll on a tree setup never marked complete

Accepts 3–10 quality-filtered captures with exactly one face per accepted frame and checks angle diversity. The capture deadline is 3 × max(recognition.timeout_secs, 5) seconds (15 seconds by default); it can finish sooner after ten accepted captures. Re-enrolling with the same label replaces the previous model on success; a cancelled or failed re-enrollment leaves the previous model in place.

Under a non-default --config, enrollment uses direct camera access under that file and never the running daemon, which reads only /etc/facelock/config.toml; when daemon.mode = "daemon" a note on stderr says so. The same holds for test, list, remove, clear, devices and preview (which then has only its text preview).

Without --skip-setup-check, an install whose setup-complete marker is missing is offered facelock setup first. Accepting runs setup and then returns without a separate enrollment; setup can itself enroll, but that step can be declined. The original enroll --user and --label choices are not forwarded into the wizard. Declining setup also returns successfully without enrolling. --skip-setup-check bypasses that offer. It is for a tree assembled by hand or by a configuration manager, where the marker was never written but the models, database and encryption key are all in place; enrollment still fails on its own terms if any of them is not.

facelock test

Test face recognition against enrolled models.

facelock test                           # current user
facelock test -u alice                  # specific user (-u/--user)

Reports match similarity and latency when a scan runs. A zero exit status means the command completed, not necessarily that a face matched or even that the camera was opened: no enrollment, no enrollment for the configured embedder, and a completed non-match all return zero with explanatory output. Inspect the human result; this command has no machine-readable success contract.

facelock list

List enrolled face models.

facelock list                           # current user
facelock list -u alice                  # specific user (-u/--user)
facelock list --json                    # JSON output

--json emits an array of objects:

[
  {
    "id": 1,
    "label": "office",
    "user": "alice",
    "created_at": 1700000000,
    "embedder_model": "w600k_r50.onnx",
    "device_id": ""
  }
]

facelock remove

Remove a specific face model by its decimal MODEL_ID, as shown by facelock list. The argument is an unsigned 32-bit integer; hexadecimal spellings are not accepted.

facelock remove 3                       # remove model #3
facelock remove 3 -u alice              # for specific user (-u/--user)
facelock remove 3 -y                    # skip confirmation (-y/--yes)

The selected user scopes the removal. Declining confirmation exits 0 without deleting. A nonexistent model also returns 0; the direct backend reports that it was not found, while the daemon's empty reply cannot distinguish that case.

facelock clear

Remove all face models for a user.

facelock clear                          # current user
facelock clear -u alice -y              # -u/--user; -y/--yes skips confirmation

Only that user's models are removed. No models or a declined confirmation is a successful no-op. This command does not remove keys, model files, audit logs, or other users' enrollments; machine-wide retained state belongs to data purge.

facelock preview

Live camera preview with face detection overlay.

facelock preview                        # Wayland graphical window
facelock preview --json                 # one JSON object per frame on stdout
facelock preview -u alice               # match against user (-u/--user)

The window opens on the invoking user's Wayland session even though the command runs as root: the compositor socket is resolved from the invoking uid's /run/user/<uid> directory (via SUDO_UID/DOAS_USER), never from inherited XDG_RUNTIME_DIR, and the connected peer must be a process running as that uid. A bare-name WAYLAND_DISPLAY picks among the sockets in that directory; a value carrying a path is ignored. The self-re-exec (facelock preview answering the sudo prompt) carries WAYLAND_DISPLAY across, so a session running several compositors previews on the right one; a direct sudo facelock preview has no display name and scans, so with more than one live compositor run sudo --preserve-env=WAYLAND_DISPLAY facelock preview to name it. Without a reachable compositor the preview falls back to text-only mode.

--json shipped as --text-only, which stays a hidden alias and keeps parsing; the payload is unchanged. One object per line, one per frame:

{"faces":[{"confidence":0.5,"height":180.0,"recognized":true,"similarity":0.75,"width":180.0,"x":112.0,"y":88.0}],"fps":15.0,"frame":1,"height":480,"jpeg_size":24576,"recognized":1,"unrecognized":0,"width":640}

Keys come out sorted, which is serde_json's doing and not a promise. jpeg_size is present only when the daemon serves the frames; the direct (oneshot) path has no JPEG and omits that key, and every other key is on both. Numbers are f32 rounded then widened to f64, so a rounded 0.988 reaches you as 0.9879999756813049: compare numerically, never as text.

facelock devices

List available V4L2 video capture devices.

facelock devices                        # human-readable listing
facelock devices --json                 # JSON output

Shows device path, name, driver, formats, resolutions, and IR status.

--json emits an array of device objects with path, name, driver, is_ir, and formats; each format carries fourcc, description, and sizes, a list of [width, height] pairs. It is a typed schema derived from the device struct, so a script reads it rather than parsing the listing above, whose columns, indentation and [IR] tag are free to change.

formats is empty whenever the daemon answers: the D-Bus device type does not carry format detail, so only the direct (oneshot) backend fills it in. The human listing omits the section for the same reason. Read formats for capability detection only when you know you are on the direct path.

facelock status

Check system status — config, daemon, oneshot fallback, camera, models, encryption, enrollment, security posture, notifications, PAM wiring. Requires root. A check that cannot be performed (unreadable database, broken config) is reported as "cannot determine" — never as a guessed value.

facelock status
facelock status --json
facelock status --json | jq -e '.daemon.reachability == "responding"'

--json prints one object with a key per section of the report — config, daemon, oneshot_fallback, camera, models, execution_provider, encryption, enrollment, security, notifications, pam — each carrying a state of ok, problem or unknown and, when it is not ok, a reason. It is the same value the report is rendered from, and a test walks both outputs of one fixture, so a section cannot answer differently in the two. This is the form to branch on: the third line above is what replaces grepping the report for [ok] responding. A fact nobody established is "state": "unknown" with a reason and no value — never a null and never a false, so read a section's state before any field beside it: on an unreadable database enrollment carries no models key at all, and (.enrollment.models // []) would answer "not enrolled" for a machine nobody could check.

Two sections keep a narrower question than their name suggests, and both have the specific answer nested one level down. Under auto-detection .camera.state reports only that detection is enabled, so it reads ok on a machine with no camera at all — .camera.device.state is the hardware fact. And .pam.state reports that pam_facelock.so is installed, not that anything uses it — .pam.services is the scan. The full schema, the per-section table of what each state answers, and the stability tier are in contracts.md under "facelock status Semantics".

Exit codes do not change under --json: status exits 0 whenever it produced a report, and the verdicts are in the document. --quiet therefore suppresses the payload and leaves nothing behind, which makes --quiet --json a no-op rather than a terser query.

The PAM services: line lists every service that carries the facelock line, from the same scan facelock pam status --all runs, and marks how many are a local override of a vendor file. It reads none configured only when every directory was read; when one could not be, it reads not checked and names the place on a line of its own, because "nothing is configured" and "I could not look" are different answers.

facelock config

Show or edit the configuration file. Bare facelock config is facelock config show.

facelock config show

Print the config file path and its contents, then report whether it parses. Unprivileged — it reads a 0644 file. A missing file or invalid configuration is reported on stdout with exit 0; an actual read failure returns an error.

facelock config                         # show config path and contents
facelock config show                    # the same, spelled out

facelock config edit

Open the config file in $EDITOR (then $VISUAL, then nano/vi/vim), validate it on save, and request a daemon restart when both the old and new configurations are valid and a setting in the command's restart list changed. Requires root. An invalid saved file is left in place with a warning and exit 0; it is not rolled back. $EDITOR and $VISUAL must name an executable, not a shell command with arguments.

sudo facelock config edit

facelock daemon

Run or restart the persistent authentication daemon. Bare facelock daemon is facelock daemon run, which is the form every shipped service unit invokes.

facelock daemon run

Run the daemon in the foreground. Requires root — it opens the camera and the face database. Normally managed by systemd, not run manually.

Run as non-root it hard-errors with the sudo hint and never offers to re-exec, even from a terminal: the service unit that normally invokes it has nobody to answer a prompt. daemon restart still prompts.

sudo facelock daemon                         # use default config
sudo facelock daemon run                     # the same, spelled out
sudo facelock daemon -c /path/to/config.toml # short alias for --config
sudo facelock daemon --config /path/to/config.toml

facelock daemon restart

Request a restart of the persistent daemon with systemctl restart facelock-daemon.service. If that command fails or cannot be launched, try a D-Bus shutdown request so the service manager or later D-Bus activation can start it again. The fallback result is not checked: exit 0 does not prove the daemon restarted. Use facelock status to check reachability. --config does not change which service is restarted or the configuration that service reads.

Requires root. If run interactively as a non-root user, the CLI prompts to re-run via sudo.

sudo facelock daemon restart

facelock auth

One-shot authentication. Used by the PAM module in oneshot mode.

sudo facelock auth -u alice              # authenticate (-u/--user)
sudo facelock auth --user alice --config /etc/facelock/config.toml

Exit codes: 0 = matched, 1 = scanned and not matched, 2 = error / no opinion, 3 = rate limited, 4 = suppressed (no enrolled models with security.suppress_unknown), 5 = all frames dark. The full table and its compatibility invariants are frozen in docs/contracts.md ("facelock auth Exit Codes").

facelock tpm

Everything that manages the embedding encryption key: the TPM device that can seal it, and the key material itself. encrypt, decrypt and reseal live here because the group owns the key's lifecycle — encrypt and decrypt run software AES-256-GCM with no TPM involved.

facelock tpm status

Report the configured TPM path's presence, sealed-key presence, encryption method and encrypted/plaintext row counts. Requires a readable database; it does not test whether the TPM can unseal the key. Use unseal-check for that.

sudo facelock tpm status

facelock tpm seal-key

Seal the existing AES encryption key with the TPM and set encryption.method to tpm. Requires a plaintext keyfile and refuses an existing sealed blob. The plaintext keyfile is retained as a recovery backup; embeddings are not re-encrypted.

sudo facelock tpm seal-key

facelock tpm unseal-key

Unseal the AES key from the TPM into a plaintext keyfile and set encryption.method to keyfile. Requires a sealed blob and refuses an existing plaintext keyfile, including the backup retained by seal-key. It leaves the sealed blob in place and does not re-encrypt embeddings.

sudo facelock tpm unseal-key

facelock tpm unseal-check

Read-only check that the sealed AES key still unseals under the current PCR values. Writes nothing and exits non-zero on failure. Diagnose the reported cause first: an unavailable TPM, wrong encryption method, or missing/corrupt blob is not evidence that resealing will help. A PCR-policy mismatch may call for facelock tpm reseal when the key is recoverable from the current TPM policy or a protected plaintext backup.

sudo facelock tpm unseal-check

facelock tpm pcr-baseline

Display the current PCR values for all configured PCR indices. Device operations (seal-key, unseal-key, unseal-check, pcr-baseline, reseal) require a build with the optional tpm feature and a usable configured TPM connection.

sudo facelock tpm pcr-baseline

facelock tpm encrypt

Encrypt all unencrypted embeddings in the database with AES-256-GCM. The cipher is software either way; encryption.method decides only where the key lives.

sudo facelock tpm encrypt                 # encrypt using the configured key
sudo facelock tpm encrypt --generate-key  # generate a new key file (or seal a new TPM key) WITHOUT re-encrypting embeddings

--generate-key creates or replaces key material, without changing the configured method. It refuses when encrypted templates exist or the database cannot be checked. With method none, it writes a keyfile; configure keyfile before running facelock tpm encrypt without the flag. In-place encryption refuses while security.bind_device_aad = true activates hard device binding; use enrollment to create bound templates.

facelock tpm decrypt

Decrypt AES-256-GCM embeddings and legacy per-embedding TPM blobs into plaintext rows. Legacy TPM blobs require a build with TPM support. The command does not change the configured encryption method or remove key files; updates happen row by row, so a later failure can leave a partly converted database. It cannot decrypt templates sealed with device-bound additional authenticated data.

sudo facelock tpm decrypt

facelock tpm reseal

Re-seal the TPM AES key under the current PCR values. This is the recovery step after a firmware or kernel change moves a measured PCR and the sealed key stops unsealing. Requires root, and applies only when encryption.method = "tpm" — under any other method it errors rather than quietly doing nothing.

sudo facelock tpm reseal

It requires an existing sealed blob and prefers unsealing it. Once the PCRs have moved it falls back to the plaintext key backup. It seals against the current PCRs, so running it before an update does not authorize the future PCR values. Without a recoverable key it fails. facelock tpm unseal-check checks the current TPM path; it does not validate the availability of a plaintext recovery backup.

facelock bench

Benchmark and calibration tools.

Every bench subcommand requires root (DEC-6): direct-mode access needs the 0600 root:root database on the enrollment-dependent measurements, and the auth benchmarks may need TPM access besides. cold-auth, warm-auth, and calibrate require enrolled faces; report requires the database but can report timing with no enrolled faces.

These are direct capture/inference measurements, not full PAM or daemon authentication: the benchmarks do not run the authentication policy, rate limiter or liveness checks. They do not change configuration or save captures. The user is selected from SUDO_USER, then USER, then LOGNAME, then unknown; the benchmark group has no --user option and does not use DOAS_USER or a UID lookup. Missing a timing target or face match does not itself make a benchmark exit non-zero.

camera-reopen needs no enrolled face and loads no models — but is root like the rest: it closes and reopens the camera --iterations times (default 5) and reports the per-phase median. That total is what device.camera_release_secs trades LED-on time against — holding the stream warm after a failed attempt buys a retry exactly this much (ADR 008).

facelock bench cold-auth

Measure cold-start authentication latency, including model load and the first authentication attempt: sudo facelock bench cold-auth.

facelock bench warm-auth

Measure ten authentication attempts with models already loaded: sudo facelock bench warm-auth.

facelock bench preview

Measure frame capture and face-processing latency (detection and embedding): sudo facelock bench preview.

facelock bench enrollment

Measure snapshot capture and embedding without storing the result: sudo facelock bench enrollment.

facelock bench model-load

Measure SCRFD and ArcFace model loading: sudo facelock bench model-load.

facelock bench calibrate

Compare faces from ten live captures with the selected user's enrolled templates, sweeping thresholds from 0.20 through 0.80 in steps of 0.05. Recommend the threshold whose pairwise match rate is closest to 90%, then sweep detector confidence from 0.30 through 0.90 on another frame: sudo facelock bench calibrate. This does not measure false-accept rates or write the recommendation to configuration.

facelock bench camera-reopen

Measure open, STREAMON and warm-up phases. Use --iterations <N> to replace the default five repetitions:

sudo facelock bench camera-reopen
sudo facelock bench camera-reopen --iterations 10

facelock bench report

Report environment details plus model-load, preview and enrollment-snapshot timings: sudo facelock bench report. With enrolled faces it also measures warm capture/match and an approximate cold-auth time (model reload plus one capture on the already-open camera); otherwise those rows say N/A/SKIP. It does not run the standalone cold-auth loop, calibrate or camera-reopen. The printed model-pack and build labels are fixed text, not detected facts; use the actual configuration and build metadata when interpreting them.

facelock pam

Manage the facelock line in /etc/pam.d service files. This command owns every write to /etc/pam.d; setup --pam is an alias onto it, and the setup wizard calls the same writer.

--service is repeatable on all three verbs and defaults to sudo, so several services are configured in one process, under one root check. add and remove require root and never offer to re-exec under sudo; status reads only and needs no root.

A service name is looked up in /etc/pam.d first and /usr/lib/pam.d second — Linux-PAM's own order, first hit wins — because packages ship their configuration there: on current Arch polkit installs /usr/lib/pam.d/polkit-1 and there is no /etc/pam.d/polkit-1 at all. Only /etc/pam.d is ever written to. A service that exists only in a vendor directory is copied there first, with the facelock line already in it and a two-line header saying what it was forked from; the package's own file is left byte for byte. That copy reports overridden rather than installed, and pam status reports a service with no local copy as vendor-only rather than as missing. Deleting the override restores the vendor file. Named pam remove does that automatically only while the two-line Facelock header, the bytes below it after removing the module rule, and the file owner/mode still match the first existing vendor service in the configured search order. If either copy has drifted, it removes the module rule but keeps the local override and says why. If no current vendor source exists, an exact header naming a normalized configured candidate is reported as absent and the local override is retained; an arbitrary header path is not trusted or opened. Set [pam] config_dirs if your distribution's vendor directory is somewhere else for explicit add, named remove, and test resolution. Machine-wide pam remove --all deliberately ignores that setting, scans the compiled system roots /etc/pam.d and /usr/lib/pam.d, and separately scans the fixed detection-only generated root /etc/authselect.

Fedora RPMs support the same service-scoped leaf-file setup. They do not ship or select an authselect profile, and Facelock never writes the generated system-auth or password-auth files. Choose an application-owned leaf such as sudo, polkit-1, or another explicit service; generated authselect symlinks are refused by the writer's no-follow checks.

facelock pam add

sudo facelock pam add                                        # /etc/pam.d/sudo
sudo facelock pam add --service polkit-1 --service hyprlock  # several at once
sudo facelock pam add --service sshd --allow-sensitive       # unlock a gated service
sudo facelock pam add --service hyprlock --if-present        # a missing file is success
sudo facelock pam add --service sudo --dry-run               # print the plan, write nothing
sudo facelock pam add --service sudo --json                  # machine-readable result
FlagMeaning
--service <NAME>service to act on; repeat for several (default: sudo)
-y, --yes (alias --no-confirm)skip the per-file confirmation, and nothing else
--allow-sensitivealso permit the gated services common-auth, login, password-auth, password-auth-ac, sshd, system-auth, system-auth-ac, system-login
--if-presenttreat a missing service file as success instead of an error
--dry-runprint the resolved plan, write nothing, exit 0
--jsonemit one JSON document instead of human text (implies --no-confirm)

--yes never implies --allow-sensitive: they are separate authorizations, "do not ask me" and "yes, edit system-auth". Every service is validated before any file is written, so a rejected service name leaves the rest untouched. The confirmation is skipped as if --yes were given whenever it could not be answered — no TTY on stdin, no TTY on stderr (where the prompt is drawn, so 2>install.log counts), or --json — and the gate is decided before any prompt exists, so an unattended pam add --service system-auth still refuses.

On Debian and Ubuntu, a selected packaged pam-auth-update profile is already one Facelock auth path. Before planning any direct add (including setup --pam), Facelock verifies the exact fixed-root profile, saved selection and live Primary block without following links. It refuses a duplicate with: sudo pam-auth-update --disable facelock, verify a real correct password succeeds and a wrong password fails, then retry the original Facelock command with all of its services and flags. Any disagreement between the saved selection and live graph, or untrusted state, also refuses without writing PAM or backup state.

facelock pam shared-profile-status is an internal, read-only Debian package maintainer probe. It exits 0 only for that exact active profile, 1 when the profile is cleanly unselected, and 2 for untrusted or inconsistent state; it never changes PAM or backup state.

Any symlinked service file is refused rather than written through: on an authselect system system-auth and password-auth link into generated state, and even an in-directory link would make a recorded service name resolve to a different file. A file with more than one hard link is refused too: a link count says another name exists and not where, so the edit cannot be shown to stay in the directory.

Before an in-place edit, add writes a 0600 root:root backup under /var/lib/facelock/pam-backups/<service>.<timestamp> and an adjacent versioned JSON provenance record. The record stores a confined service name, backup basename, positive monotonic sequence, hashes, and prepared/committed state; it never stores a target path. Only the exact <service>.<seconds>-<nine-digit-nanoseconds> basename grammar is recognized. This also moves the human and JSON backup value from the former adjacent /etc/pam.d/<service>.facelock-backup location to the dedicated state path. Legacy adjacent files remain visible as rollback hints and are removed by a default pam remove, but they are not rewritten into versioned provenance.

--dry-run is honoured after the root check, so it still needs root. pam status is the unprivileged read to reach for instead.

facelock pam remove

sudo facelock pam remove                                     # /etc/pam.d/sudo
sudo facelock pam remove --service login                     # removal is never gated
sudo facelock pam remove --all                               # every recognized owned edit
sudo facelock pam remove --service sudo -y                   # accepted compatibility flag
sudo facelock pam remove --service hyprlock --if-present     # a missing file is success
sudo facelock pam remove --service sudo --keep-backup        # retain rollback state
sudo facelock pam remove --service sudo --dry-run --json

Takes the same flags as add except --allow-sensitive, which it does not offer: removal can only take away a way to authenticate, so there is nothing to gate. It never prompts; -y/--yes (alias --no-confirm) is accepted for symmetry and compatibility but does not change removal behavior. Named removal uses the configured lookup path. By default it removes committed Facelock-owned provenance and backups for the requested service, including the legacy adjacent <service>.facelock-backup name. Unresolved prepared state is preserved for recovery. --keep-backup opts out of cleanup. A cleanup error remains non-zero, but the JSON action is cleanup-failed and the human diagnostic says that the PAM state change already completed. For a Facelock-created local vendor copy, named removal first uses the normal crash-safe complete-file replacement to remove the module rule, then deletes the override only after moving its exact published inode to a no-replace transaction quarantine and rechecking that inode, canonical-name absence, and the current vendor bytes and metadata. The first existing later-root service wins; Facelock does not accept a matching lower-priority copy. The pre-removal document must contain exactly the one Facelock-emitted rule; extra or customized rules are drift. A restart also recognizes the exact header-bearing copy after the line is already absent. Header, payload, owner/mode, or vendor drift keeps the override; Facelock never deletes a merely similar local file. If the current vendor source is absent, only a header path derived from a normalized configured later-root candidate is recognized, solely to report why the local override is retained; header paths are never opened.

remove --all is the package-safe, config-independent form. It opens the compiled /etc/pam.d, /usr/lib/pam.d, and detection-only /etc/authselect roots without following links, enumerates the opened directory descriptors, and uses directory-relative regular/single-link reads. A symlink is skipped only when its exact absolute target is the same service in a later compiled root that is scanned independently; every other linked entry is an unmanaged blocker. This lets Fedora's unrelated generated PAM links be checked at their fixed root without traversing the links. Directory contents are detection ground truth; provenance can authenticate an arbitrary service Facelock previously changed, but never supplies a target path. An exact pre-0.2 auth sufficient pam_facelock.so edit is recognized only under a conventional service basename. Dot-prefixed and package/administrator artifact names such as .pacsave, .rpmsave, pam-auth-update .pam-old, and ~ require strict provenance for that exact name or an exact current Facelock vendor-copy header; unowned artifacts are ignored and preserved. A customized control, options or spacing, corrupt provenance for a candidate, any other linked entry, or a reference in a read-only root is an unmanaged blocker. Nothing is changed when preflight finds one. The same scan recognizes an exact unchanged Facelock-created vendor override even if a previous run already removed its module rule, so package cleanup can finish that bounded intermediate. A drifted or source-absent override is not a blocker by itself: when its Facelock rule is provenance-owned or the exact canonical line, --all removes the rule and keeps the file, the in-place rewrite named removal already performs. A file edited after add is judged the same way: the recorded hash no longer matching only means the file changed, so a conventional service with the exact canonical line is still Facelock's to clean. A reference it cannot vouch for blocks the whole run, and that blocker names facelock pam remove --service <name> as the way out.

With --dry-run, an existing PAM backup directory is inspected read-only and must already have its trusted owner and mode. The preview does not repair or sync that directory, acquire its write lock, or run recovery; it refuses the preview if the directory is not already trusted.

Before the first PAM file changes, the command persists rollback state for the complete target set and one bounded, root-owned whole-set journal. Each replacement re-resolves and rechecks the planned identity. A later failure or the final compiled-root rescan finding any active reference exchanges every earlier original inode back in reverse order. Only after the rescan is clear is a self-contained commit marker published and cleanup finalized. Version 2 journal and commit targets carry a required delete_override boolean; version 1 state remains recoverable and must omit it. Once committed, a flagged target is deleted only while the exact installed inode still matches and the journaled header payload, owner/mode, and first existing fixed-root vendor service still agree. The journal backup's full prepared identity and the line-removed installed hash are checked before parsing that shape. An already absent flagged target is an idempotent completed unlink. Recovery rolls back a prepared journal and completes a durable commit marker. It recognizes an exact intent-only, pre-publication service as unstarted only while the canonical full identity still matches and both temp and binding are absent. After a reverse exchange, rollback removes the identity-checked replacement temp, then its publication binding, then delegates the remaining base intent to that exact intent-only recovery. Each boundary is restartable; ordinary forward publication keeps its existing cleanup order. Cleanup recovery resumes exact pair quarantine/unlink state; a fully absent pair is already clean, but partial or conflicting state blocks. --keep-backup preserves versioned and legacy rollback state for every target; the default cleans only validated Facelock-owned state.

The command reads no Facelock config, database, model, camera, daemon, or ONNX Runtime state. Package uninstallers invoke it while the CLI and PAM module are still installed. Debian and RPM removal abort if cleanup cannot prove a clear final scan. Booted coverage runs direct dpkg/rpm and the apt-get, apt, and dnf frontends through abort retention and blocker-free success. Arch packages also ship a Remove-only libalpm PreTransaction hook with AbortOnFail, so pacman stops before removing either file. This all-or-nothing promise covers direct PAM edits owned by this command. The packaged Debian pam-auth-update profile is opt-in (Default: no), so fresh installation leaves common-auth unchanged. Package removal never silently disables a selected profile: it probes first and aborts with the package, module, PAM graph, direct edits and daemon state retained. Run sudo pam-auth-update --disable facelock, prove a real correct password succeeds and a wrong password fails, then retry removal. No older package persisted evidence distinguishing auto-enable from a later administrator choice, so every existing selection is preserved; automatic legacy migration is intentionally deferred. With the profile unselected, removal performs a read-only direct-cleanup preflight and the journaled cleanup before generated service lifecycle handling. Ordinary removal stops the daemon but preserves its enabled state for reinstall; only purge retires that state. The inert profile metadata leaves with the package without a generated-graph transition. Fedora #226 retired the packaged authselect profile and added a read-only upgrade guard. This command only detects references in generated /etc/authselect state and never changes that state.

facelock pam status

facelock pam status                                          # /etc/pam.d/sudo
facelock pam status --service sudo --service polkit-1
facelock pam status --service sudo --json
facelock pam status --all                                    # everything configured
facelock pam status --all --json

Unprivileged, and the probe to branch on instead of grepping /etc/pam.d yourself: it answers from the same file, without root, and reports "absent" and "unreadable" as themselves rather than as "not configured". It offers --service, --all, --if-present and --json, and neither --dry-run nor --allow-sensitive — there is no write to preview or gate. The exit code is the answer, on the same 0/1/2 scale as is-enrolled and grep:

CodeMeaning
0every requested service carries the line
1at least one exists without it
2at least one is absent, unreadable, misnamed, symlinked out of the directory, or hard-linked

Across several services the worst outcome wins. --if-present means here what it means on add and remove: an absent service file is reported and no longer forces exit 2, so exit 0 becomes "every requested service that exists carries the line" and optional integrations can be installed and then verified with the same flag on both commands. It forgives absence only — a service whose file is a dangling or looping symlink is still exit 2, because an unresolvable link is not an absent file.

sudo facelock pam add --service hyprlock --service swaylock --if-present
facelock pam status --service hyprlock --service swaylock --if-present

A service whose file is a local copy hiding a package's own of the same name reads

facelock PAM line present (local override of <vendor path>) rather than

facelock PAM line present, and its JSON row carries a shadows key naming that file. It is configured either way; the note says the copy will not follow the package's updates. This is a property of the row, so it appears with --service as it does with --all, and on pam add and pam remove rows too.

--json emits one document:

{"command":"status","dry_run":false,"module_path":"/lib/security/pam_facelock.so","services":[{"action":"present","backup":null,"path":"/etc/pam.d/sudo","service":"sudo"}]}

module_path is where pam_facelock.so was found, or null when no candidate hit — a property of the machine rather than of a service, and what tells an integrator that a service carries the line while the module it names is at a path nothing looks at. add refuses before writing when the module is missing; remove can clean up a stale reference even then. Neither write verb includes this key in its document.

The document's shape, the action vocabulary, and the rule that a consumer must tolerate an action it does not recognize rather than treat it as an error, are a stability contract — see contracts.md, "facelock pam Semantics", along with the exit codes for add and remove and what --json does on a validation failure.

facelock pam status --all

--all answers the other question: not "is this name configured?" but "what is configured on this machine?". It replaces --service (the two conflict) with every service in the resolved directories whose file names pam_facelock.so, so a polkit-1 or an omarchy-lock-face nobody thought to ask about is reported. It scans rather than reading a list of what facelock has edited, because such a list drifts the moment /etc/pam.d is edited by hand.

facelock pam status --all
facelock pam status --all --json

Nothing configured exits 1: a machine with no facelock line anywhere is not configured, and --if-present does not convert that — a name reaches the report by having been found, so there is nothing to forgive. The one exception is a file deleted between the listing and the read, which reports absent like any other.

A directory that could not be listed exits 2 and is named, rather than being reported as holding nothing. The "nothing is configured" sentence is scoped to the directories that were read and names the rest as unread in the same breath, so taking the human answer with 2>/dev/null cannot turn "I could not look" into "nothing is there". A directory that does not exist is neither case: it demonstrably holds no service files, and the default search path names a vendor directory many machines do not have. --all --json adds a directories key listing every directory searched with a status of scanned, absent or unreadable.

Only regular files are read. A FIFO, socket, device node or symlink to a directory in a pam.d directory is skipped rather than opened, since reading a FIFO blocks until a writer appears and a diagnostic that hangs on a malformed /etc/pam.d is worse than one that omits an entry no PAM stack could use. An entry that merely could not be examined — a symlink into a directory you may not traverse, a symlink loop, a dead mount — is not skipped: it is reported unknown, exit 2, which is what --service says about it too. The exception is a path that is simply not there, which is an absence rather than an unanswerable question: a dangling symlink is skipped by --all and reported unknown by --service. An entry whose name is not valid UTF-8 is skipped and logged.

facelock hyprlock

Manage hyprlock lock-screen integration: the face glyph in placeholder_text, and the ignore_empty_input = false setting that lets a bare Enter submit to PAM. Runs as your normal user and refuses to run as root, since it edits $XDG_CONFIG_HOME/hypr/hyprlock.conf (falling back to ~/.config/hypr/hyprlock.conf). The config must already exist. enable creates an adjacent .facelock-backup if none exists; disable leaves it in place.

--no-icon is for a hyprlock font with no Nerd Font glyphs; it flips the functional setting and leaves any existing icon alone. disable sets ignore_empty_input = true only when neither pam_fprintd.so in /etc/pam.d/hyprlock nor fingerprint:enabled = true in the config is detected. The fingerprint glyph alone does not preserve the setting. It does not restore the backup or remember the setting's original value.

Wiring /etc/pam.d/hyprlock itself is a separate, root step — see facelock pam. disable and status read /etc/pam.d/hyprlock for integration hints; writes are limited to the selected hyprlock configuration and its backup.

facelock hyprlock enable

Enable empty-Enter submission and add the face glyph. --no-icon changes only the functional setting and leaves the placeholder text alone.

facelock hyprlock enable
facelock hyprlock enable --no-icon

facelock hyprlock disable

Remove the face glyph and apply the fingerprint-aware setting change above: facelock hyprlock disable.

facelock hyprlock status

Report the current integration state without changing it: facelock hyprlock status.

facelock audit

View the structured audit log of authentication events.

sudo facelock audit                     # show last 20 entries (default)
sudo facelock audit -l 50               # show last 50 entries
sudo facelock audit --lines 50          # long form
sudo facelock audit -f                  # follow mode: stream appended entries
sudo facelock audit --follow            # long form
FlagShortDefaultDescription
--follow-ffalseWatch for new entries (like tail -f)
--lines N-l20Number of recent entries to display

Requires root and never offers elevation. Disabled logging or a missing log prints an explanation and exits 0, including in follow mode. Follow mode polls file growth every 500 ms; it does not reset its offset after truncation or rotation, so restart it after the log is replaced.

facelock data

Manage retained Facelock state on this machine. One verb today, purge.

data is a noun group with a single subcommand on purpose. A top-level name was rejected:

facelock purge

That spelling would sit 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 you read.

facelock data purge

Destroy retained Facelock state: enrolled embeddings, the database and its sidecars, encryption keys and sealed blobs, models, enrollment markers, audit logs, snapshots and upgrade backups.

This is the sanctioned way to destroy biometric data before uninstalling, and the only one. Package removal deliberately leaves retained state behind, so there is no removal path that also destroys data — see contracts.md under "Fixed-root purge boundary".

sudo facelock data purge --dry-run                  # classify configured paths; removes nothing
sudo facelock data purge --allow-destruction        # destroy, after a confirmation prompt
sudo facelock data purge --allow-destruction --yes  # destroy without the prompt
sudo facelock data purge --allow-destruction --json # destroy and emit the machine document
sudo facelock data purge --allow-destruction --yes --leave-activation-barred
FlagDefaultDescription
--allow-destructionfalseAuthorize irreversible destruction. Required except with --dry-run; nothing else implies it
--yesfalseSkip the confirmation prompt (also --no-confirm, -y)
--dry-runfalseClassify configured paths and remove nothing. Does not examine the contents of the roots
--leave-activation-barredfalseKeep the daemon stopped and D-Bus activation barred afterwards
--jsonfalseEmit the machine document instead of the human report

Two gates, and neither implies the other. --yes suppresses the confirmation prompt. --allow-destruction authorizes the destruction itself. A wrapper that passes --yes to every command so scripts run unattended has not thereby authorized a purge, and --json — which suppresses the prompt for the same reason it does on facelock pam add, because a question on stderr while a parser waits on stdout is a hang — does not authorize one either. This is the same split facelock pam add draws between --yes and --allow-sensitive.

Root is required, and the check runs first: a non-root invocation refuses before the prompt, before the daemon is touched, and before anything is read. There is no sudo re-exec offer, because the command is typically invoked from an uninstall script where a stray prompt is a hang.

What is traversed. Only the three compiled roots: /etc/facelock, /var/lib/facelock, /var/log/facelock. The roots themselves are never removed. Configuration cannot add a root. A configured path that points outside them — daemon.model_dir, storage.db_path, encryption.key_path, encryption.sealed_key_path, audit.path, snapshots.dir — is reported as an external remnant and left untouched; deciding what to do with it is yours.

What is refused. Symbolic links, hard-linked files, non-regular objects, wrong ownership, group- or world-writable modes, anything on another mount, and anything below the 64-level depth or 10,000-entry caps. A refusal is reported, not worked around. /var/lib/facelock/pam-backups stays opaque unless it is already empty, because a remaining entry there is unresolved PAM cleanup evidence. /etc/pam.d is never traversed — use facelock pam remove for PAM lines.

The daemon during a purge. Stopping facelock-daemon.service is not enough on its own: the D-Bus activation file lets any PAM Authenticate call restart it mid-traversal. So purge takes an exclusive lifecycle lock, bars activation, stops the daemon, and restores exactly the prior state when it finishes — including on error, on panic, and on Ctrl-C. An interrupt stops the traversal at a deletion boundary before the daemon is allowed back.

--leave-activation-barred keeps the daemon stopped and masked after the purge, for a caller that uninstalls next. Face authentication stays off until the named barrier file is removed and systemctl daemon-reload runs, or until the machine reboots — the barrier lives on tmpfs.

Repeating a purge is safe. Every refusal leaves the object in place, so a second run after fixing an ownership or link problem picks up what the first one could not prove safe.

Removing a name is not erasure. Filesystem deletion does not securely erase SSDs, snapshots, or backups. The report says which names were removed and which remnants remain; it never describes purge as forensic destruction of biometric data.

Reading the report

The exit status is 0 whenever the purge ran and the daemon lifecycle was restored, whatever the purge could not remove — a safety refusal is a reported outcome, not a crash, and the same rule the Debian purge follows so a failed removal cannot strand a half-purged install. A nonzero exit after a rendered report means the purge ran and the lifecycle needs attention, not that nothing happened. The report, not the exit code, is the answer. A run that retained anything says so in the negative and never claims completeness:

Removed 14 name(s) from the compiled Facelock roots.
2 object(s) inside the roots were retained:
  /var/lib/facelock/facelock.db — regular file has 2 links
  /etc/facelock/config.toml — symbolic link
1 configured path(s) lie outside the compiled roots and were left untouched by design:
  storage.db_path = /srv/faces/faces.db
Facelock data was NOT completely destroyed. The remnants above are still
present; re-run after resolving them, or remove the external paths yourself.
Removing a name is not erasure. Filesystem deletion does not securely erase
SSDs, snapshots, or backups.

With --json, branch on complete — it is the engine's own verdict, false whenever a remnant, an external path, an unclassifiable configuration or an interrupt occurred. A document is emitted only when a pass actually ran, so its presence distinguishes "the purge ran" from "the purge never started", and lifecycle_restored distinguishes a clean finish from one that needs attention:

{
  "mode": "purge",
  "roots_examined": true,
  "removed": [{"logical": "/var/lib/facelock/enrolled/alice", "kind": "File"}],
  "remnants": [
    {
      "logical": "/var/lib/facelock/facelock.db",
      "kind": "HardLink",
      "detail": "regular file has 2 links"
    }
  ],
  "external": [{"field": "storage.db_path", "path": "/srv/faces/faces.db"}],
  "config_note": null,
  "complete": false,
  "interrupted": false,
  "secure_erasure": false,
  "lifecycle_restored": true,
  "lifecycle_error": null,
  "activation_barred": false,
  "activation_barrier_path": null
}

secure_erasure is a constant false, present so no consumer has to infer it.

What --dry-run does and does not tell you

A dry run classifies the configured paths and stops. It does not open the compiled roots, so it reports nothing about what is stored inside them:

Dry run: nothing will be removed. Reporting what purge would find.
Scope: configured paths only. The contents of /etc/facelock, /var/lib/facelock
and /var/log/facelock were NOT examined, so this reports nothing about what is
stored there. A real purge traverses them and reports what it removed and
retained.

Its document says the same in mode and roots_examined, and gives no completeness verdict — complete is null, never true. An empty dry run means "nothing was checked", not "nothing is there":

{"mode": "dry-run", "roots_examined": false, "complete": null, "removed": []}

Use it to see which configured paths fall outside the roots before authorizing a purge, not to confirm that data is gone.

If the purge is interrupted

Ctrl-C raises the lease's interrupt flag; the engine stops at a deletion boundary, and the daemon is restored only after it has stopped, so an interrupt never leaves the daemon racing a live traversal.

An interrupted run prints no report at all. The signal handler restores the daemon and then terminates the process, which happens while the command is still waiting on that restore — before it reaches the point where it would print anything. Whatever was deleted stays deleted.

Repeating a purge is always safe, so the way to see what remains after an interrupt is to run it again. A second run reports what the first one left.

User Resolution

For commands that accept --user:

  1. Explicit --user flag (highest priority)
  2. SUDO_USER environment variable
  3. DOAS_USER environment variable
  4. Current user ($USER or getpwuid)

Environment Variables

VariablePurpose
FACELOCK_CONFIGOverride config file path only while the effective user is non-root. Every effective-UID-0 process ignores it, including ordinary root CLI commands; use the explicit global --config flag when supported.
RUST_LOGControl log verbosity (e.g., facelock_daemon=debug). Outranks both the built-in default and -v. An unparseable value is reported at warn and ignored.

Auxiliary Commands

The workspace builds three executables outside the unified facelock command tree. They do not inherit facelock's global flags, privilege dispatcher, or output contracts.

facelock-bench

facelock-bench is a developer benchmark binary. Current Debian, RPM, Arch, and release artifact paths do not install or publish it; the Nix derivation's workspace-wide install result has not been established as a delivery contract. Build it from a checkout and run the resulting path explicitly:

cargo build --release --bin facelock-bench
target/release/facelock-bench --help

After providing a configuration, models, camera access, and any per-command prerequisites in the table below, for example:

target/release/facelock-bench camera-reopen --iterations 10

It has exactly eight verbs:

CommandMeasurementAdditional prerequisite
facelock-bench cold-authmodel load, camera/store open and captures until first detected face or timeout; config loading precedes the timerplaintext enrolled templates
facelock-bench warm-authten captures and matches with models loadedplaintext enrolled templates
facelock-bench previewcamera capture and face processing (detection and embedding)camera and models
facelock-bench enrollmentfive capture-and-embed snapshots, without storing themcamera and models
facelock-bench model-loaddetector and embedder loadmodels
facelock-bench calibrateten live captures compared with the current user's enrolled templates, sweeping thresholds from 0.20 through 0.80 toward a 90% match ratecamera, models and current-user plaintext enrollment; it does not estimate false-accept rates
facelock-bench camera-reopenopen, STREAMON, warm-up and total reopen latencycamera; optional --iterations <N>, default 5
facelock-bench reportenvironment plus model-load, preview and enrollment-snapshot timings; warm and approximate cold-auth timings when plaintext templates existcamera, models and a readable database; templates are optional for timing

There are no --config, --quiet, --verbose, --json, or --user options and no automatic root gate. FACELOCK_CONFIG selects the configuration only for a non-root process; effective-UID-0 processes ignore it and read the fixed default. Access to the default root-owned database and many camera devices may still require privileges.

This older standalone path reads only plaintext embedding rows. It cannot benchmark the current default encrypted (keyfile) store. Do not turn off encryption on a real enrollment merely to use it; prefer the supported sudo facelock bench ... commands, which understand the configured store and apply a consistent root gate.

Where a verb needs a user, facelock-bench reads USER, then LOGNAME, then uses the literal name unknown. It does not use --user, SUDO_USER, DOAS_USER, or a UID lookup. Running it through sudo therefore commonly selects root, not the invoking desktop user. Measurement reports go to stdout; diagnostics go to stderr. RUST_LOG controls diagnostic filtering.

These measurements do not run PAM/daemon policy, rate limiting or liveness checks, and a missed timing target or non-match does not itself fail the command. report approximates cold authentication by reloading models and capturing once on its already-open camera; it does not run the standalone cold-auth loop, calibrate or camera-reopen. Its model-pack and build labels are fixed text rather than detected metadata. The ordinary capture paths open with an empty quirks database; camera-reopen loads the real quirks database for its reopen measurement.

facelock-polkit-agent

facelock-polkit-agent is an experimental session service, not a CLI. It has no options or subcommands. In particular, do not run it with --help or --version: those strings are not parsed and the process will instead connect to D-Bus and start the agent.

The binary needs all of the following:

  • a working system D-Bus and polkit authority
  • the user's session D-Bus
  • a usable Facelock daemon and enrollment
  • a valid local session ID in XDG_SESSION_ID for registration
  • an action ID listed in [polkit].face_eligible_actions

It reads the ordinary configuration as the session user. If that load fails, it uses the restrictive default allowlist containing only org.freedesktop.login1.lock-sessions. LANG supplies the registration locale, with en_US.UTF-8 as the fallback. If XDG_SESSION_ID is missing, the binary submits the literal string auto; it does not resolve a session ID itself. Registration must succeed before the agent can handle requests.

Packages may install the executable, but they intentionally do not install an autostart entry. A desktop-session integrator that has tested the agent can use this shape in ~/.config/autostart/org.facelock.AuthAgent.desktop:

[Desktop Entry]
Type=Application
Name=Facelock polkit authentication agent
Exec=facelock-polkit-agent
OnlyShowIn=ExampleDesktop;
X-GNOME-Autostart-enabled=true

Replace ExampleDesktop with the desktop identifier or remove OnlyShowIn only after testing the session's agent selection. Polkit permits one authentication agent per session. Registering this experimental agent can displace the desktop's password agent; when Facelock declines or fails, that can produce a denial instead of a password dialog. Keep a recovery path and do not deploy it as a universal replacement. The internal FACELOCK_POLKIT_SKIP_REGISTER test hook is not a supported user setting.

For per-action policy and the fallback limitation, see contracts.md. For build, test and maintenance commands, see developer-commands.md.

facelock-synth-face

facelock-synth-face is a test fixture writer, built from facelock-test-support. No package installs it. It takes one argument, an output directory, and writes the loopback tier's synthetic face sequence as raw video: ir.y8 (640x480 GREY, 24 frames back to back), rgb.yuyv (the same frames as YUYV 4:2:2 with neutral chroma) and frame-00.pgm (the first frame, for a look). The output is deterministic: the same bytes on every host.

cargo run -p facelock-test-support --bin facelock-synth-face -- /tmp/synth

The face is drawn procedurally and is nobody's (test/loopback/NOTICE.md). test/loopback/run-loopback-tier.sh runs the binary itself; the only reason to run it by hand is to inspect a frame. Exit 2 means the argument is missing, exit 1 that the directory could not be written.

Developer Commands

This index is derived from Cargo targets and the public justfile metadata. Regenerate it with python3 test/docs-inventory.py --write; just check-docs detects drift.

Run recipes from a repository checkout. Recipes can build, download, install, remove, or publish state: inspect just --show RECIPE and read the linked guide before using one. An entry here records an interface, not evidence that a release or hardware test ran.

Executables

ExecutableCrateReference
facelockfacelock-cliReference
facelock-benchfacelock-benchReference
facelock-polkit-agentfacelock-polkitReference
facelock-synth-facefacelock-test-supportReference

The PAM module is a shared library, not a command: see contracts.

Prerequisites and effects

  • Build/test/lint recipes need the development dependencies
  • Package/container recipes need Podman, their declared images and build tools; testing safety explains the tiers
  • Camera/TPM/GPU recipes need the named devices/models; skipped hardware is not verification
  • Install/uninstall recipes change system files through sudo; use a disposable guest for testing
  • Release recipes may change versions or publish externally; follow releasing and inspect the recipe before invocation
  • Documentation checks inspect examples; walkthroughs establish actual clean-system results

Public recipes

Arguments in square brackets are optional; defaults are shown. This is a syntax index, so substitute real values for metavariables before running a recipe.

InvocationDescription
just auditScan Cargo.lock for RustSec advisories; requires cargo-audit and applies .cargo/audit.toml.
just buildBuild in debug mode (development)
just build-releaseBuild in release mode (for install)
just build-smoke-binariesBuild only the release facelock (tpm) and PAM module the CI smoke tiers consume.
just checkRun local tests, lint, format, audit, PAM isolation and documentation/install/release contracts; excludes full packaging and hardware lanes.
just check-agent-docs [base=]Check repository instructions and lifecycle contracts; optional base ref adds a coupling check.
just check-docsVerify instructional coverage, references and parser acceptance (no example execution).
just check-package-names-liveResolve documented dependency names against live upstream repositories (network required).
just check-pam-standaloneBuild PAM independently and reject forbidden async-io backend dependencies.
just check-workflow-policyPin the trust boundary of the comment-triggered Claude workflow (docs/security.md, CI Trust Boundary).
just cleanClean build artifacts
just docs-inventoryReport the tracked documentation, public recipes and Cargo executables as JSON.
just docs-site-checkBuild with mdBook 0.4.44 and check rendered links/assets; retain the temporary site for review.
just fmtFormat code
just fmt-checkFormat check
just installBuild release binaries as the invoking user, then elevate for system file installation.
just install-filesInstall pre-built binaries to system (requires root, no build)
just link-models [src=]Populate models/*.onnx from an existing checkout or install tree
just lintLint every workspace target with Clippy, denying warnings (matches CI).
just lint-tpmLint every workspace target with the tpm feature enabled (matches CI).
just moCompile and validate available PO catalogs into target/locale (requires msgfmt).
just potRegenerate both gettext POT templates from source messages.
just release <version>Validate and update release versions, then print the commit/tag/push steps; does not publish.
just release-preflight [tag=]Check release prerequisites and pinned evidence; infer tag from Cargo.toml unless supplied.
just show-pathsShow installed file locations
just testRun all unit tests
just test-allRun all tests including hardware-dependent (ignored) tests
just test-apt-repo [trixie_manifest=] [resolute_manifest=]Test local signed APT publication/client resolution using both supplied manifests or stable stand-in packages.
just test-arch-camera-freeAutomated camera-free E2E tests (Arch container, no camera needed)
just test-arch-camera-requiredBoth camera-required E2E tiers, recorded for release-preflight (requires camera)
just test-arch-dev-shellDev shell — interactive Arch container with host models for fast iteration (requires camera)
just test-arch-integrationAutomated daemon integration tests (Arch, requires camera)
just test-arch-layoutCheck installed state-directory permissions and enrollment-marker visibility in Arch.
just test-arch-loopback [ir=] [rgb=]Both camera-required E2E tiers against a synthetic v4l2loopback camera, recorded for release-preflight
just test-arch-oneshotAutomated oneshot (daemonless) integration tests (Arch, requires camera)
just test-arch-package-selectTest selection of the main Arch package rather than its debug split.
just test-arch-pamAutomated PAM smoke tests (Arch container)
just test-arch-pkgPackage test — build the real dist/PKGBUILD with makepkg, install it with pacman, validate
just test-arch-release-shellInteractive Arch shell with locally staged binaries, no host model mounts (for camera testing).
just test-cargo-vendor-contractProve the deterministic, exact Cargo source component used by Debian builds.
just test-classify-changesTest CI packaging path classification using temporary Git histories.
just test-copr [release=44]COPR-equivalent build — Packit SRPM + mock from-source rebuild on a Fedora chroot (slow, opt-in)
just test-copr-lanesEvery Packit/COPR release target rebuilt from source at its declared depth
just test-copr-pkg [release=44]COPR lifecycle lane — mock source rebuild, then the booted package lifecycle
just test-copr-smoke [release=45]Branched-release COPR lane — mock source rebuild, then the runtime smoke
just test-debRun both exact supported-suite Debian package gates.
just test-deb-dev-shellDev shell — interactive .deb container with host models for fast iteration (requires camera)
just test-deb-package-contract <manifest>Validate every binary package named by one exact generated manifest.
just test-deb-package-contract-testExercise exact Debian manifest identity, checksum, and atomic-staging mutations.
just test-deb-release-shellInteractive Ubuntu 26.04 shell with a locally built .deb and test config, no host model mounts.
just test-deb-resolute-pkgUbuntu 26.04 Resolute package — exact source build, TPM/PCR, and booted lifecycle.
just test-deb-source-contractStatic Debian source/metadata/release-consumer contract.
just test-deb-trixie-pkgDebian 13 Trixie package — exact source build, TPM/PCR, and booted lifecycle.
just test-debian-postrm-purgeExercise Debian remove/purge policy below disposable fixed roots only.
just test-docs-walkthrough <scenario> <identity> <output>Execute one explicit walkthrough scenario using a pinned identity inside a disposable guest.
just test-legacy-system-assetsValidate immutable system assets and migrate only exact historical /etc copies.
just test-locale-install-contractCheck locale installation across package paths; compile a fixture when gettext is available.
just test-packaging-matrixEvery packaging lane the release gate requires, recorded for release-preflight
just test-packit-configPackit config schema gate — runs the real packit in a digest-pinned Fedora container
just test-release-artifactsStatic contract: the release publishes exactly once, after validation
just test-release-contractFast release contract tests that do not require distro package tools.
just test-release-matrixComplete Track V version/matrix gate.
just test-release-native-orderingNative version comparison tools run only inside disposable, digest-pinned containers.
just test-rpm [release=44]Test RPM packaging in Fedora container
just test-rpm-authselect [release=44]Static and booted, model-free Fedora authselect retirement lifecycle
just test-rpm-dev-shell [release=44]Dev shell — interactive .rpm container with host models for fast iteration (requires camera)
just test-rpm-lanesEvery declared Fedora release target at its declared lifecycle depth
just test-rpm-pkg [release=44]Package test — build real .rpm, install via dnf, validate under booted systemd
just test-rpm-release-shell [release=44]Interactive Fedora shell with a locally built .rpm and test config, no host model mounts.
just test-rpm-smoke [release=45]Branched-release lane — build the package, then boot it for a runtime smoke
just test-source-install-daemon-lifecyclePreserve the daemon's pre-install runtime state across source file replacement.
just test-source-install-daemon-lifecycle-systemdExercise the source-install barrier against a real systemd and system bus.
just test-upgrade-v014Both released-predecessor upgrade lanes — the stable entrypoint for #231
just test-upgrade-v014-contractReleased-predecessor upgrade lanes (#231) — container-free half, runs anywhere
just test-upgrade-v014-debDebian half: install the real v0.1.4 .deb, upgrade to the candidate, roll back
just test-upgrade-v014-pinsConfirm the pinned v0.1.4 assets are still the assets GitHub serves (needs gh)
just test-upgrade-v014-rpmFedora half: same proof against the released fc44 RPM
just uninstallRemove source-installed system assets through sudo; retain biometric state and models.
just uninstall-filesUninstall files from system (requires root, called by uninstall)
just versionShow current version

Security Model

Threat Model

Facelock is a local biometric authentication system. The threat model assumes:

  • Attacker has physical access to the machine (the entire point of face auth is physical-presence scenarios like unlocking a laptop)
  • Attacker may have a photo or video of the enrolled user
  • Attacker does not have root (if they do, game over regardless)
  • Attacker cannot modify files in /etc/facelock/, /var/lib/facelock/, or the distribution's PAM module directories

Privacy Guarantees

Facelock is designed to keep biometric data under the user's exclusive control:

  • Local-only inference: All face detection and recognition runs on-device via ONNX Runtime. No images, embeddings, or metadata are ever transmitted over the network.
  • No telemetry: Facelock contains zero analytics, tracking, or phone-home code. Authentication makes no network request. The separately invoked sudo facelock setup flow downloads model files when they are missing.
  • No cloud dependencies: Authentication works fully offline. No account registration, no API keys, no external services.
  • Data stays on disk: Face embeddings are stored in a local SQLite database (/var/lib/facelock/facelock.db) with restrictive permissions (600, root:root). New enrollments use AES-256-GCM keyfile encryption by default; TPM sealing is optional.
  • Open source: Facelock's source is dual-licensed under MIT or Apache-2.0. Dependencies and model weights have separate licenses; see the model notice. Privacy claims can be checked against the source.

Attack Vectors & Mitigations

1. Photo/Video Spoofing (CRITICAL)

Attack: Hold a photo or video of the enrolled user in front of the camera.

Why this matters: This is the #1 attack against face authentication. Without mitigation, anyone with a Facebook photo can unlock the machine.

Mitigations (layered, implement all):

A. IR Camera Enforcement (Required)

security.require_ir config flag, default true:

[security]
require_ir = true  # Refuse to authenticate on RGB-only cameras

Rationale: Requiring an IR-classified capture path and applying the IR texture threshold raises the bar against static RGB presentations. It is one layer, not proof of sensor authenticity or resistance to video replay.

Limitation: IR classification uses an exclusively mono advertised format set or an exact hardware quirk; the device name is never evidence. A mixed YUYV/mono node is not auto-classified without a matching quirk. Use sudo facelock devices to inspect the result. Y16 authentication additionally requires a hardware-verified y16_bit_depth; Y8, Y10, and Y12 are classification evidence but are not decoded.

B. Frame Variance Check (Required)

Require minimum variance across consecutive frames during authentication. Real faces have micro-movements causing slight embedding variation. A static photo produces near-identical embeddings (similarity > 0.99).

Config:

[security]
require_frame_variance = true  # Reject static images (photo attack defense)
min_auth_frames = 3            # Minimum frames before accepting match

In IR mode, verify that the face region has expected IR texture characteristics:

  • Real skin has micro-texture visible in IR
  • Photos/screens appear as flat, uniform surfaces in IR
  • Compute standard deviation of pixel intensity within the face bounding box
  • Reject faces with abnormally low texture variance

2. Model Tampering

Attack: Replace ONNX model files with adversarial models that always match (or match specific attackers).

Mitigations:

A. SHA256 Verification at Load Time (Required)

Verify model integrity not just at download, but every time the daemon loads models. Tampered files are rejected before any inference runs.

B. File Permissions on Model Directory (Required)

# Models owned by root, not writable by others
chown -R root:root /var/lib/facelock/models
chmod 755 /var/lib/facelock/models
chmod 644 /var/lib/facelock/models/*.onnx

3. Embedding / Database Security

Attack: Read or modify the SQLite database to extract biometric data or inject fake embeddings.

Mitigations:

A. Database File Permissions (Required)

# Database owned by root, readable by root only
chown root:root /var/lib/facelock/facelock.db
chmod 600 /var/lib/facelock/facelock.db

B. Embedding Sensitivity Warning

Face embeddings are biometric data. Unlike passwords, they cannot be changed. The database contains irreversible biometric templates -- if compromised, the user's face embeddings cannot be "rotated" like a password.

C. Encryption at Rest (Implemented)

Templates are encrypted at rest by default. The key lives in a plaintext key file (encryption.method = "keyfile", the default) or sealed to the TPM (encryption.method = "tpm"), and either way the embeddings themselves are AES-256-GCM. The keyfile is generated at mode 0600 on first use. A TPM-sealed key is unsealed once at daemon startup and held in memory.

Plaintext storage (encryption.method = "none") is an explicit opt-out: enrollment refuses to write unencrypted templates unless security.allow_plaintext = true. Auth is never affected by any of this. A decrypt failure falls back to the password, never to a lockout.

tpm.pcr_binding is off by default, and turning it on is a commitment rather than a hardening tweak. With it on, the sealed key is bound to a PCR selection recorded in the sealed blob, and unsealing replays a real PolicyPCR session against the machine's current PCRs. A firmware or kernel change to a bound PCR makes the key refuse to unseal. Face auth then falls through to the password, which is the safe failure, but the templates stay locked until you act.

Recovery is one command:

sudo facelock tpm reseal

It re-seals the key under the current PCR state, recovering the key from the existing blob if the PCRs still match and from the plaintext encryption.key backup if they do not.

Keep that encryption.key backup. It is the recommended setup and it is what makes a reseal painless: without it, a PCR change after a firmware update means re-enrolling every face. The honest cost is that while the backup exists, the tpm method's protection against anyone who can read the file is the backup's own 0600 root-only permissions, not the TPM. Deleting the backup buys stronger at-rest confidentiality and pays for it in re-enrollment.

See Configuration for the [encryption] and [tpm] sections, and docs/security.md for the full finding.

4. D-Bus IPC Security

Attack: Unauthorized user connects to the daemon via D-Bus to trigger auth, enroll faces, or extract data.

Mitigations:

A. D-Bus System Bus Policy (Required)

The D-Bus system bus policy (/usr/share/dbus-1/system.d/org.facelock.Daemon.conf) governs who may own the bus name and which methods each caller may send. Two grants (ADR 010): root may send anything on the interface and receive its signals; every local user may send exactly one method, org.facelock.Daemon.Authenticate, which is what lets screen lockers and the polkit agent unlock with no group and no re-login. There is no facelock group; signal receipt is root-only. (/etc/dbus-1/system.d/ is the admin-override location for local customization.)

Because the bus admits every local user's Authenticate, the in-daemon per-method UID check is the boundary for that method. Before executing any method, the daemon calls GetConnectionUnixUser to verify the caller's UID. Authenticate allows root, or a non-root caller acting on its own username — a user must be able to request authentication for themselves, since screen lockers run their PAM stack as that user. Every other method, including Enroll, Shutdown, and the preview methods, is restricted to root (UID 0), so no non-root caller can enroll faces, pull camera frames, or shut down the daemon.

B. D-Bus Message Size Limits (Required)

The D-Bus bus daemon enforces message size limits, preventing memory exhaustion attacks.

Throttle authentication attempts: 5 per user per 60 seconds by default. Prevents brute-force and rapid-retry attacks.

5. PAM Module Hardening

A. Audit Logging (Required)

All authentication attempts are logged to syslog with user, service, and outcome:

pam_facelock(sudo): match for user alice
pam_facelock(sudo): no_match for user bob

This creates an audit trail in /var/log/auth.log or journald.

Allow different PAM services to have different security levels:

[security.pam_policy]
allowed_services = ["sudo", "polkit-1"]
denied_services = ["login", "sshd", "su"]

6. Daemon Process Hardening

After initialization, the daemon drops all unnecessary capabilities.

B. systemd Hardening (Required)

The systemd unit includes: ProtectSystem=strict, ProtectHome=yes, NoNewPrivileges=yes, PrivateTmp=yes, and other sandboxing directives.

Security Configuration Reference

[security]
disabled = false
abort_if_ssh = true          # Refuse face auth over SSH
abort_if_lid_closed = true   # Refuse if laptop lid closed
require_ir = true            # CRITICAL: refuse RGB-only cameras (anti-spoof)
require_frame_variance = true # Reject static images (photo defense)
require_landmark_liveness = false # Require landmark movement between frames (off by default)
min_auth_frames = 3          # Minimum frames before accepting (variance check)

[notification]
mode = "terminal"            # Show "Identifying face..." on login screen

[security.pam_policy]
allowed_services = ["sudo", "polkit-1"]
denied_services = ["login", "sshd"]

[security.rate_limit]
max_attempts = 5             # Max face-detected auth failures per user per window
window_secs = 60             # Rate limit window

Summary: Security Implementation Priority

PriorityMitigation
P0IR camera enforcement (require_ir)
P0Frame variance check (anti-photo)
P0Model SHA256 at load time
P0D-Bus system bus policy
P0D-Bus message size limits
P0PAM audit logging
P0Database file permissions
P1IR texture validation
P1Rate limiting
P1systemd hardening
P1Capability dropping
P1Service-specific PAM policy
P2Embedding encryption at rest
P2Memory zeroing on drop
P2Constant-time similarity comparison

Troubleshooting

Camera selection

Start with the root-gated device report:

sudo facelock devices
v4l2-ctl --list-devices

Facelock identifies IR from an exclusively mono advertised format set or an exact hardware quirk, never from “IR” in the device name. GREY is supported; Y16 additionally needs a hardware-verified y16_bit_depth. Y8, Y10, Y12, raw Bayer, and unknown formats are not decode paths. See Compatibility before forcing a device.

Use preview to distinguish selection from detection problems:

sudo facelock preview
sudo env RUST_LOG=facelock_camera=trace facelock preview

Recognition and performance

facelock test may return zero when no camera scan ran or after a completed non-match. Read the printed result. A cold daemon attempt includes model load and camera reopen; measure the hardware-specific reopen cost with:

sudo facelock bench camera-reopen

PAM lockout recovery

Keep a separate root shell open for every host PAM test. From that shell, prefer the validated removal path:

If you still have a root shell open

facelock pam remove --service sudo

Current managed backups live beneath /var/lib/facelock/pam-backups/ with versioned names and adjacent JSON provenance. Do not restore the newest-looking file without reviewing its provenance and the live target. Current Facelock does not automatically create /etc/pam.d/sudo.facelock-backup; that path exists only when an operator or an older release made it.

If you are locked out

With no root shell, boot recovery media, remount the root filesystem read-write, and remove the exact pam_facelock.so rule or restore a separately recorded and reviewed operator copy. Test sudo before any shared stack, display manager, login, or sshd integration.

Daemon and logs

systemctl status facelock-daemon.service
journalctl -u facelock-daemon.service -n 50 --no-pager
sudo facelock status --json
sudo facelock -vv daemon run

The package uses the D-Bus system bus and the package-owned policy under /usr/share/dbus-1/system.d/. An administrator may also have local fragments; D-Bus merges them. sudo facelock setup --systemd validates installed assets and reports preserved local policy for review.

See the canonical Troubleshooting page on GitHub for model verification, IPU relay, permissions, and detailed recovery guidance.

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.

Compatibility

The current delivery targets are x86_64 Linux systems with V4L2 and Linux-PAM; source builds require Rust 1.88+. Package validation covers Arch, Debian 13 (trixie), Ubuntu 26.04 LTS (resolute), and Fedora 43/44/45. RHEL is not in the supported matrix. Source templates exist for OpenRC, runit, and s6, but that does not establish package or hardware support for every distribution using those supervisors.

Tested Distributions

Debian-family release support is exactly Debian 13 (Trixie) and Ubuntu 26.04 LTS (Resolute). Other Debian and Ubuntu releases are unsupported.

DistributionInit SystemModeStatus
Arch Linuxsystemddaemon + D-Bus activationPrimary target
Debian 13 (Trixie)systemddaemon + D-Bus activationBooted package gate
Ubuntu 26.04 LTS (Resolute)systemddaemon + D-Bus activationBooted package gate
Fedora 43/44/45systemddaemon + D-Bus activationRelease matrix target

Expected to Work (untested)

No additional distribution is claimed as expected to work. Source templates do not convert a distribution into an untested support claim.

Cameras and formats

Facelock classifies an IR node from its advertised format set or an exact hardware quirk. It does not use a device name containing “IR” as evidence. The current hardware validation record covers the Logitech BRIO 046d:085e IR node using native GREY. Do not infer support for Intel RealSense or “Windows Hello” cameras from their product category alone.

FormatAuthentication support
GREYsupported 8-bit grayscale path
Y16conditional on a hardware-verified y16_bit_depth quirk from 8 through 16
YUYV, NV12, MJPGsupported decode paths; normally RGB unless an exact quirk says otherwise
Y8, Y10, Y12IR-classification evidence only; not decoded
raw Bayer and other formatsnot supported

The shipped RealSense Y16 quirks deliberately have no bit-depth evidence, so authentication rejects those Y16 paths. A 16-bit V4L2 container does not prove the sensor's meaningful bit depth. Auto-detection excludes devices with no decodable format and reports the advertised formats.

RGB operation requires security.require_ir = false and is for development. Frame variance and any enabled landmark-liveness check still apply, but they do not restore the default IR boundary or guarantee resistance to presentation attacks. See the canonical Compatibility page on GitHub for the IPU6/IPU7 relay notes, exact negotiation order, and current validation evidence.

Init and PAM

systemd with D-Bus activation is the packaged daemon path. On non-systemd systems, use daemon.mode = "oneshot" or install one of the source-tree OpenRC/runit/s6 templates after a source install. The daemon command must run as root.

Test PAM on sudo first while retaining a root recovery shell. Shared stacks, console login, and SSH are sensitive targets and require the CLI's explicit --allow-sensitive gate. PAM service availability is distribution-specific; use facelock pam status rather than assuming a path exists.

Inference providers

CPU is the default and tested provider. CUDA, ROCm, and OpenVINO require a matching ONNX Runtime build; configuration support is not evidence that a GPU or a particular runtime package has been validated. The setup auto choice inspects providers compiled into ONNX Runtime, not the hardware.

Testing and Safety

Unit, lint, audit, documentation, and contract checks need no host authentication changes:

just check

Camera tests, package lifecycle tests, and live authentication have different risk. Use containers for isolated PAM smoke coverage and an explicitly marked disposable guest for booted package/login scenarios. The walkthrough runner does not provision the guest and refuses ordinary hosts. See Testing and Safety and the Testing Walkthrough.

The development configuration is not rootless. Management commands retain their root gate, and effective-UID-0 processes ignore FACELOCK_CONFIG:

just build
just link-models
sudo target/debug/facelock --config "$PWD/dev/config.toml" devices

Do not use development setup as a shortcut into host PAM. Before any host PAM test, validate the isolated tiers, retain a separate root shell, start with sudo, and test from a new terminal. Prefer validated removal:

facelock pam add --service sudo
facelock pam remove --service sudo

Run those two commands from the retained root shell. Current managed backups are versioned beneath /var/lib/facelock/pam-backups/ and carry JSON provenance. Facelock does not automatically create /etc/pam.d/sudo.facelock-backup; that path exists only if an operator or an older release made it. Review any copy before restoring it.

facelock test can return zero without a match or camera scan. Treat its human output, not exit status alone, as the result.

Testing and Safety

PAM, package lifecycle, service activation, enrollment, and authentication can change the machine or require real hardware. Do not exercise those paths on a workstation merely to validate documentation or a patch.

Safe local checks

These checks do not need root, a camera, installed models, or host PAM edits:

cargo test --workspace
cargo clippy --workspace -- -D warnings
just check

just check is broader than the first two commands: it includes formatting, the RustSec audit, documentation/contracts checks, source-install lifecycle tests, and package/release contract checks. See Developer Commands for the generated inventory.

Ignored hardware tests need models and a camera and are not part of that safe baseline:

just link-models
cargo test --workspace -- --ignored

Container and guest tiers

The Arch PAM smoke container tests module loading and failure behavior without editing host PAM:

just test-arch-pam

The camera container recipes pass real devices through and perform live enrollment/authentication. Run them only when that hardware interaction is intended:

just test-arch-integration
just test-arch-oneshot

They default to a 90-second live-step timeout. A longer timeout uses timeout(1) syntax:

FACELOCK_LIVE_TIMEOUT=5m just test-arch-integration

Synthetic camera

The same two scripts run against a synthetic camera, with no real device passed through and nobody in frame:

just test-arch-loopback

The camera is a v4l2loopback node fed by ffmpeg with a procedurally rendered face sequence (test/loopback/NOTICE.md: drawn from arithmetic, nobody's face). While it is fed, the node enumerates GREY only, so facelock classifies it as IR by format evidence — the residual Security describes — and the run keeps require_ir and require_frame_variance at their product defaults, because the sequence drifts frame to frame the way a person does. A second node fed YUYV is the non-IR camera the require_ir refusal assertions need; without it they report SKIP.

The tier needs two idle loopback nodes the calling user can write. Loading the module needs root; the recipe does not do it and exits 2 with this when the nodes are missing:

sudo modprobe v4l2loopback devices=2 video_nr=20,21 \
    card_label=facelock-synth-mono,facelock-synth-color exclusive_caps=1,1
sudo udevadm settle && sudo chmod a+rw /dev/video20 /dev/video21

The settle matters: udev applies its own 0660 root:video mode when it processes the add event, which can land after a chmod issued right behind the modprobe.

If v4l2loopback is already loaded for something else, add nodes without unloading it (v4l2loopback-utils, module 0.13 or later):

sudo v4l2loopback-ctl add -x 1 -n facelock-synth-mono /dev/video20
sudo v4l2loopback-ctl add -x 1 -n facelock-synth-color /dev/video21

While the tier feeds them, the nodes are visible to the host too. An idle exclusive_caps=1 node enumerates no formats and auto-detection ignores it, but a fed facelock-synth-mono node enumerates GREY only and classifies as IR by format evidence, exactly as a real IR camera without a quirk entry does; whichever of the two enumerates first wins a host auto-detect, so a host face-auth attempt during the run (a sudo prompt, a lock screen) can be judged against the synthetic face and fall through to the password. A camera the quirks database knows still ranks above it. The labels carry no ir token on purpose: auto-detection prefers a format-classified node whose name says ir, and a synthetic node should never beat a real sensor on its name (has_ir_name_token in crates/facelock-camera/src/device.rs). The tier itself never reads the label. Pin device.path on the host or wait out the run, which takes about two minutes.

FACELOCK_LOOPBACK_IR and FACELOCK_LOOPBACK_RGB pick other nodes (FACELOCK_LOOPBACK_RGB=none runs without the twin). The script refuses a node that has a parent device in sysfs — a real camera — or that another process is already feeding, so it cannot open the host's webcam by mistake. Only the loopback nodes are passed into the container.

It records the commit it passed at to .loopback-tier-verified, which satisfies just release-preflight on its own, as the real-camera record does. It is cheaper evidence, not the same evidence: it proves capture, IR classification, the liveness gates, enrollment, the daemon and one-shot paths and PAM end to end on a device the product treats as an IR sensor, and it cannot prove that a real sensor's frames match a real face.

Container coverage is not proof that a booted package, display manager, or real login stack is safe. Use the evidence walkthrough in an explicitly marked disposable guest for those cases; its runner refuses ordinary hosts and does not provision a VM for you. See Testing Walkthrough.

Development configuration

dev/config.toml uses checkout models, oneshot mode, and temporary database, key, snapshot, and audit paths. It is not rootless: the management CLI keeps its normal privilege gate. Root also ignores FACELOCK_CONFIG, so pass the configuration explicitly:

just build
just link-models
sudo target/debug/facelock --config "$PWD/dev/config.toml" devices
sudo target/debug/facelock --config "$PWD/dev/config.toml" enroll --skip-setup-check
sudo target/debug/facelock --config "$PWD/dev/config.toml" test

Do not run setup for this flow. Setup owns installed-system state, including the fixed /etc/facelock/.setup-complete marker, and may offer systemd and PAM changes. The explicit non-default configuration routes supported management commands through direct access; it does not make a manually started daemon use that backend.

facelock test returning zero is not proof of a match or even a scan. It also returns zero when no usable enrollment exists and after a completed non-match. Read its output.

Host PAM testing

Only test host PAM after the container and disposable-guest tiers are satisfactory.

  1. Open a separate root shell and keep it open.
  2. Optionally create and label your own emergency copy before Facelock touches the service: cp /etc/pam.d/sudo /root/sudo.pam.before-facelock from that root shell.
  3. Add only the sudo service with facelock pam add --service sudo from the root shell.
  4. Test a correct password and a wrong password in a new terminal, then test face authentication.
  5. If anything is wrong, run facelock pam remove --service sudo from the retained root shell.

Facelock-managed rollback files are versioned under /var/lib/facelock/pam-backups/ with adjacent JSON provenance. They are not the old /etc/pam.d/sudo.facelock-backup path. Never select the newest-looking backup and copy it blindly: review its provenance and target state, or let the CLI perform the validated removal. An adjacent /etc/pam.d/sudo.facelock-backup exists only if an operator or an older release created it; current Facelock does not create that emergency copy.

Do not begin with login, sshd, a display manager, or shared stacks such as system-auth and common-auth. The CLI requires --allow-sensitive for these targets because one error can affect many authentication paths.

If the retained root shell is unavailable, boot a recovery environment, remount the root filesystem read-write, and remove the exact pam_facelock.so rule or restore a separately reviewed operator copy. See Troubleshooting.

Logging

Use global -v flags for privileged commands because they survive sudo's environment filtering:

sudo facelock -v test
sudo facelock -vv daemon run

For target-specific filters, pass the environment through a trusted env invocation:

sudo env RUST_LOG=facelock_camera=trace facelock devices

Clean-System Testing Walkthrough

This workflow records reviewable evidence for documentation commands on a clean system. It never turns the documentation inventory into a script to run blindly. Only explicit cases in test/docs-walkthrough/cases.json may execute; unmapped executable and manual-only inventory rows remain visible as pending.

Use this workflow when collecting new clean-system execution evidence. It is not the completion gate for a documentation-only audit: source review, parser conformance, existing behavior tests, and package metadata can establish many documentation facts without replaying the commands. A pending execution record does not by itself identify a documentation defect.

Safety boundary

Booted scenarios run only in a disposable guest you provision and snapshot. The runner does not create a VM. Before it will mutate the guest, it requires the regular root-owned mode-0644 marker /etc/facelock-walkthrough-guest.json with this shape:

{
  "disposable": true,
  "guest_id": "unique-per-guest-id",
  "os": "debian-13",
  "image": "exact-matrix-image@sha256:full-image-digest",
  "init": "systemd",
  "snapshot": "pristine-snapshot-id",
  "level": "booted-vm",
  "hardware": []
}

Match os, image, and init to the selected scenario. The runner also verifies virtualization and refuses shared 9p, virtiofs, NFS, or CIFS mounts; protected PAM/bus mounts; and unexpected camera or TPM devices. Hardware scenarios must declare intentionally attached devices instead of inheriting them accidentally. A marker is an authorization for this disposable guest, not something to place on a workstation.

Inspect and validate the catalog

These repository-local commands do not run documented install/auth commands:

python3 test/docs-walkthrough/run.py list
python3 test/docs-walkthrough/run.py check
python3 test/docs-walkthrough/run.py report

list shows scenario IDs, check validates definitions, source pins, and total mapping, and report compares explicit case mappings with the complete documentation inventory. The separate walkthrough unit tests exercise safety guards. Use refresh only when intentionally refreshing the checked-in inventory and generated manual sections derived from documentation:

python3 test/docs-walkthrough/run.py refresh
git diff -- test/docs-walkthrough/cases.json
git diff -- test/docs-walkthrough/manual-sections.json

Review changed expectations and manual gate classifications before committing either generated file. A refreshed mapping is not evidence that any command was executed or reviewed.

The catalog includes repository and direct-package cases for APT, RPM/COPR, AUR, source, NixOS, OpenRC, runit, and s6, plus first setup, daemon/oneshot auth, desktop lock, physical TPM, GPU, and Y16 cases. Fixed adapters are reviewed route probes with source-context references, not literal replay of the referenced line. Literal source rows become ordered manual-section candidates. Listing or generating a case is not a claim that its expectations were reviewed or that it passed.

Pin release identity

Every run consumes an identity JSON rather than inferring binary publication from a tag or local build. For a GitHub release-asset channel, generate the readiness report for the intended release and channel:

RELEASE_TAG=v0.2.0-alpha.4
CHANNEL=github-alpha
READINESS_FILE=/tmp/facelock-walkthrough-readiness.json
python3 test/docs-walkthrough/run.py readiness --release "$RELEASE_TAG" --channel "$CHANNEL" --output "$READINESS_FILE"

The identity must bind release, normalized version, exact native package version, a 40-hex artifact_commit, channel, runtime policy, and immutable artifact evidence: name, URL, positive size, and 64-hex SHA256. GitHub binary release channels additionally require a positive release asset ID. Source/Nix identities instead name the exact GitHub tag archive; a tag archive does not require a GitHub Release. The current readiness helper nevertheless queries the Release API for these channels too, so its missing-release result is not a valid source-archive availability check. Check the tag resolution and archive identity separately; the source adapter does not require Release metadata.

The evidence record separately binds harness_sha256 and harness_tree_dirty. Public-repository identities additionally bind the downloaded package digest and repository URL, plus APT suite/signing-key digest, COPR chroot, or AUR commit as applicable. An AUR source-built package may omit the expected package digest; its evidence instead records the built payload digest and the verified recipe commit. Every other repository channel requires the expected package digest. artifact_commit is an asserted input unless the installed record's source_commit_verification proves tag/build linkage. Do not substitute a source checkout, staged build, or successful rebuild for published-asset identity.

Run one explicit case

Copy the repository into the disposable guest without a shared host mount, install the marker, and use a new evidence directory:

SCENARIO=apt-trixie
IDENTITY_FILE=/root/facelock-walkthrough-identity.json
EVIDENCE_DIR=/root/facelock-evidence/apt-trixie
python3 test/docs-walkthrough/run.py run --scenario "$SCENARIO" --identity "$IDENTITY_FILE" --output "$EVIDENCE_DIR"
python3 test/docs-walkthrough/evidence.py validate "$EVIDENCE_DIR/evidence.json"

Use --require-pass only when a passing outcome is required. A real publication absence is evidence, not a reason to rewrite the record as a pass. A clean apt-trixie run against an unpublished suite records the 404 its Release URL returned; that record is the outcome, not a failed run to retry.

When an environmental prerequisite is deliberately unavailable, record an explicit blocked result rather than skipping silently. The generic runner always rejects camera and TPM devices; intentional hardware work uses the separate manual protocol.

SCENARIO=physical-tpm
IDENTITY_FILE=/root/facelock-walkthrough-identity.json
EVIDENCE_DIR=/root/facelock-evidence/physical-tpm
python3 test/docs-walkthrough/run.py blocked --scenario "$SCENARIO" --identity "$IDENTITY_FILE" --reason "no dedicated TPM passthrough guest available" --output "$EVIDENCE_DIR"

Rootless container launcher

Container-eligible cases can use the guarded launcher with the exact image from the release matrix. It creates a named, UUID-scoped, rootless container with no mounts; it is not a substitute for booted systemd/PAM evidence:

SCENARIO=deb-trixie-direct
IDENTITY_FILE=/tmp/facelock-walkthrough-identity.json
CONTAINER_IMAGE=debian:13@sha256:full-image-digest
EVIDENCE_DIR=/tmp/facelock-evidence/deb-trixie-direct
python3 test/docs-walkthrough/run.py launch-container --scenario "$SCENARIO" --identity "$IDENTITY_FILE" --image "$CONTAINER_IMAGE" --output "$EVIDENCE_DIR"

Aggregate evidence

Validate a collection and use the strict aggregate only when intentionally requiring complete execution coverage of this walkthrough catalog:

EVIDENCE_ROOT=/root/facelock-evidence
python3 test/docs-walkthrough/evidence.py aggregate "$EVIDENCE_ROOT"
python3 test/docs-walkthrough/evidence.py aggregate --require-pass "$EVIDENCE_ROOT"

Aggregation reports missing cases and unmapped documentation inventory rows. It does not convert manual-only commands into executed coverage or let one distribution/channel stand in for another. This optional aggregate is not wired into just release-preflight and is not the documentation-accuracy gate.

Manual evidence

manual-sections.json presents remaining manual commands as ordered steps, including the exact documentation text, source location, and source hash. Manual review is not a shortcut around that binding. A passing manual record can qualify for completion only after its checked-in case has review_status: reviewed, concrete per-step invocation/exit/output/state expectations, and explicit fixture bindings. Refreshing the generated catalog resets these definitions to candidates requiring another review. The record must include manual_review with the operator, notes, expectations_reviewed: true, and the fixture bindings used. Each passing step must preserve the exact documented_command, record the actual argv, expected exit, actual output and observed state, and reference a sanitized, hashed log. If a required expectation cannot be observed, record the case as blocked or failed rather than marking the section complete.

Command Documentation Audit

This audit supports issue #211. Its deliverable is accurate, current documentation, not a rerun of the project's behavioral or release qualification suites. Review commands against their parsers and implementations, package instructions against manifests and the relevant distribution repositories, and descriptions against the existing contracts and behavioral tests. Use a disposable container to settle an installation-specific uncertainty; use a VM or dedicated hardware only when a particular documentation claim cannot otherwise be established.

Documentation conformance and clean-system execution are separate evidence. A passing parser or source review does not establish a new hardware result; conversely, an example need not be rerun on hardware to correct its syntax or explain already-tested behavior accurately.

Authoritative surfaces

Cargo metadata discovers executable targets; the recursive Clap tree supplies the unified CLI's commands, aliases, scoped options, positionals, defaults, required arguments, value choices and conflicts. Public Just metadata supplies the developer recipe index. The exact file classification lives in test/docs-corpus.json; new instructional files and stale classifications fail the inventory check, including in distribution source archives without .git.

  • CLI reference: every public command and its scoped flags; the book includes this source
  • Auxiliary commands: standalone benchmark verbs and the non-CLI polkit session executable
  • Developer commands: generated public recipe signatures and executable destinations
  • Configuration: defaults compared with the serialized Rust configuration and PAM-only policy declarations
  • Contracts: privilege, paths, state ownership, output and integration behavior
  • Man pages: scoped options checked against the parser; roff syntax checked separately
  • Website and book: command extraction plus link, fragment and asset checks against assembled HTML

Historical proposals, intentional invalid examples, syntax metavariables and unsupported shell expressions have explicit classifications. Parsing never dispatches a documented command. Unsupported expressions need source/context review; their optional walkthrough records remain pending instead of silently becoming successful runtime tests.

Reproduce the deterministic checks

Use the development prerequisites in Quick Start, including Python 3, Bash, Just and the Rust toolchain. The site additionally requires the same mdBook 0.4.44 used by Pages; CI also runs mandoc.

just check-docs
just docs-site-check

The CLI inventory can be exported without accessing cameras or the system bus:

FACELOCK_DOCS_SURFACE_OUTPUT=/tmp/facelock-cli-surface.json cargo test -p facelock-cli --bin facelock conformance::surface::export_cli_surface_if_requested

Mutation fixtures cover invented commands/options, missing required arguments, stale security defaults, shell quoting/redirection, missing documentation, broken site links, changed source hashes and invalid walkthrough evidence. The source archive test path must remain usable by Arch's package check(); Python is a check dependency, not an added runtime dependency.

Existing behavioral evidence boundaries

The audit supplements rather than replaces the existing semantic suites:

  • cli_smoke checks early privilege refusals, including PAM changes, state removal, TPM commands, and daemon startup; it also checks help/version and config-independent capabilities
  • json_stream_split checks machine output and diagnostic stream separation
  • facelock-core path tests check privileged configuration overrides and protected path identity
  • The camera-free container suite checks real D-Bus/PAM preflight, authorization, and output behavior
  • Package lifecycle/source-install suites check owned paths, install/removal behavior and service handling
  • Physical camera, desktop, TPM, GPU and alternative-init walkthroughs still require the corresponding disposable environment and observations

A suite's presence in this list does not claim it was executed for a particular release. Keep each run's revision, exact command, environment and outcome with its evidence.

Dated channel observations

Observed on 2026-09-05:

  • The v0.2.0-alpha.1 tag exists, but its GitHub Release was unavailable; no alpha release asset installation was established
  • A fresh pinned Debian 13 container reached the public APT trixie suite and received HTTP 404/no Release file; the documentation now identifies this suite as planned, not currently installable
  • Fresh pinned Fedora 43, 44 and 45 containers installed production COPR packages and ran their CLI; the observed packages were 0.1.3-1.fc43, 0.1.3-1.fc44 and 0.1.3-1.fc45, not the alpha or latest stable 0.1.4
  • Stable APT/AUR/production COPR channels cannot establish prerelease coverage; the release matrix separates these channels

Those earlier observations established publication limitations, not a prerequisite to reviewing the alpha.1 source. Its tag resolved to 53385c53b9c4b2e0f83368797c14599dc2c61485, and GitHub serves the tagged source archive without a GitHub Release at that checkpoint. Keep source-review results distinct from claims about uploaded release binaries or public repository packages.

See the walkthrough protocol when new runtime evidence is needed. Its catalog records route tests separately from exact documentation occurrences. Mechanically generated cases are execution candidates, not a documentation-accuracy score or a requirement to repeat established tests.

Documentation-accuracy follow-up

The 2026-09-05 follow-up reviewed the CLI, auxiliary executables, public Just recipes, configuration, contracts, man pages, Markdown guides and rendered site against their source definitions. It corrected privilege requirements, exit-status caveats, TPM recovery instructions, JSON examples, setup behavior, rate-limit accounting and claims about hardware, performance and security. The book now includes the canonical Quick Start instead of maintaining a second installation guide.

Distribution metadata checks covered source prerequisites on Arch, Debian 13, Ubuntu 26.04 and Fedora 43. The instructions distinguish native Rust versions from the pinned rustup toolchain, build dependencies from ONNX Runtime's shared library, and official Arch CPU/GPU packages from virtual package names. Nix instructions explicitly describe the current privileged-loader and model/key provisioning limitations; they do not present the module interface as a validated working installation. Model licensing refers to upstream terms and does not infer permission for personal desktop authentication.

Verification for this follow-up is deliberately documentation-focused: just check-docs, the assembled-site link/anchor/asset check, man-page lint, release-matrix documentation checks and focused offline regressions for package instruction extraction and lookup. It does not repeat the full behavioral suite below, install host authentication configuration, or claim new camera, VM, GPU or TPM results. Published alpha assets are not required for these source and documentation checks.

Published alpha.4 follow-up

The follow-up rebased the documentation onto 8fa96c9179a7d14a7c0ca1b74b686672335a8841, the commit identified by the v0.2.0-alpha.4 tag, its release manifest and the successful release run. The prerelease was published at 2026-09-06 02:07:12 UTC (September 5 in Pacific time).

  • Downloaded all nine published assets and matched their sizes and SHA256 digests with the release metadata; the eight payloads also matched MANIFEST.json
  • Matched the separately downloaded tag archive to the manifest's source digest, 28d35c51b4c0f3702291c526174e1bbb4d249daf86b017c0d652a4b30cda7053
  • Inspected both Debian control archives and file inventories: native versions use tildes, but GitHub download filenames use dots; the dotted URLs work and the tilde URLs return 404
  • Inspected the direct Fedora 44 RPM's version, dependencies, file inventory and scriptlets; it bundles ONNX Runtime, unlike the system-runtime COPR build
  • Confirmed both Debian packages contain the manifest-pinned CPU ONNX Runtime 1.20.1 under /usr/lib/facelock; the direct RPM places it under /usr/lib64/facelock
  • Ran only the released main executable's help and version in a disposable Ubuntu 24.04 container with libxkbcommon0; it reports version 0.2.0-alpha.4

The manifest itself has SHA256 b7f9fbc5df710ea3facb894e5ed4d67d2a4aaa8808261ecc15aada4dcfa7eb5e. Matching downloaded bytes to release metadata is an integrity check, not an independent reproducible-build or signing attestation. The tag is unsigned, the release is not immutable, and the direct RPM has no package signature. The downloaded files are retained locally in /tmp/facelock-alpha4-audit.6w9Zm1; that temporary directory is not shipped.

This check corrected the current release links and exact package commands in Quick Start, the README, the book and the HTML website. It also found redundant setup-then-enroll instructions in the released Debian/RPM descriptions and post-install prompts. The source package text now directs users to the setup wizard; Quick Start explains the redundant alpha.4 prompt. Published alpha.4 bytes were not replaced.

Package descriptions now use the same bounded security claims as the guides, and Arch/RPM removal prompts no longer promise that an unmodified package-owned configuration file survives removal. The lifecycle contract remains the authority for .pacsave and .rpmsave handling.

The review follow-up added pinned checksum gates before direct package installation and mdBook extraction, clarified PAM timeout and historical permission wording, and made configuration-default extraction ignore fenced examples. Official Debian/Ubuntu binary metadata and ELF inspection corrected the earlier claim that those distributions lacked ONNX Runtime packages: their published native libraries exist, but their multiarch locations and versioned SONAMEs do not satisfy the current trusted loader. The install guides distinguish that limitation from the compatible runtime bundled in Facelock's published packages.

Two requested behavioral changes remain outside this documentation audit: bootstrap setup does not forward an enrollment command's original user/label, and daemon restart does not check the D-Bus fallback's exit status. The CLI reference describes both limitations; correcting their behavior would require a separate implementation and contract change, not a documentation claim that the published binary already behaves differently.

The documentation audit and published-alpha inspection do not require stable 0.2.0 publication. Stable AUR, codenamed APT and production COPR still need a separate post-publication check of their actual packages and metadata. The live facelock-git recipe also still lacks the runtime dependency already declared in this source tree; the install guides explicitly warn about it. No host installation or camera, GPU, physical TPM or authentication retest was performed for this follow-up. Pending walkthrough execution records remain pending; refreshing their source pins does not convert them to evidence.

Earlier checkpoint: evidence identity and scope

The earlier audit checkpoint inventoried 69 instructional files, three executable targets and 77 public recipes. Its isolated workspace run passed 1,713 tests with 12 ignored hardware tests; all-target Clippy, formatting, source-archive extraction, site links, mandoc and package-lifecycle documentation checks were also exercised. These are local verification results, not release or hardware attestations. The full just check gate completed; the final documentation/safety subset was rerun after the last isolation fixes. Native Debian/RPM version-ordering checks were skipped because dpkg and rpmdev-vercmp were unavailable. Dependency audit completed with three yanked-package warnings allowed by existing policy. That checkpoint's extractor recorded 1,350 occurrences: 417 executable, 21 manual-only, 733 schematic references, 177 historical and two intentional negative examples. The catalog has 28 route/hardware definitions and 134 manual section candidates; all 438 executable/manual occurrences had pending definitions, with none unmapped. These are the counts at the preceding audit checkpoint; edits change them. Use the inventory and walkthrough report for current counts. Pending execution does not mean that the documentation assertion is unreviewed or incorrect. Definition coverage is not execution completion.

The production COPR observations used build 10489915 and these retained package hashes:

ChrootNative package versionSHA256
Fedora 43 x86_640.1.3-1.fc4366061f0d239a4ac58cd37b8f49a1189977bd34aaed7fb9ed4e6a849bcf01f2a3
Fedora 44 x86_640.1.3-1.fc44469fabc1c8678bccb48342d36107a3dd67429b0a8ac1406e742ed0d8f5884697
Fedora 45 x86_640.1.3-1.fc45521a7abbdf266bea57f1c8031912734c4968b57251ba9f9b1cec4e44f97f58b0

The tag commit 24aed886a71a3ed904f3232a1776a460f81b7d85 is an identity assertion, not independently verified binary build provenance. Matching a retained same-version cache payload does not exclude same-version repository replacement. Repository completion therefore additionally requires verified transaction-bound bytes; these container observations do not qualify.

Local, uncommitted artifacts are retained under target/docs-audit/ in the audit worktree. inventory.json, examples.json and coverage.json contain the final machine-readable inventories. evidence/apt-trixie-final, evidence/copr-43-final, evidence/copr-44-final, evidence/copr-45-final and evidence/alpha-blocked-final contain sanitized logs and JSON records. These paths are build artifacts, not files shipped in a source checkout.

The APT/Fedora 44/alpha records name harness revision 1f20684f294697584c8cea9b000edb82c7d6c201; Fedora 43/45 name 628e2b83ff303d0c1cd2b8e3a932d375d42f0139. All were clean committed harness runs at their recorded revision. Later documentation and guard changes are not retroactively credited to these records: old source pins must not be rewritten to qualify against the final inventory.

A further Fedora 44 probe at clean harness revision 8f4253f4c892719b594995286ceec6920130812c exercised the final isolation guards. Its record and logs are in evidence/copr-44-guarded. All six pristine-state observations were checked, including PAM and service assets. Validation accepts it as a container observation and correctly rejects it as walkthrough completion; transaction-byte and source-build provenance remain unestablished.

Published alpha assets are needed only to check their actual download URLs, metadata, hashes and delivered contents. Public-package instructions depend on the respective repository, and stable channels do not carry prereleases. Neither publication nor full booted/hardware walkthrough coverage is a gate for documentation-only review. Any unresolved claim should identify its specific missing evidence instead of blocking unrelated documentation work. No host PAM, desktop or device configuration is changed by the static audit.

Releasing

Current channel observation

The following is a dated 2026-09-06 observation: v0.2.0 is the latest published GitHub release. The trixie and resolute APT Release URLs return signed metadata for 0.2.0-1, main serves the same Trixie package, and legacy serves signed empty indexes. All three AUR entries serve 0.2.0-1. The production COPR serves 0.2.0-1 on Fedora 43, 44, and 45, from a build submitted by hand after the tagged release run failed to produce one. These are availability observations, not changes to the release policy below.

Versioning

Facelock uses Semantic Versioning:

  • MAJOR (1.0.0): Breaking changes to config format, database schema, D-Bus interface, or CLI flags
  • MINOR (0.2.0): New features, non-breaking config additions
  • PATCH (0.1.1): Bug fixes, documentation, dependency updates

The project is pre-1.0. The public contract is:

SurfaceWhat constitutes "breaking"
Config (/etc/facelock/config.toml)Removing or renaming keys, changing defaults that affect security
Database schemaIncompatible schema changes without migration
D-Bus interface (org.facelock.Daemon)Removing methods, changing signatures
CLI flagsRemoving subcommands or changing flag semantics
PAM behaviorChanging auth/ignore/deny semantics

Rust crate APIs are internal and not part of the versioning contract.

Prerelease identity conversions

Release input is strict SemVer: X.Y.Z or X.Y.Z-{alpha,beta,rc}.N. The same identity is converted explicitly for each package manager:

SurfaceAlpha 1Stable
Git tagv0.2.0-alpha.1v0.2.0
Cargo0.2.0-alpha.10.2.0
Debian upstream0.2.0~alpha.10.2.0
RPM Version-Release0.2.0-0.1.alpha.10.2.0-1
Arch pkgver-pkgrel0.2.0alpha1-10.2.0-1
GitHub Releaseprereleaserelease

The first alpha Debian revisions are 0.2.0~alpha.1-1~deb13u1 (trixie) and 0.2.0~alpha.1-1~ubuntu26.04.1 (resolute).

Package rebuilds advance independently of the Cargo version. Debian and Arch increment their package revision for a rebuild of the same prerelease and reset it for the next semantic prerelease. RPM uses one monotonic prerelease counter across the whole series:

Debian: 0.1.4-1 < 0.2.0~alpha.1-1 < 0.2.0~alpha.1-2 < 0.2.0~alpha.2-1 < 0.2.0~beta.1-1 < 0.2.0~rc.1-1 < 0.2.0-1
RPM:    0.1.4-1 < 0.2.0-0.1.alpha.1 < 0.2.0-0.2.alpha.1 < 0.2.0-0.3.alpha.2 < 0.2.0-0.4.beta.1 < 0.2.0-0.5.rc.1 < 0.2.0-1
Arch:   0.1.4-1 < 0.2.0alpha1-1 < 0.2.0alpha1-2 < 0.2.0alpha2-1 < 0.2.0beta1-1 < 0.2.0rc1-1 < 0.2.0-1

scripts/release-versions.sh is the executable conversion contract. Repeating the same prerelease is a package rebuild; repeating a stable version is rejected because Debian/Arch would otherwise advance while RPM remains at release 1. Semantic version regressions are rejected before any file is edited. just test-release-matrix verifies the exact order with native dpkg --compare-versions, rpmdev-vercmp, and vercmp in disposable, digest-pinned containers.

How to Release

just release 0.2.0
# or
just release 0.2.0-alpha.1

This will:

  1. Convert and bump Cargo, Arch tag/pkgver/pkgrel, RPM Version/Release, and Debian upstream/revision metadata
  2. Run cargo check --workspace to verify the version bump compiles
  3. Preserve package rebuild ordering, including the monotonic RPM prerelease counter
  4. Prompt you to update CHANGELOG.md (add entries under the new version heading)
  5. Print the git commit / git tag / git push commands for you to run

Then push the tag to trigger the release workflow:

git push origin main --tags

What happens on tag push

The .github/workflows/release.yml workflow:

  1. Validates the tag against the checked-in release identity and target matrix
  2. Builds release binaries and uploads them as workflow artifacts
  3. Prepares the pinned ONNX Runtime and lock-bound Cargo-vendor source components with their reviewed manifests and checksums
  4. Builds two suite-specific TPM-enabled .deb packages for trixie and resolute
  5. Builds the direct .rpm package in the pinned Fedora 44 container and validates contents
  6. Validates the unlocked Nix flake evaluation (network inputs are resolved because dist/nix has no checked-in flake.lock)
  7. For a stable tag, builds the signed codenamed APT repository plus the main and legacy compatibility suites until 0.3.0; missing APT signing secrets fail here, before the GitHub Release is public
  8. Verifies the tag, assembles and validates every asset, writes MANIFEST.json, and publishes the release exactly once
  9. After a stable GitHub Release is public, attempts to publish facelock, facelock-bin, and facelock-git to AUR; a missing AUR_SSH_KEY prints a skip notice and exits successfully, while an invalid configured key fails after publication
  10. For a stable tag, triggers the GitHub Pages rebuild that deploys the updated APT repo

Validated prerelease tags set the GitHub Release prerelease output and upload direct artifacts, but skip stable APT and all AUR publication. The workflow guards use the validated release identity rather than substring matching.

COPR (Fedora) is not built by release.yml. It is handled by Packit, which reacts to the release the publish job makes public in step 8. A draft raises no release event, so nothing downstream fires until validation passes. The workflow does watch: a stable run's verify-copr job polls the public COPR API for the released EVR and fails the run if it never appears. It builds nothing and can undo nothing — the release is already public by then — it only makes a failed Packit submission loud on the day it happens. See the COPR section below.

Builders build, publish publishes

No builder writes to the release. Each one uploads a workflow artifact and a digest attestation naming what it produced, the image it produced it in, and the components it consumed. Until the publish job runs, the tag has no release at all: nothing is public and no downstream automation has seen anything.

That split is the point. Every builder compiles or packages code this project does not own, from every dependency's build.rs to rpmbuild and dpkg-buildpackage. A builder holding the publication credential is a builder that can publish whatever it likes. publish compiles nothing, so it is the only job that holds RELEASE_PAT and the only one with contents: write; every other job holds contents: read, and the workflow's own default is deny-all.

publish runs after every builder and validator, and it:

  • verifies the tag exists, names the validated version, and points at the built commit; where the tag carries a signature it must verify. The job reads the tag and never creates, moves, or replaces one.
  • stages exactly the canonical assets out of the builders' artifacts. The allowlist is derived from the validated version, Debian revision, and RPM counter, so an artifact built from another identity has no canonical name and a file a builder added beside the one it was asked to produce is never staged.
  • holds every staged asset to the SHA-256 its builder attested. An asset that changed between its build and publication stops the release, as does one no builder attested or one two builders claim.
  • holds each attestation to the provenance its slot may declare: the suite, the image dist/release-matrix.json pins, and the component names. A builder cannot report another image or an extra component into MANIFEST.json; a matrix the job cannot read stops the release instead of shortening the allowlist.
  • trusts an attestation only once it hashes to the job output its builder recorded. Artifacts are shared, writable storage for every job in the run; a job output belongs to the job that wrote it. An attestation that was replaced after its job finished, or whose job recorded no output, is refused by name.
  • creates the release as a draft carrying those assets, then writes MANIFEST.json over them, plus the source tarball digest, the pinned build-image digests, and the reviewed ONNX Runtime and Cargo-vendor component digests. It replaces the three-binary SHA256SUMS file, which covered a fraction of the release and was written before most of it existed.
  • reads the draft back from the API, holds it to the allowlist a last time, holds each published asset's size, and digest where the API exposes one, to MANIFEST.json and the uploaded manifest to the file it wrote, and flips the draft to published once. A tag whose release is already published is refused before anything is written, so re-running the workflow after a failure is safe; re-running it after success goes red by design, at verify-creatable, with nothing written. One case needs a hand: if the Debian revision or RPM counter changed between runs, the draft still carries the asset built under the old name, and the readback refuses it. The failure names the file and the command that removes it, gh release delete-asset.

The workflow runs once at a time per tag (concurrency keyed by the ref, never cancelling the run in progress), so a re-run started while a run is inside publish queues behind it instead of racing it. Two drafts for one tag, which only a race could leave behind, are refused with the gh api --method DELETE command that removes the extra one.

A builder's extra output fails the release closed: a canonically named file in an artifact the allowlist does not expect it from is refused at staging, and the failure says so. Re-running only the failed publish job keeps that artifact, so the remedy is fixing the builder and re-running all jobs. A partial re-run of a single build-deb leg can similarly leave the other suite's attestation unbound (attestation deb-<suite> is not bound to a job output); the remedy is the same, re-run all jobs.

Two consequences for the maintainer:

  • RELEASE_PAT is required. It is now the only credential that can write the release, so an unset secret fails the publish job. just release-preflight checks for it, and fails when it cannot check: without gh, or unauthenticated, the check is reported as unchecked and preflight does not pass.
  • A signed tag must be verifiable on the runner. Importing the maintainer's public key is release infrastructure tracked by #235; until it lands, an unsigned tag is accepted and a signed one that the runner cannot verify stops the release.

The publication prerequisites include build-nix: its flake evaluation must pass, while its nix build step is advisory. No Nix lockfile is checked in, so evaluation and builds depend on the currently resolved flake inputs. An evaluation failure blocks the publish job before it creates a draft in that run; fix the flake and re-run the failed jobs, never tag again.

test/release-artifacts-contract.sh (just test-release-artifacts) proves this shape by fixture and by mutation. The workflow itself runs only on a tag, so the gate never tags anything to test it.

Debian package channels

ChannelBuild envRust toolchainTPMVersion suffix
trixieDebian 13official Trixie Backports cargo and rustcYesX.Y.Z-1~deb13u1
resoluteUbuntu 26.04native distro cargo and rustcYesX.Y.Z-1~ubuntu26.04.1

Debian-family release support is exactly Debian 13 (Trixie) and Ubuntu 26.04 LTS (Resolute). Both codenamed suites ship one binary package named facelock with TPM support enabled. No rustup toolchain participates in Debian source builds. Bookworm and Noble artifacts may remain in historical releases, but those suites are unsupported and receive no new packages. Trixie package builds use the official Trixie Backports cargo and rustc; Resolute package builds use the native Ubuntu toolchain.

Both .deb packages are uploaded to the GitHub Release for direct download. Stable packages are published under the matching codename at https://tysmith.me/facelock/apt/.

Each Debian source package consists of the exact tagged main upstream tarball, the reviewed ORT component, the deterministic Cargo-vendor component, and the Debian quilt delta. Complete .dsc rebuilds run with network denied and empty Cargo/Rustup caches. Stable APT publication consumes exactly two suite manifests, one for Trixie and one for Resolute, before signing or writing the repository.

Each suite manifest contains exactly eight artifacts in this order: the main orig tarball, ORT orig component, Cargo-vendor orig component, Debian quilt delta, .dsc, .buildinfo, .deb, and .changes. The Cargo component carries a generated legal inventory covering every exact lock-bound crate; its specific DEP-5 stanza precedes the Facelock source catch-all. CI prepares that component with Rust 1.95.0 through the immutable dtolnay/rust-toolchain action commit 4360b52568e2003a75bf9bc1d59f33a8e3fc893c, matching the repository's pinned 1.95 toolchain channel.

Every built .deb passes .github/workflows/scripts/validate-deb.sh in the suite container before staging: package identity, forbidden transition fields, generated dependencies, the required file set, the hash-verified ORT bundle, and a lintian run that fails on error-severity tags. Deliberate deviations are suppressed in that script, each with a recorded reason; warnings are printed for review but do not gate.

Supported release matrix

dist/release-matrix.json is the checked-in authority. The release workflow, APT configuration, Packit targets, and this table are checked against it.

PlatformArchitecturePackaging/channelRuntimeSupport tierRelease targetLifecycle depth
Debian 13 trixieamd64one facelock package; TPM required; staged APT/direct debbundled ORT 1.20.1supportedyesfull
Ubuntu 26.04 LTSamd64one facelock package; TPM required; staged APT/direct debbundled ORT 1.20.1supportedyesfull
Fedora 43x86_64staging COPRsystem ORTsupportedyesfull through the 2026-12-02 EOL gate
Fedora 44x86_64staging COPRsystem ORTsupportedyesfull
Fedora 45 branchedx86_64staging COPRsystem ORTsupportedyesrequired build/runtime smoke
Fedora Rawhide (Fedora 46 development)x86_64optional experimental production COPR chrootsystem ORTexperimentalnobest-effort pinned Track D smoke only
Fedora 44x86_64direct RPMbundled ORT 1.20.1supportedyesfull
Arch Linux Archive snapshot 2026-08-18x86_64PKGBUILD and binary recipesystem ORTsupportedyesfull

Production COPR requires Fedora 43, Fedora 44, and Fedora 45. Rawhide is the only optional allowed experimental production chroot, so it may be present or absent; missing any required chroot or enabling any unknown extra 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 release target and is not a Packit staging or production release target. Both fedora-rawhide and fedora-rawhide-x86_64 fail validation, and no alpha may publish to Rawhide.

Fedora 43 and Fedora 44 carry the full lifecycle. Fedora 45 carries required build/runtime smoke. Rawhide remains best-effort pinned Track D smoke only; a Rawhide-only failure is not alpha-blocking, and Rawhide cannot supply lifecycle, artifact, upgrade, rollback, served-version, or availability evidence. Promotion requires a separately reviewed amendment and full Fedora gates. Issue #236 owns the pre-tag and post-publication proof that optional Rawhide serves no alpha or candidate build.

Container identities are pinned by registry/index digest, with the linux/amd64 manifest digest retained where the registry exposes both. They were resolved from Docker Hub registry metadata and Fedora registry Docker-Content-Digest on 2026-08-18. The Arch repository identity is pinned separately to https://archive.archlinux.org/repos/2026/08/18/; every matrix-associated CI and AUR pacman invocation installs that exact mirror before refreshing package metadata.

Local distro validation

Before releasing, validate packages build and install correctly on each target:

# Automated (no camera needed)
just test-arch-pam       # Arch container PAM smoke tests
just test-rpm            # Fedora — validate file layout from manual install
just test-deb            # delegate to both exact supported-suite package gates
just test-deb-trixie-pkg    # Debian 13 — offline source rebuild, install, TPM, lifecycle
just test-deb-resolute-pkg  # Ubuntu 26.04 — the same complete package gate
just test-rpm-pkg        # Fedora — build real .rpm, install via dnf, validate
just test-rpm-lanes      # every declared Fedora target at its declared depth
just test-rpm-authselect # Fedora — retired-profile upgrade guard lifecycle
just test-packit-config  # Packit config schema — real `packit` in a pinned Fedora container
just test-copr           # COPR-equivalent build only — Packit SRPM + mock from-source rebuild (slow)
just test-copr-pkg 43    # the same rebuild, then install it and run the booted lifecycle
just test-copr-lanes     # every Packit/COPR target rebuilt from source at its declared depth

# Interactive (requires camera)
just test-deb-dev-shell      # Ubuntu .deb with host models — fast iteration
just test-rpm-dev-shell      # Fedora .rpm with host models — fast iteration
just test-deb-release-shell  # Ubuntu .deb clean room — real user experience
just test-rpm-release-shell  # Fedora .rpm clean room — real user experience

The test-rpm recipe validates file layout from manually installed binaries. test-deb delegates to both supported-suite *-pkg recipes. The *-pkg recipes build real packages using the same scripts as CI, install them with the actual package manager (dnf / dpkg), and validate the result — testing postinst scripts, dependency resolution, ORT bundling, tmpfiles triggers, and the full install path.

The *-dev-shell recipes mount host models for fast interactive camera testing. The *-release-shell recipes start from a clean package install with nothing from the host — run facelock setup to download models, then enroll and test.

Fedora lanes

Every Fedora recipe takes a release — just test-rpm-pkg 43, just test-copr 45 — and defaults to 44. just test-rpm-lanes runs each declared release target at the lifecycle depth dist/release-matrix.json gives it: full lifecycle for Fedora 43 and 44, build plus runtime smoke for branched Fedora 45. Rawhide is optional and experimental, has no lane, and can never stand in for a Fedora 43, 44, or 45 result.

Each Fedora target needs two lanes, not one. test-rpm-lanes proves the direct .rpm: host-built binaries, bundled ONNX Runtime. That is not the delivery path the matrix declares for Fedora, which is Packit publishing to COPR against Fedora's system ONNX Runtime. just test-copr-lanes proves that one at the same declared depths — test-copr-pkg 43, test-copr-pkg 44, test-copr-smoke 45. Each rebuilds the package from source in a mock chroot (the test-copr half), exports the RPM it built, installs it with dnf so the package's own Requires: onnxruntime resolves, and boots it for the same validation the direct lane runs. just test-packaging-matrix requires both, and test/packaging-evidence.py refuses a direct-RPM record offered as a COPR target's evidence.

The COPR lanes are slow even by this file's standards: each one compiles the whole workspace and runs the spec's %check inside the mock chroot, and mock needs a privileged container, so they run serially through a single staging path (target/copr-lane/facelock.rpm). Never run two at once.

test/fedora-lane-image.sh resolves each lane's digest-pinned base image from the matrix, so no Containerfile carries its own Fedora digest. It refuses a release the matrix does not declare and refuses one that has reached its EOL gate: Fedora 43 goes EOL on 2026-12-02, and from that date the Fedora 43 lane stops with a message instead of quietly testing an unmaintained release. Set RELEASE_MATRIX_TODAY to rehearse that date, the same override test/check-release-matrix.py reads. Retiring the lane means retiring its matrix rows, and moving the date is a deliberate matrix edit.

Fedora 43 is the only release carrying a gate today. The lookup is generic on fedora.<release>_eol_gate, so adding a 44_eol_gate or 45_eol_gate key gates those lanes immediately; until one exists, 44 and 45 run past their own end of life without complaint.

just test-rpm-lanes runs each release through the recipe its matrix lifecycle_depth names, and test/check-release-matrix.py requires that exact pairing, so a full lifecycle lane cannot be quietly downgraded to a smoke lane.

The full lifecycle lane also pins %config(noreplace): an unmodified /etc/facelock/config.toml is replaced in place on upgrade, a modified one survives byte for byte with the new file diverted to .rpmnew, and erase removes an unmodified copy outright while retaining a modified one as .rpmsave. docs/contracts.md carries the same contract.

The RPM embeds a read-only retired-profile upgrade guard in %pre. The model-free test-rpm-authselect gate boots Fedora with systemd and exercises real authselect and PAM password success/failure across fresh, unselected, selected-retired, custom-profile, malformed-state, and authselect-absent transactions. It never changes the host PAM stack. The exact retired facelock selection blocks with manual backup-and-reselection guidance; ordinary selections are preserved and the new RPM ships no authselect profile or dependency.

An already-installed v0.1.4 RPM cannot be retroactively guarded: direct uninstall runs only the scriptlets already installed from v0.1.4. Users must install a guarded release before a later uninstall so the guarded upgrade can retire the old authselect payload first.

Packaging gates in CI

.github/workflows/packaging.yml runs the lanes above in CI: both Debian suite gates, every declared Fedora lane, the Arch package built from the real dist/PKGBUILD, and the native version-ordering matrix. It downloads and checksum-verifies the ONNX models first, through .github/actions/fetch-models, so the daemon-start assertions execute instead of being counted as skipped.

Three schedules, because the full matrix takes about 1 h 45 min (measured 2026-09-02) and most pull requests touch no packaging:

WhenLanesFiltered
Pull requestall but copryes, per lane, only the lanes the diff reaches
Nightly, 07:00 UTCallno
just release-preflightevidence of a green run at HEADno

The copr job never runs on a pull request regardless of the filter above -- mock needs a privileged container.

The pull-request filter is a changes job running .github/workflows/scripts/classify-changes.sh, which classifies the merge-base diff in plain bash and emits one output per lane. Each job gates on its own output -- if: needs.changes.outputs.deb == 'true' for the Debian suites, rpm for Fedora, arch for the Arch package, release_binaries for the Arch-container build the Fedora lanes stage from, release_matrix for the version-ordering matrix -- which reports a real "skipped" conclusion; GitHub's own paths: filter would leave a required check pending forever instead.

A path only one family's recipe or harness reads selects that family's lane. Everything else that reaches a package selects every lane; when in doubt, every lane.

Changed pathLanes
debian/, dist/apt/, test/*deb*, test/*apt*, .github/workflows/scripts/*deb*deb
dist/facelock.spec, dist/rpm/, .packit.yaml, test/Containerfile.{fedora,copr*,rpm*,packit}, test/*rpm*, test/*copr*, test/fedora-lane-image.sh, .github/workflows/scripts/*rpm*rpm, release_binaries
dist/PKGBUILD*, dist/facelock.install, dist/facelock-pam-remove.hook, test/*arch*, .github/workflows/scripts/*aur*arch
test/release-*release_matrix
the rest of dist/, systemd/, dbus/, config/, scripts/, justfile, Cargo.toml, Cargo.lock, crates/*/Cargo.toml, test/Containerfile*, test/*pkg*, the shared PAM/polkit/TPM validators, .github/workflows/packaging.yml, .github/workflows/release.yml, the other workflow scripts, .github/actions/all
crates/facelock-cli/src/commands/pam.rs, commands/daemon.rs, lifecycle.rsall
any other file under crates/release_binaries

Any package lane also selects release_matrix, since the versions it orders live in debian/changelog, the spec and the PKGBUILD; rpm also selects release_binaries, which it stages from. The three Rust files are listed because facelock pam remove --all runs from %preun, from Arch's pre_remove and from Debian's prerm, so a change to that command can abort a package removal without touching a packaging file. ci.yml and the other non-packaging workflows select nothing; a container digest bump inside packaging.yml itself still selects every lane, because a path cannot say which job's image moved. just test-classify-changes pins the table.

Residual risk. A Rust change outside those three files runs only the release_binaries lane on its own pull request: just build-release in the pinned Arch container, which proves the workspace still compiles the way the packages consume it, in minutes. It does not build or boot a package. A Rust change to daemon startup, a new runtime dependency, a file the spec does not ship: each of those leaves its own pull request green with the deb, rpm and Arch lifecycle jobs reported as skipped. Do not read that as packaging-verified. The nightly matrix catches it within a day, and the release gate below catches it before anything ships. When a change is packaging-relevant in a way the filter cannot see, run the lane by hand or add the path to classify-changes.sh.

The COPR jobs go further and skip pull requests entirely. Each one compiles the workspace inside a mock chroot, which needs a privileged container, and no pull request has been shown to get one on a rootless-podman runner; making that a required check before it is proven would block every packaging merge on an unproven capability. So a COPR-only break — something that shows up when the package is rebuilt from source or run against Fedora's system ONNX Runtime, and not otherwise — survives its own pull request even when the filter fires. The nightly and the pre-release workflow_dispatch are unfiltered and do run them; locally, just test-copr-lanes.

The Debian lanes skip the .dsc rebuild on pull requests. The full gate compiles the workspace twice per suite: once to assemble the candidate .deb, once more from the extracted .dsc in a clean image to prove the source package rebuilds standalone. Both are lto = true release builds and no cache applies, so the rebuild is about ten of the lane's twenty-six minutes on a runner that has reached the 90-minute cap (#337). packaging.yml sets FACELOCK_DEB_SKIP_DSC_REBUILD=1 on pull requests, which drops that second compile and nothing else: the .deb is still built from source with the network denied, its dependency closure still proved, the booted lifecycle still run. A lane that skipped the rebuild records depth=partial, which the release matrix requires of nothing, so just release-preflight refuses it -- it refuses pull-request runs regardless. The nightly, the dispatch, and a local just test-deb or just test-packaging-matrix keep the rebuild. What survives a pull request, then, is an incomplete source package: a file the .dsc does not carry, or a build that only works from the Git checkout. The nightly catches it within a day, the release gate before anything ships.

Run this before creating/pushing a release tag:

just test-arch-loopback               # the same tiers on a synthetic camera; records the commit
just test-arch-camera-required        # or: camera + a person in frame; records the commit
gh workflow run packaging.yml --ref main   # the packaging matrix, at this commit
just release-preflight                # stable release checks
just release-preflight v0.2.0-rc.1   # prerelease checks; no stable secret access
just check
just test-arch-pam
just test-arch-camera-free

The end-to-end tiers come first because preflight cannot run them. It refuses to pass until one of two records names HEAD, so run whichever you choose after the last commit that will ship, not before:

  • just test-arch-loopback runs test-arch-integration and test-arch-oneshot against a v4l2loopback node fed with a procedurally rendered face, with require_ir and require_frame_variance on, and writes the commit to .loopback-tier-verified. No camera, no person, a few minutes. It proves the capture, IR classification, liveness, enrollment, daemon, one-shot and PAM paths end to end; it cannot prove that a real sensor's frames match a real face. The loopback nodes it needs and the modprobe line are in Testing Safety.
  • just test-arch-camera-required runs the same two tiers against /dev/video* with a person in frame and writes the commit to .hardware-tiers-verified. It is the only run that proves real-sensor recognition of a real face.

Either satisfies the gate (test/e2e-tier-evidence.sh). Those two tiers are the only automated evidence that face authentication works end to end: real D-Bus activation, the real PAM stack, real capture, and the one-shot path PAM falls back to. Nothing else ran them, and three of their assertions rotted undetected as a result (#139). A run done by hand at this exact commit is acknowledged by naming it: FACELOCK_LOOPBACK_TIER_ACK=<sha> or FACELOCK_HARDWARE_TIERS_ACK=<sha> on the just release-preflight command.

Preflight also refuses to pass without complete packaging evidence for HEAD. Every packaging lane writes a record of what it claimed and what it counted, and test/packaging-evidence.py accepts the set only when every lane the release matrix requires is present at this commit with zero skips and the ONNX models on hand (the contract is in docs/contracts.md, "Packaging matrix evidence"). That set includes a COPR lane per Packit release target beside the direct-RPM lane, so a green Fedora .rpm result alone leaves the evidence incomplete. Preflight reads it from the packaging-evidence-* artifacts a successful packaging.yml run at that exact commit uploaded, fetched with gh run download, or from .packaging-matrix-verified, which just test-packaging-matrix writes after running every lane locally. A run's green conclusion alone is not evidence: a path-filtered pull-request run skips every lane and still concludes "success". A pull-request run cannot satisfy it either: it builds the merge commit, not the commit being released. A FACELOCK_ALLOW_MISSING_MODELS=1 run is a diagnostic: it writes its partial lane records, and the marker is withheld. The one-line commit marker from before 0.2.0 is refused with a message naming the new format. A nightly run does not satisfy it either: nightly builds whatever main was at 07:00 UTC, and a release commit is a version bump nobody has built a package from yet.

just release-preflight checks local tools, required packaging files (including .packit.yaml), and whether AUR_SSH_KEY, APT_GPG_PRIVATE_KEY, and APT_GPG_PASSPHRASE are configured in GitHub secrets (via gh). COPR needs no secret — it is driven by Packit. Preflight and CI also read the public production COPR API and require its enabled chroots to equal the checked-in authority: Fedora 43/44/45 are required and Rawhide is the only optional experimental chroot. Rawhide may be present or absent; a missing required chroot or any unknown extra is release-blocking drift. Preflight goes one step further than CI and asks what production COPR actually serves: the EVR of the predecessor pinned in dist/release-matrix.json. The checker never modifies the project. Preflight always runs packit config validate --offline against .packit.yaml, in the digest-pinned Fedora container built from test/Containerfile.packit — the same real schema gate just test-copr runs, reachable without a host packit install. It has no skip path: podman is a preflight prerequisite, and without it the gate fails rather than passing unrun. just test-packit-config runs the same gate on its own.

Preflight also holds the APT compatibility window: main and legacy compatibility suites present until 0.3.0, as dist/release-matrix.json declares, and absent from the first 0.3.0 tree on. just test-apt-repo proves the published shape when run, as a clean APT client; no workflow runs it.

Package repository setup (one-time)

AUR (Arch Linux)

Automated after setup. Every stable release run attempts AUR publication after the GitHub Release becomes public. If AUR_SSH_KEY is absent, the publisher prints a skip notice and exits successfully; an invalid configured key fails after publication and needs manual recovery. Release operators must therefore verify the live AUR packages rather than infer publication from a green job.

One-time setup (~10 minutes):

  1. Create an AUR account at https://aur.archlinux.org/register

  2. Add your SSH public key to your AUR account at https://aur.archlinux.org/account

  3. Register the package names. CI's publish-aur.sh will create any of these on first push if they don't already exist, but you can also pre-register them manually:

    REPO_ROOT="$(pwd)"
    
    # facelock (source build — default for `yay -S facelock`)
    git clone ssh://aur@aur.archlinux.org/facelock.git aur-facelock
    cd aur-facelock
    cp "$REPO_ROOT/dist/PKGBUILD" .
    cp "$REPO_ROOT/dist/facelock.install" .
    # dist/PKGBUILD ships a __SRC_SHA256__ placeholder; substitute the real
    # tarball digest before pushing or the recipe refuses to build. Download
    # first, hash the file after: piping curl into sha256sum hashes empty
    # input when the download fails, and that digest must never be published.
    TAG="$(sed -n 's/^_tag=//p' PKGBUILD)"
    curl -fSsL -o "/tmp/facelock-v${TAG}.tar.gz" \
      "https://github.com/tyvsmith/facelock/archive/v${TAG}.tar.gz" &&
      SUM="$(sha256sum "/tmp/facelock-v${TAG}.tar.gz" | cut -d' ' -f1)" &&
      sed -i "s/__SRC_SHA256__/${SUM}/" PKGBUILD
    makepkg --printsrcinfo > .SRCINFO
    git add PKGBUILD facelock.install .SRCINFO
    git commit -m "Initial commit"
    git push
    cd ..
    
    # facelock-bin (prebuilt binaries from the GitHub Release — no cargo build)
    git clone ssh://aur@aur.archlinux.org/facelock-bin.git aur-facelock-bin
    cd aur-facelock-bin
    cp "$REPO_ROOT/dist/PKGBUILD-bin" PKGBUILD
    cp "$REPO_ROOT/dist/facelock.install" .
    makepkg --printsrcinfo > .SRCINFO
    git add PKGBUILD facelock.install .SRCINFO
    git commit -m "Initial commit"
    git push
    cd ..
    
    # facelock-git (VCS package tracking main)
    git clone ssh://aur@aur.archlinux.org/facelock-git.git aur-facelock-git
    cd aur-facelock-git
    cp "$REPO_ROOT/dist/PKGBUILD-git" PKGBUILD
    cp "$REPO_ROOT/dist/facelock.install" .
    makepkg --printsrcinfo > .SRCINFO
    git add PKGBUILD facelock.install .SRCINFO
    git commit -m "Initial commit"
    git push
    
  4. Generate an SSH key for CI and add the public key to your AUR account:

    ssh-keygen -t ed25519 -f aur-deploy-key -N ""
    
  5. Add the private key as a GitHub repository secret named AUR_SSH_KEY:

    gh secret set AUR_SSH_KEY < aur-deploy-key
    

    Or use the web UI: https://github.com/tyvsmith/facelock/settings/secrets/actions

After this, every non-prerelease tag push automatically updates the AUR package.

COPR (Fedora)

Packit reads .packit.yaml from the released tag. Packit's documented upstream_tag_exclude filtering applies to downstream synchronization jobs, not to copr_build, so it is not a prerelease safety boundary.

The production tyvsmith/facelock job sits at trigger: release, restored by hand for the stable release. A prerelease-capable configuration parks it back at trigger: ignore, which makes an alpha-tagged config structurally incapable of selecting a release-triggered production project. just release-preflight rejects a production release job for a prerelease and rejects its absence for a stable, so the trigger moves with the version instead of drifting away from it. The deliberate stable restoration targets fedora-43-x86_64, fedora-44-x86_64, and the separate fedora-45-x86_64 branched target. Rawhide is Fedora 46 development in this matrix, not an alias for Fedora 45, and not a staging or production Packit target. A configuration that targets Rawhide for release fails the matrix check.

.packit.yaml deliberately uses JSON syntax, which is a valid YAML subset. Release guards therefore parse its jobs semantically with the Python standard library instead of comparing YAML spelling; general YAML outside that subset fails closed. The production project is tyvsmith/facelock; the prerelease staging project is tyvsmith/facelock-testing, covered below. Issue #236 still owns the remaining staging infrastructure, so changing the project's chroots or permissions belongs there, not to a release-identity change.

The COPR RPM is built from source with the spec's default %bcond_with bundled_ort mode and does not bundle ONNX Runtime. Its BuildRequires/Requires: onnxruntime use Fedora's runtime-only package; the package check asserts onnxruntime-devel is absent and creates a real ORT session from the checksum-pinned minimal model in test/fixtures/. (The ort crate feature api-20 keeps the binary compatible with Fedora's runtime.)

One-time setup (~10 minutes):

  1. Create a Fedora Account at https://accounts.fedoraproject.org
  2. Log in to COPR at https://copr.fedorainfracloud.org and ensure the tyvsmith/facelock project exists with the fedora-43-x86_64, fedora-44-x86_64, and fedora-45-x86_64 chroots enabled. The optional fedora-rawhide-x86_64 experimental chroot may be enabled or absent; no other chroot is allowed (Settings → Chroots).
  3. Install the Packit-as-a-Service GitHub App on the repository: https://github.com/marketplace/packit-as-a-service
  4. In the COPR project → Settings → Permissions, grant the packit user admin permission, and in the "allowed forge projects" field add github.com/tyvsmith/facelock. Builder permission is enough to build and not enough to edit the project, which is a distinction Packit makes for you: it reconciles the project against .packit.yaml before submitting anything, and a reconciliation it is not allowed to perform aborts every target. See "Why v0.1.4 never reached COPR" below.
  5. In the COPR project → Settings, enable "Enable internet access during builds". The RPM is built from source and cargo fetches crates from crates.io during %build; COPR's build chroot is network-isolated by default, so this toggle is required or the build fails resolving crates. The toggle is half of it: COPR takes network access per build, and a Packit submission carries its own value. .packit.yaml must also declare enable_net: true on every copr_build job, because Packit's default is false and it wins over the project. See "Why v0.2.0 built nothing in COPR" below.

Step 4's allowlist and step 5's toggle apply to both channels and are checked on every pull request by python3 test/check-live-release-channels.py, which reads them off the public project response. Step 5's .packit.yaml half is not in that response and is checked by test/check-release-matrix.py instead. The permission grant is not public, so it stays a hand-confirmed step -- and it is the one that failed silently for v0.1.4.

Verify the COPR build locally before relying on it with just test-copr, which reproduces the Packit SRPM + mock from-source rebuild on a Fedora chroot and checks that the payload has no bundle while its dependencies select Fedora ORT.

Only a stable-tagged config with the deliberately restored production release trigger can populate production COPR automatically. A prerelease tag never points at production; staging below is where a candidate gets built.

Why v0.1.4 never reached COPR

Packit was installed, the release trigger was correct, and the release event reached it. Every target still failed at submission with Copr project update failed for 'tyvsmith/facelock' project., thirteen seconds after publication (#333).

Packit reconciles the COPR project against .packit.yaml before it submits anything, and it edits the project whenever the config's targets are not already a subset of the project's enabled chroots. The v0.1.4 config listed fedora-42-x86_64, which the project had never enabled and which COPR no longer offers. Editing a COPR project requires admin, and until this section was written the setup steps above asked for builder. One chroot the project did not have cost all three builds. v0.1.3 failed identically and reached COPR only because it was submitted by hand fourteen minutes later.

Two rules follow, and both are enforced:

  • .packit.yaml targets must be a subset of the project's enabled chroots. The release matrix binds fedora.packit_release_targets to copr_channels.production.required_supported_chroots, and test/check-live-release-channels.py requires the live project to enable every one of them, so a target the project lacks fails before the tag.
  • A COPR build that never lands must fail something. Nothing in the release run can observe Packit's submission — it happens outside the run, after publication — so the release workflow's verify-copr job polls the public COPR API for the released EVR and fails the run when it never appears. Before this existed, the chroot comparison passed on every day of the three months COPR served 0.1.3.

Packit's default fix-spec-file action rewrites Release: 1%{?dist} to 1.{timestamp}.{ref}, which would publish a v0.2.0 release as facelock-0.2.0-1.20260904220135575676.v0.2.0 rather than the facelock-0.2.0-1 the conversion table promises. The production copr_build job therefore carries update_release: false, and production's served comparison is an equality.

The mismatch would not stay cosmetic. RPM ranks the suffixed build above the canonical one:

0.2.0-1.fc44 < 0.2.0-1.20260904220135575676.master.0.g7d9ffe7.fc44

So on a machine with both the COPR repo and a directly installed RPM of the same release, COPR wins every dnf update and the version the other channels ship never takes hold. Packit's own documentation warns that an inherited release suffix breaks NVR ordering. Pinning the release is what keeps every channel's 0.2.0 the same 0.2.0.

Staging keeps the default. It builds every pull request into one project, so its NVRs have to differ from each other, and the snapshot suffix is what makes them; its comparison ends at the boundary dot instead, accepting 0.2.0-1 and 0.2.0-1.<anything> while still refusing 0.2.0-11. The flag and the comparison are one contract in test/check-release-matrix.py: neither channel can change one without the other, because either half alone reds every stable release.

What is proven, and what is not. The schema accepts the per-job flag (just test-packit-config), Packit's own config parser resolves it to False for the production job while staging stays True, and packit srpm with the flag set produces facelock-0.2.0-1.fc44.src.rpm against facelock-0.2.0-1.20260904220135575676.master.0.g7d8eef8.fc44.src.rpm without it. What no local check can reach is packit-service applying the job's flag on a real release event: just test-copr builds its SRPM through the CLI, which reads package-level config, so that lane still carries the snapshot suffix and proves the package builds rather than the EVR it will be published under. The first stable tag after this change is the proof. If that release publishes a suffixed EVR anyway, the service ignored the job-level flag and verify-copr fails on an otherwise healthy build. The response is to delete update_release from the production job and set its served_evr_exact back to false, taking the suffix on both channels again.

Do not hoist update_release to the top level to force it. Top level reaches staging too, and staging builds every pull request into one project: without the snapshot suffix two pull requests produce the same NVR. Scoping the flag to the production job is the whole reason production and staging can differ here, so the fallback for a flag the service ignores is to stop asking for the canonical EVR, not to ask for it somewhere that breaks the other channel.

just release-preflight asks the same question about the previous release: test/check-live-release-channels.py --expect-predecessor requires production COPR to serve the EVR pinned in predecessors. The v0.1.4 build was never backfilled — 0.2.0 supersedes it — so that gap is recorded in copr_channels.production.served_evr_gap and reported rather than failed. The record names both EVRs and issue #333, and it retires itself: once the predecessor pin moves past v0.1.4 the release matrix contract fails until the record is deleted.

A recovery after a failed submission is a hand-submitted build from a checkout of the tag. Packit reacts only to new release events, so re-publishing is not an option:

git checkout vX.Y.Z
packit srpm --no-update-release
copr-cli build tyvsmith/facelock facelock-X.Y.Z-1.*.src.rpm \
    -r fedora-43-x86_64 -r fedora-44-x86_64 -r fedora-45-x86_64

--no-update-release is not optional. The Packit CLI reads package-level config, not a job's, so it would otherwise apply the snapshot suffix the production job pins off and hand production an EVR its own gate refuses. packit build in-copr has no equivalent flag, which is why the recovery goes through copr-cli with an SRPM built locally. Name the three supported chroots: copr-cli build with no -r builds every chroot the project has enabled, which picks up the optional Rawhide one. This path needs a COPR API token in ~/.config/copr, which the Packit CLI did not.

Why v0.2.0 built nothing in COPR

v0.2.0 fixed everything v0.1.4 got wrong. Packit triggered on the release event, reconciled the project, and submitted all three chroots under the canonical 0.2.0-1. Every one of them then failed in %build, four minutes in, on Could not resolve host: index.crates.io.

enable_net is a per-build value, not a project one. The project toggle is the default COPR applies to a build that does not carry its own, and a Packit submission always carries its own: Packit's enable_net defaults to false and is sent on every build it creates. So the toggle was on, the build task read 'enable_net': False, and the chroot had no resolver while cargo needed crates.io. v0.1.3 is the control -- same spec, same project, and it built, because a human submitted it with copr-cli, which omits the field and lets the project default stand.

Three things had to be true at once for this to reach production, and each is now closed:

  • The gate read the wrong layer. test/check-live-release-channels.py compares the public project response, which is the only place enable_net appears before a build exists, and the value there was correct. test/check-release-matrix.py now requires enable_net: true on every copr_build job in .packit.yaml, which is the layer that decides.
  • The local lane hardcoded the answer. just test-copr builds the Packit SRPM and rebuilds it under mock, and it passed --enable-network unconditionally, so it modelled a chroot COPR was never going to give it. The lane now reads the flag out of .packit.yaml and gets the same network the real submission would.
  • The docs asked for the toggle and stopped. Setup step 5 now names both halves.

Recovery is the hand-submitted build above. copr-cli sends no enable_net unless --enable-net is passed, so it inherits the project toggle and needs nothing else.

Staging COPR (tyvsmith/facelock-testing)

The project exists, with exactly the fedora-43-x86_64, fedora-44-x86_64, and fedora-45-x86_64 chroots, internet access during builds enabled, and github.com/tyvsmith/facelock on its Packit forge allowlist. Those three properties are contract-checked on every pull request, not just described here: test/check-live-release-channels.py reads all of them off the project response and fails on any of them. dist/release-matrix.json records the claim as copr_channels.staging.provisioned: true and holds the expected forge project in copr_channels.staging.required_forge_project.

Builder permission for the packit user is the one setup step no gate can see. COPR serves project permissions only to an authenticated owner, so the checker, which reads the public project API, cannot compare them. Confirm that one by hand in the web UI.

.packit.yaml declares a second copr_build job for it, on trigger: pull_request with manual_trigger: true. Packit therefore offers the build on a pull request and a maintainer dispatches it by hand with a /packit build comment; nothing builds into staging on its own, and a tag never publishes into staging.

python3 test/check-live-release-channels.py --channel staging now queries the real project and compares it with the checked-in authority, on every pull request in CI and again in just release-preflight. A chroot that appears or disappears in COPR fails that gate, as does internet access switched off or a forge allowlist that stops naming this repository.

The trigger and the switch move together, and test/check-release-matrix.py enforces the pairing in both directions: provisioned: true requires trigger: pull_request, and provisioned: false requires trigger: ignore. Setting the switch back without moving the trigger, or the reverse, fails the release matrix contract. The pairing is what stops a pull-request trigger from aiming every Packit run at a project that answers 404.

Provisioning was three edits, and the contract rejected any two of them without the third: the switch in dist/release-matrix.json, the trigger in .packit.yaml, and retiring the staging COPR provisioning must stay unclaimed until issue #236 creates the project assertion in test/check-release-matrix.py. That third assertion existed so provisioning could not be claimed by a config change alone; retiring it was the moment someone confirmed the project really exists. The live comparison above is what holds the claim honest from here.

Staging tolerates no optional experimental chroot. Production accepts Rawhide's presence or absence; in staging any chroot beyond the supported three is drift.

Pre-tag attestation

scripts/release-attestation.py renders and validates the document that binds a candidate to what its channels serve: the candidate commit, the EVR each channel serves per target, artifact and repository digests, signing key fingerprints, and when each channel last refreshed its repository metadata.

python3 scripts/release-attestation.py render --input facts.json --output attestation.json
python3 scripts/release-attestation.py validate --attestation attestation.json --expect expect.json

validate fails closed on a drifted candidate commit, a served EVR or digest that disagrees with the recorded expectations, a changed signing fingerprint, metadata older than metadata_max_age_seconds or stamped in the future, and on any channel carrying the production COPR identity. Gathering those facts from live staging repositories is issue #236's remaining infrastructure work; the script and its contract cases run against fixtures today.

Note: a previously published release will not retroactively build — Packit reacts only to new Release events.

The old COPR_WEBHOOK_URL GitHub secret is no longer used and can be deleted (gh secret delete COPR_WEBHOOK_URL).

APT (Debian/Ubuntu)

Automated after setup. Every stable release run attempts to build a signed APT repository before it publishes the GitHub Release. APT_GPG_PRIVATE_KEY and APT_GPG_PASSPHRASE are required; if either is absent or invalid, the stable release remains unpublished.

One-time setup (~15 minutes):

  1. Generate a GPG signing key (if you don't have one):

    gpg --full-generate-key
    # Select RSA 4096, expiry 3y
    # UID: Ty Smith (Package Signing) <packages@m.tysmith.me>
    
  2. Export and add the private key as a GitHub secret:

    gpg --armor --export-secret-keys "packages@m.tysmith.me" | gh secret set APT_GPG_PRIVATE_KEY
    
  3. Add the passphrase as a GitHub secret:

    gh secret set APT_GPG_PASSPHRASE --body "your-passphrase"
    

    Or use the web UI: https://github.com/tyvsmith/facelock/settings/secrets/actions

The repository configuration lives in dist/apt/conf/distributions. Two codenamed suites are published:

  • trixie: Debian 13 TPM build using Trixie Backports Rust/Cargo
  • resolute: Ubuntu 26.04 TPM build using native Rust/Cargo

Two compatibility suites are published alongside them until 0.3.0, for clients whose source entry was written for v0.1.4:

  • main: the trixie package, included by publish-apt.sh from the same validated artifact
  • legacy: no package; reprepro export writes signed empty indexes so apt update keeps succeeding

Those clients must replace the suite in their Facelock source entry with their operating-system codename before 0.3.0. dist/release-matrix.json declares the window under apt_suites.compat, and check-release-matrix.py fails the first tree at or past retire_at that still carries the stanzas. Prerelease packages are never inserted into any of these stable suites. Stable publication requires exactly one suite-matching package for both codenames before signing or repository writes begin.

The APT repo is hosted at https://tysmith.me/facelock/apt/ alongside the docs site. The public keyring is at https://tysmith.me/facelock/apt/tysmith-archive-keyring.gpg. It carries one rsa4096 key: Ty Smith (Package Signing) <packages@m.tysmith.me>, fingerprint E7F8A4C424C6D59BD38536B536A81FCD934C17CE, expiring 2029-03-27. dist/release-matrix.json pins that fingerprint, uid, and expiry under apt_signing_key. test/check-release-matrix.py fails the tree if docs/quickstart.md or this file quotes a different fingerprint or drops the pinned one, so rotate the pin and both docs together.

GPG key rotation: The fetched keyring is a client's only trust root; nothing redelivers it on its own. A client that never re-fetches stops verifying the repository once the key expires (2029-03-27) or is rotated early, because apt refuses an expired or unrecognized key's signature. Renewing the expiry instead of rotating does not help: gpg --edit-key <key-id> expire writes a new self-signature onto the same exported key, and a client's already-fetched copy still carries the old one. Redistribution is required either way.

The publisher enforces the pin at sign time: it refuses to sign unless the imported key's fingerprint, uid, and expiry all match apt_signing_key, naming the mismatch. Rotating the APT_GPG_PRIVATE_KEY secret without updating the pin therefore fails the release rather than publishing a keyring the docs do not match. When the signing key changes, generate a new key, update APT_GPG_PRIVATE_KEY and APT_GPG_PASSPHRASE, update the apt_signing_key pin and both docs, and cut a new release; just test-release-matrix fails once today reaches the pinned expiry, and fails again if the pin and the docs disagree. A keyring package that lets an installed client pick up the new key through apt upgrade is tracked in #346.

Manual AUR update (fallback)

If CI is not configured or fails:

  1. Download the release tarball, then compute the checksum from the file. Piping curl into sha256sum prints the digest of empty input when the download fails; downloading first prints no digest at all:
    curl -fSsL -o "facelock-v$VERSION.tar.gz" \
      "https://github.com/tyvsmith/facelock/archive/v$VERSION.tar.gz" &&
      sha256sum "facelock-v$VERSION.tar.gz"
    
  2. Clone the AUR repo (first time only):
    git clone ssh://aur@aur.archlinux.org/facelock.git aur-facelock
    
  3. Copy dist/PKGBUILD and dist/facelock.install into the AUR repo
  4. Replace the __SRC_SHA256__ placeholder in the PKGBUILD with the real checksum from step 1
  5. Generate .SRCINFO:
    cd aur-facelock
    makepkg --printsrcinfo > .SRCINFO
    
  6. Commit and push to AUR:
    git add PKGBUILD facelock.install .SRCINFO
    git commit -m "Update to v$VERSION"
    git push
    

Version Sources

The canonical version is in the root Cargo.toml under [workspace.package]. The version fields synced by just release are:

FileField
Cargo.toml[workspace.package] version
dist/PKGBUILD, dist/PKGBUILD-binupstream _tag, converted pkgver, package pkgrel
dist/PKGBUILD-gitconverted display pkgver
dist/facelock.specconverted Version and monotonic prerelease Release
debian/changelogconverted upstream and package revision in first entry

The independently maintained dist/release-matrix.json records supported targets, lifecycle depth, and immutable environment identities. Release preflight, CI, and release metadata checks validate that authority; just release does not rewrite it.

The version facelock-git actually installs

dist/PKGBUILD-git's pkgver field is display only. AUR's web page and .SRCINFO show it because makepkg --printsrcinfo runs without a checkout to describe, and just release keeps it level with the release so the page does not drift. What a build installs is whatever pkgver() computes at build time:

<released pkgver>.r<commits since that tag>.g<abbreviated object name>

git describe --abbrev=7 sets a floor, not a width: the object name is seven hex characters, or more where seven would be ambiguous. So a build off v0.1.4 reads like 0.1.4.r650.ga8c48b7, and one off v0.2.0-alpha.1 like 0.2.0alpha1.r7.gdeadbee. Two properties make that version usable, and both are enforced rather than assumed:

  • it must outrank the release it descends from, or pacman refuses the upgrade and every AUR helper reports the package as permanently out of date
  • it must rank below the next release, or the git package blocks the real one

Four things earn that, and all four were live faults (#330):

--tagsEvery release tag since v0.1.2 is lightweight. Without it, describe walks back to the last annotated tag, v0.1.0-rc4.
--match 'v[0-9]*'The repository carries a non-version tag (assets), and describe takes it whenever it sits nearer HEAD.
stripped leading vpacman ranks an alphabetic first segment below a numeric one, so a surviving v sorts the build under every release.
converted prerelease suffixv0.2.0-alpha.1 becomes 0.2.0alpha1, the same conversion the released package gets. pacman compares separator runs before segments, so keeping the punctuation ranks the build above 0.2.0alpha2, 0.2.0beta1 and the stable 0.2.0 alike.

test/release-version-contract.sh holds the recipe to that shape against a synthetic tag graph, and test/release-native-ordering.sh hands the result to vercmp inside the pinned Arch container. release_arch_git_pkgver in scripts/release-versions.sh is the one definition both read.

ONNX Runtime Bundling

The ort crate is built with feature api-20, so facelock requires ONNX Runtime 1.20 or newer at runtime. ONNX Runtime is forward-compatible, so a single build works against any runtime ≥ 1.20.

ONNX Runtime is sourced differently per channel:

  • GitHub-Release .deb: bundles CPU-only ORT 1.20.1 under /usr/lib/facelock/, because ONNX Runtime is not available in Ubuntu repositories.
  • GitHub-Release direct .rpm: builds the spec with --with bundled_ort, installs libonnxruntime.so.1 under %{_libdir}/facelock/, and has no system onnxruntime dependency.
  • COPR RPM (built from source by Packit): leaves the spec's %bcond_with bundled_ort disabled, contains no bundled runtime, and requires Fedora's system onnxruntime package.
  • Arch Linux (PKGBUILD): depends on the virtual onnxruntime capability. The exact official-repository providers are onnxruntime-cpu, onnxruntime-opt-cuda, and onnxruntime-opt-rocm; there is no package literally named onnxruntime.

The bundled ORT is a CPU-only fallback — users who install a system-wide GPU-enabled ONNX Runtime (CUDA, ROCm, OpenVINO) will have it take precedence automatically (the search order prefers system paths over the bundled copy).

The reviewed pins in .github/workflows/release.yml include the version, upstream URL, archive and library SHA-256 values, upstream commit, and MIT license identity. The download job verifies the archive before extraction and the library after extraction, then emits manifest.json, SHA256SUMS, and PROVENANCE.md beside upstream LICENSE, ThirdPartyNotices.txt, VERSION_NUMBER, and GIT_COMMIT_ID. Direct RPM assembly re-verifies those inputs and enters .github/workflows/scripts/run-networkless.sh before creating the source archive or rpmbuild tree. That wrapper uses util-linux enosys to deny socket and io_uring network syscalls, closes inherited non-stdio file descriptors, and requires its network probe to fail with ENOSYS before invoking rpmbuild; CARGO_NET_OFFLINE=true remains defense in depth. The RPM ships the reviewed inputs under its package documentation/license directories for SBOM and provenance consumers.

When upgrading the ort crate dependency, update every reviewed ORT pin and the RPM bundle filename together and, if the crate requires a higher floor, the api-NN feature in crates/facelock-face/Cargo.toml.

Rawhide may be attempted only with the digest-pinned experimental environment recorded in dist/release-matrix.json. A Rawhide system-ORT build/session smoke is best effort: absence or failure is nonblocking and can never stand in for lifecycle, upgrade, rollback, artifact, served-version, availability, or alpha release evidence. It must not publish or modify a COPR channel.

Upgrade Safety

Since facelock is a PAM module, broken releases can lock users out. Every release must:

  1. Pass just check (tests + clippy + fmt)
  2. Pass just test-arch-pam (Arch container PAM smoke tests)
  3. Pass just test-arch-camera-free (camera-free daemon and one-shot E2E)
  4. Pass just test-arch-loopback (synthetic camera, no person) or just test-arch-camera-required (a camera and a person in frame, the only run that proves real-sensor recognition) against the final release commit; just release-preflight fails until one has
  5. Pass just test-rpm and just test-deb (multi-distro package validation)
  6. Not change PAM auth semantics without explicit changelog entry
  7. Preserve /etc/pam.d/sudo backup on install (/var/lib/facelock/pam-backups/sudo.<timestamp>)
  8. Default to PAM_IGNORE on internal errors (fall through to password)

Upgrading from the last release

just test-upgrade-v014 proves that state written by v0.1.4 survives an upgrade to the candidate and a rollback back to v0.1.4. Two lanes, Debian trixie and Fedora 44, each install the real published artifact rather than a synthesized older build of the candidate.

What the lanes pin. dist/release-matrix.json carries a predecessors block holding the GitHub release id, the asset id, the SHA256 and the byte size of each predecessor artifact. The lane Containerfiles take those as build args and carry no digest of their own, so one review changes the pin everywhere. just test-upgrade-v014-pins asks the release API whether those assets are still the assets it serves, which is how a re-uploaded or substituted predecessor gets caught before a lane silently proves something about a different file.

What the lane images carry. Each image installs the runtime libraries the released binary needs before the predecessor goes on. v0.1.4 wrote its Debian control file by hand and never declared libxkbcommon0, which its own binary links, so that release cannot start on a minimal Debian 13 at all. The candidate is built from debian/control and derives the list with ${shlibs:Depends}. Nothing is masked by supplying it: candidate dependency resolution belongs to test/deb-dependency-closure.sh on a pristine suite base.

What the lanes build. Predecessor state comes from the released v0.1.4 binary, never from the candidate: plaintext rows, keyfile-encrypted rows, mixed rows, and two swtpm-sealed shapes, one PCR-bound and one not. Each shape also carries a modified config, the reviewed models, an enrollment marker, an audit log and a hand-wired PAM service, because v0.1.4 has no facelock pam subcommand and that is the shape a real upgrade finds.

What each lane proves after the upgrade. The V5 database reaches V6 with legacy rows at device_id = NULL. A known embedding still decrypts to the exact plaintext it was enrolled as, which a file hash cannot show: a preserved key and a preserved ciphertext nobody can open any more hash identically. No key artifact is replaced and none appears that was not there before. Modes converge to ADR 010 without content changing. The enrollment marker keeps its owner and mode and its content is reconciled against the database rather than preserved byte for byte — the one piece of state the upgrade is supposed to rewrite (#137). The administrator's PAM service is byte-identical, a correct password still authenticates, and a wrong one still fails.

Version ordering on a development tree. Until just release bumps the workspace, the candidate .deb built from the tree is 0.1.4-1~deb13u1, which sorts below the published 0.1.4-1. The lanes build the same payload as an upgrade-test version instead, and every run prints the version it chose and why. Once the workspace version sorts above 0.1.4 the re-versioning stops and FACELOCK_UPGRADE_TEST_VERSION becomes a no-op: the lane installs the shipped version exactly. The native comparator inside the container decides either way, so a lane can never quietly become a downgrade test. Whatever version it lands on is spelled by scripts/release-versions.sh, the same file the release workflow uses, so a pre-release candidate reaches the lane as 0.2.0~alpha.3-1~deb13u1 rather than in a Cargo spelling neither packager would ever ship. The RPM release counter it passes is local to the lane, not the series counter from "Prerelease identity conversions" above: the lane's only ordering requirement is against the published predecessor, never against a previously published prerelease.

Upgraders from v0.1.4 already have face authentication enabled. That release's pam-auth-update profile shipped Default: yes, so installing it switched Facelock on in common-auth. The packaged profile is Default: no now, which applies to fresh installs; an upgrade leaves the global stack exactly as it found it, and the lane fails if it is edited in either direction. Removing an enabled profile would take face authentication away from someone using it, so the lane treats that as the more dangerous direction, not a clean result.

Where it runs. Locally, by design: a cached run is about twenty minutes and a cold one considerably more, so packaging.yml does not carry it and a nightly-only job is the follow-up. just check runs the container-free contract (just test-upgrade-v014-contract), so a broken lane definition still fails every pull request.

Rollback. The candidate daemon starts and migrates the database before the downgrade, so the predecessor is handed the file production would hand it. V6 has no down-migration and the schema stays at 6 after the package rolls back. See docs/contracts.md for what that does and does not guarantee.

Contributing

Prerequisites

  • Rust 1.88+ (rustup update)
  • Linux and the native build dependencies listed in Quickstart
  • A camera only for live capture/authentication work; IR is required by the default configuration
  • Podman (for container tests)

Building

cargo build --workspace

The unified binary is then target/debug/facelock; it is not installed on PATH. See Developer Commands for the full inventory.

Workspace structure

Facelock is a Cargo workspace with 11 crates:

CrateTypePurpose
facelock-corelibConfig, types, errors, D-Bus interface, traits
facelock-cameralibV4L2 capture, auto-detection, preprocessing
facelock-facelibONNX inference (SCRFD + ArcFace)
facelock-storelibSQLite face embedding storage
facelock-daemonlibAuth/enroll logic, liveness, audit, rate limiting, handler
facelock-clibinUnified CLI (facelock binary, includes bench subcommand)
facelock-benchbinDeveloper standalone benchmark utility; see Auxiliary Commands
pam-facelockcdylibPAM module (libc + toml + serde + zbus only)
facelock-tpmlibOptional TPM-bound encryption for embeddings at rest
facelock-polkitbinPolkit authentication agent for face auth
facelock-test-supportlibMocks and fixtures for testing, plus the facelock-synth-face fixture writer; see Auxiliary Commands

Version is declared once in the root Cargo.toml and inherited via version.workspace = true. Inter-crate dependencies use relative paths.

Code style

  • Error handling: thiserror for library error types, anyhow in binaries. Return Result<T> over panicking. Never unwrap() in library code.
  • Logging: tracing for structured logging. Control verbosity via RUST_LOG env filter.
  • Tests: #[cfg(test)] modules in each source file.
  • Formatting: cargo fmt (default rustfmt settings).
  • Linting: cargo clippy --workspace -- -D warnings must pass with zero warnings.

Dependency rules

The PAM module (pam-facelock) must stay lightweight: libc, toml, serde, zbus only. No ort, no v4l, no facelock-core. This keeps the shared library small and avoids dragging heavy dependencies into every PAM-using process.

Each crate has a defined dependency boundary. See the Contracts chapter for the full table.

Testing

Tier 1: Unit tests (no hardware)

cargo test --workspace
cargo clippy --workspace -- -D warnings

Run these before every commit. They require no camera or models.

Tier 2: Hardware tests (camera + models)

cargo test --workspace -- --ignored

Requires a connected camera and downloaded models. These tests are marked #[ignore] and skipped by default.

Tier 3: Container tests (requires podman)

just test-arch-pam          # Arch PAM smoke tests (no camera)
just test-arch-integration  # end-to-end with camera (daemon mode)
just test-arch-oneshot      # end-to-end with camera (no daemon)
just test-arch-dev-shell    # interactive container shell for debugging

Container tests validate PAM integration without risking host lockout.

Tier 4: VM testing

Use a disposable VM with snapshots for testing PAM changes against real login flows.

Tier 5: Host PAM testing

Only after tiers 3--4 pass. Always keep a root shell open. Start with sudo only -- never add Facelock to login or display manager PAM until sudo works reliably.

All checks at once

just check  # full local validation aggregate, including audit and docs/contracts

just check does not run the full packaging matrix or camera-required lanes. For documentation-only changes, start with just check-docs and just docs-site-check; use source review and the established behavior tests to check meaning, and a targeted container probe for uncertain distro commands.

Translations

Facelock is wired for gettext but not yet translated. po/ holds only the two .pot templates, and that is the intended state -- there are no .po files to review, and no language is shipped.

Two catalogs, deliberately separate and never merged: facelock for the CLI (extracted from the message seam in crates/facelock-cli/src/message/) and pam_facelock for the PAM module, which has its own hard dependency ceiling.

just pot   # regenerate po/*.pot from source (translators and CI only)
just mo    # compile po/<lang>/*.po into target/locale for local verification

gettext is a build dependency of every package but stays optional for a source install: English is compiled in as the fallback, so just install-files on a machine without msgfmt installs untranslated rather than failing.

Installing a catalog is scripts/install-locale-catalogs.sh, and every install path calls it -- deb, rpm, the three PKGBUILDs, Nix, and the source install that OpenRC, runit and s6 systems use. just test-locale-install-contract is what holds that together; it builds a throwaway pseudo-locale, because with no .po in the tree nothing else would notice a broken install path until the first translation landed. Add a packaging path, wire it there too.

Two things are still missing, both tracked in #140:

  • a long tail of CLI print sites (counted per domain in #140) still writes English directly instead of going through the message seam, so a translation would cover the converted subset only. The conversion pattern is documented in crates/facelock-cli/src/message/mod.rs and is best done one domain at a time.
  • no translation has been accepted yet. Start one with mkdir -p po/de && msginit -i po/facelock.pot -o po/de/facelock.po -l de.

Security considerations

Read the Security chapter before implementing any auth-related code. Key rules:

  • security.require_ir defaults to true. Never weaken this default.
  • Frame variance checks must remain in the auth path.
  • Model files are SHA256-verified at load time.
  • D-Bus message size limits are enforced by the bus daemon. Never allocate unbounded buffers.
  • D-Bus system bus policy restricts daemon access.
  • The PAM module logs all auth attempts to syslog.
  • Daemon and oneshot authentication limit face-detected failures (5/user/60s by default); successful and no-face attempts do not consume this budget.

Contracts

Do not change binary names, paths, config keys, database schema, or auth semantics without updating the Contracts chapter.

Submitting changes

  1. Run just check (or at minimum cargo test --workspace && cargo clippy --workspace -- -D warnings).
  2. Run container tests if your change touches PAM, daemon, or IPC code.
  3. Keep commits focused. Separate refactoring from behavioral changes.
  4. Write clear commit messages that explain why, not just what.