§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:
- Stage.
GenerationBackend::stagecopies 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) plusflake.lockinto an immutable directory/var/lib/planarian/stage/<txn>/, computes and recordsbase_source(the NAR hash of that copy before any override replacement, i.e. the owner’s source with the base override), then replacesplanarian/overrides.nixwith the candidate override set and recordsstaged_source(the NAR hash of the tree actually evaluated and built). Both identities, plusbase_overrideandcandidate_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 tobase_sourceonly;staged_sourceis what the candidate must match atverify_candidate. So a change that alters only the override reaches activation withoutStale, and an uncommitted owner edit after staging producesStale. Nothing is “pinned by hash and read later”: the bytes evaluated are the staged bytes. - Evaluate the staged source with
pure-evalon (it is a path input with no external state) and record the evaluated option set,UnitDefs, and activation plan. - 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 ascandidate_nar. Beforeactivate_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. - Base and candidate identities. At
Preparedthe executor copies the liveoverrides.nixto the transaction-specific, immutable/var/lib/planarian/txn/<txn>/base.nixand records its content hash asbase_override; the staged candidate’s content hash iscandidate_override. No shared.prevfile exists. Promote (on the commit path, afterpublish_boot): under an exclusiveflockon the override file’s directory, read the live file’s hash; if it equalscandidate_override, the step is already done; if it equalsbase_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. Thetxn/<txn>/directory is created with an exclusivemkdir(§12 step 4) and is deleted afterCommitted/Reverted/Rejected/Cancelledand 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. - GC root.
buildis 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 requirePrepared;protectis the lease-gated form used for generations). When the transaction is created in §12 step 4, it adopts the root (recorded asgc_root); the root is removed when the transaction reaches a terminal state (kept ifCommitted, since the generation’s own profile link then roots it). On startup, and before everyCollectGarbage, the daemon removes anybuilds/*root not adopted by a live transaction and older than 24 h: this covers builds whose daemon died beforePreparedas 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 duringbuild, restart, wait past the age limit (the orphan root is gone). - Stale check. If the owner’s Nix-visible source no longer hashes to
base_sourceat any check betweenStagedandActivating, the transaction isRejected(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 atPrepared, at approval, and immediately beforeActivatingon every path, including when a transaction leavesBuiltafter waiting for an activation window (§12f) — together with re-derivation and a freshdecide(I8). Gym cases: an override-only change with no owner edit activates withoutStale; an uncommitted edit to a tracked owner file after staging producesStale; an owner edit while a change waits for the session to idle producesStalewhen 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.