Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

§9. Manifest, change catalog, and configuration editing

planarian @ a924e09 · Spec v4.6 · snapshot main · all versions

Manifest (services.planarian.units.<name>): desired (status + condition), edges (kind + target), sensitivity (set), restartDisruption, probes, synonyms, and, for a network-security unit (§12d), holdAllow (the endpoints it needs to reconnect while its traffic is held) and holdInterfaces (its own interfaces, whose traffic the hold passes). Defaults derived at build time: Requires=/BindsTo=/Requisite= → Requires edges; Wants= → Wants edges (weak, never a root cause); After= → Ordering edges only; functional prerequisites systemd cannot express come only from the manifest; predicted from enable; sensitivity from a shipped table; restart_disruption from a shipped table (display-manager → SessionLoss; network → ConnectivityLoss; databases → DataInterruption). Diagnostic edges come only from the manifest or, later, from learned data, and carry a confidence; a learned edge is never admitted without a passing gym case. The manifest and the override file have Sensitivity::{Security}.

Probe kinds. Canonical and user probes are built from the typed kinds (SystemdActive, Tcp, Http, DBus, FilePresent). Exec probes, which run arbitrary commands, are permitted only in manifest entries authored by a principal with administrator authority; they are flagged in the console and the journal, and their definition changes are Security-sensitive like the rest of the manifest.

Canonical probes ship in the planarian package for networking, sshd, pipewire, display-manager, graphical-session, storage, and the user services the reference config enables. A generation may add User probes; it may not remove or alter canonical ones (I10). The reference-config graphical-session unit has desired condition SessionEstablished { seated: true } (logind reports a new seated session, i.e. authentication succeeded at a seat; an SSH login expects no desktop) and a Readiness probe “the compositor process in that session’s cgroup (session-<id>.scope) reaches its ready state within 60 s of the session’s creation,” with graphical-session.target as corroboration only. (The target lives in the per-user manager, so for a second graphical session of the same uid it is already reached and cannot serve as evidence.) The condition comes from logind, the result from systemd, so a desktop that fails after a successful login opens an incident, and an idle greeter opens none. The greeter’s probe is SystemdActive. Canonical probes are tagged role: Readiness where their side effects are None or a bounded Measurement (TCP connects, GETs to health endpoints); the audio tone and any Audible/Visible/RemoteStateChange/Disk probe are Diagnostic and OnDemand.

Applicability of probes to authorized desired-state changes. A probe applies only while its unit is desired-present under the candidate’s desired state. A transaction that disables a service (authorized by policy) makes that service’s probes inapplicable in the candidate; it is not probe tampering, which means changing a probe’s definition. Both are derived facts: touches_canonical_probe (definition changed) versus changes_desired (applicability changed). Only the former is protected by I10.

Change catalog (crates/changes, wired by nix/reference.nix): every ApplyChange names a catalog entry. An entry has: id, args with types and permitted values, a handler that produces option assignments for the override file, affected_modules, description, and locked_if (user mkForce detected). The executor evaluates the handler’s output (GenerationBackend::evaluate) and derives affected units, sensitivity, disruption, and manifest/probe-touching from the evaluated generation’s option set and activation plan, not from the entry’s own metadata (I14); the entry’s affected_modules is a hint for the matcher and a test assertion, not a policy input. Phase A ships entries for: enable/disable a service from an allowlist; set font scale; set keyboard layout; select Wi-Fi network; set a service’s listen address from a permitted set; re-enable a manifest-predicted option observed disabled. The model (B) may propose new catalog entries for human review; it never writes raw Nix.

Override source and staged evaluation. Planarian never edits the user’s own Nix. The owner’s flake imports ./planarian/overrides.nix relative to the flake root (the module template does this), containing flat option assignments wrapped in lib.mkOverride 90. A candidate is never evaluated from mutable host files:

  1. Stage. GenerationBackend::stage copies the owner’s configuration as Nix will see it (for a Git flake: the tracked files from the working tree, including uncommitted modifications to tracked files, which is what a local Git flake input exposes; untracked files are excluded and reported; for a non-Git directory: the whole directory) plus flake.lock into an immutable directory /var/lib/planarian/stage/<txn>/, computes and records base_source (the NAR hash of that copy before any override replacement, i.e. the owner’s source with the base override), then replaces planarian/overrides.nix with the candidate override set and records staged_source (the NAR hash of the tree actually evaluated and built). Both identities, plus base_override and candidate_override, are bound to the transaction and the approval token. The pre-activation stale check (step 6) recomputes the NAR hash of the same Nix-visible set of bytes from the owner’s working tree and compares it to base_source only; staged_source is what the candidate must match at verify_candidate. So a change that alters only the override reaches activation without Stale, and an uncommitted owner edit after staging produces Stale. Nothing is “pinned by hash and read later”: the bytes evaluated are the staged bytes.
  2. Evaluate the staged source with pure-eval on (it is a path input with no external state) and record the evaluated option set, UnitDefs, and activation plan.
  3. Build before approval. The candidate store path is determined by the build; approval binds to it. The build also records the candidate’s realized identity: the store path’s NAR hash (nix path-info --json), kept in the transaction as candidate_nar. Before activate_live, verify_candidate(path, candidate_nar) confirms the path exists and its NAR hash equals the recorded one; an existence check alone does not satisfy this step. The dry activation that produces the activation plan (switch-to-configuration dry-activate) MUST exit successfully within its timeout with untruncated output; otherwise the build fails. A plan is never derived from partial output.
  4. Base and candidate identities. At Prepared the executor copies the live overrides.nix to the transaction-specific, immutable /var/lib/planarian/txn/<txn>/base.nix and records its content hash as base_override; the staged candidate’s content hash is candidate_override. No shared .prev file exists. Promote (on the commit path, after publish_boot): under an exclusive flock on the override file’s directory, read the live file’s hash; if it equals candidate_override, the step is already done; if it equals base_override, write the candidate to a temp file, fsync, atomic rename over the live path, fsync the directory, release the lock; anything else → RecoveryFailed (a third party changed the file). Restore (revert path): the same procedure with base and candidate swapped. Both are therefore idempotent by construction and cannot save the candidate over the base. The lock closes the window between comparison and rename for writers that take it (Planarian’s own paths, and PlanarianOS’s wrapped editors from C); a root editor that does not is X-12 in the register. The txn/<txn>/ directory is created with an exclusive mkdir (§12 step 4) and is deleted after Committed/Reverted/Rejected/Cancelled and a successful checkpoint (§12c); the content-addressed override store (overrides/<hash>.nix) is pruned of entries no live or protected generation references at the same time.
  5. GC root. build is store-only bookkeeping and roots its own output: on success it creates /nix/var/nix/gcroots/planarian/builds/<staged_source hex> pointing at the candidate store path, before any transaction exists and without a lease (this is the one root operation that does not require Prepared; protect is the lease-gated form used for generations). When the transaction is created in §12 step 4, it adopts the root (recorded as gc_root); the root is removed when the transaction reaches a terminal state (kept if Committed, since the generation’s own profile link then roots it). On startup, and before every CollectGarbage, the daemon removes any builds/* root not adopted by a live transaction and older than 24 h: this covers builds whose daemon died before Prepared as well as committed candidates. A policy record is never a root; only the symlink is. Gym cases: run GC while approval is pending (the candidate survives); crash during build, restart, wait past the age limit (the orphan root is gone).
  6. Stale check. If the owner’s Nix-visible source no longer hashes to base_source at any check between Staged and Activating, the transaction is Rejected(Stale) and re-prepared; the staged copy is unaffected, but the policy facts were derived against a source that no longer matches what the owner will build next. The check runs at Prepared, at approval, and immediately before Activating on every path, including when a transaction leaves Built after waiting for an activation window (§12f) — together with re-derivation and a fresh decide (I8). Gym cases: an override-only change with no owner edit activates without Stale; an uncommitted edit to a tracked owner file after staging produces Stale; an owner edit while a change waits for the session to idle produces Stale when the window opens.

Options the user set with mkForce are locked. Gym cases: an override-only candidate changes the evaluated output; the owner edits the source during a build; a crash at each commit boundary (§12).

External desired-state source (hook). Desired state may additionally be sourced from a signed external document, alongside the manifest, verified against a key declared in the generation. The key option and the source’s location are Security-sensitive (changing either is AskFirst with administrator authority). A stale or unreachable source leaves the last valid document in force; an invalid signature is an integrity failure (§12d). Phase A implements the verification and cache path with no producer; fleet desired state, membership, and pushed defaults use it later (future-work §5) without reopening state or policy.

Catalog coverage. The gym computes coverage: of the options a profile’s user plausibly changes (inferred from the NixOS option tree filtered to user-facing, bounded-domain, non-Security options, the desktop settings surface, and option frequency in public configurations), the fraction with a catalog entry. Coverage is a gym metric, never measured on users. Sensitivity is derived from the evaluated change (I14), so catalog growth does not grow the policy review.

NotInCatalog by phase. A: reject and say so; an authorized user may still edit the override file directly, through the transaction path. B: the model may propose a new catalog entry for review; on approval the original request runs through it. C: Flatpak is the user-scope valve for installing applications, which needs no system change and no catalog entry.

Executor-owned requirements. Beyond the proposer’s expected, the executor always requires: no unit outside may_change that is desired-present under the candidate leaves Running; no Readiness probe that is applicable under the candidate and passed before fails after; the generation equals the verified candidate. The second requirement is checked per probe against the set recorded before the change, not by comparing aggregate unit status: a unit that was Degraded before (probe A failing, B passing) still fails the check if B fails after. The first requirement is checked over every observed unit, not only those the proposer named. A proposer can only add claims.