Planarian’s guarantees
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
What Planarian promises, and the code, proofs, tests and limits that carry each promise, computed from the repository at this commit (SPEC §17c).
25 invariants · 52 obligations discharged against 63 assumptions (d:a 52 : 63) · 29 assumptions · 39 bypass rows · 161 gym entries
| Invariant | Statement | Status | d:a | Gym | Limits |
|---|---|---|---|---|---|
| I1 | A boot-confirmed known-good generation always exists, is bootable, and is in the boot menu | Tested, not proved | 0 : 2 | 3 | 5 |
| I2 | The installed core package’s hash equals the hash recorded in its signature | Tested, not proved | 0 : 2 | 2 | 6 |
| I3 | Garbage collection never removes known-good, the previous generation, the current genera… | Tested, not proved | 0 : 2 | 3 | 5 |
| I4 | Running means desired-Running under the current conditions, observed-active, and every… | Tested, not proved | 0 : 1 | 1 | 6 |
| I5 | Forbidden actions are unrepresentable as transitions | Proved | 4 : 3 | 1 | 5 |
| I6 | At most one generation-changing transaction is active (a generation transaction waiting … | Proved | 7 : 5 | 9 | 5 |
| I7 | Every executed transition is logged with proposal, source, pre- and post-state, derived … | Tested, not proved | 0 : 1 | 0 | 5 |
| I8 | A tier-3 transition executes only with an approval token bound to the concrete candidate… | Proved | 1 : 1 | 4 | 6 |
| I9 | The model process holds exactly one write capability: submit a proposal | Neither proved nor tested | 0 : 1 | 0 | 6 |
| I10 | No change that removes or alters a canonical probe executes below AskFirst, and canonica… | Proved | 1 : 2 | 4 | 5 |
| I11 | A session’s cumulative effect is classified at least as high as any single transition pr… | Proved | 2 : 2 | 1 | 5 |
| I12 | Under the progress assumptions of §17a, every transaction in the journal reaches a termi… | Proved | 9 : 8 | 6 | 5 |
| I13 | A unit under an override is never a repair target while the override holds | Proved | 3 : 2 | 2 | 5 |
| I14 | Policy facts (sensitivity, affected units, manifest-touching, caller identity) are deriv… | Proved | 1 : 2 | 4 | 6 |
| I15 | Exactly one owner (lease) per transaction | Proved | 2 : 2 | 7 | 6 |
| I16 | No automatic revert, rollback, or boot fallback selects a generation below the security … | Proved | 2 : 3 | 5 | 6 |
| I17 | The set of principals holding administrator capability is never empty | Proved | 2 : 2 | 1 | 5 |
| I18 | Every journal record commits to the hash of its predecessor | Proved | 5 : 3 | 3 | 6 |
| I19 | No principal approves its own proposal, and no principal grants itself, or a principal i… | Proved | 3 : 3 | 3 | 5 |
| I20 | No agent (the on-device model, a remote relay, or any agent started by `planarian agent … | Tested, not proved | 0 : 2 | 7 | 8 |
| I21 | No transition that cannot be reversed, or that may lose a session, user data or connecti… | Proved | 2 : 4 | 4 | 7 |
| I22 | Changing the generation, collecting garbage, binding drivers, resolving the recovery int… | Proved | 5 : 4 | 3 | 8 |
| I23 | A generation transaction commits only after its boot entry is written and promoted | Proved | 3 : 1 | 2 | 6 |
| I24 | An agent’s authority is bound to the one AgentStarted record for the unit instance it … | Tested, not proved | 0 : 3 | 1 | 6 |
| I25 | Post-commit bookkeeping (security-floor derivation, the committed generation’s override … | Tested, not proved | 0 : 2 | 3 | 5 |
I1 · A boot-confirmed known-good generation always exists, is bootable, and is in the boot menu
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A boot-confirmed known-good generation always exists, is bootable, and is in the boot menu; an
accepted_baselinenever replaces it there. It may lag the security floor until the floor generation is boot-confirmed (§12e); it is then a manual fallback, never an automatic one.— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Tested, not proved | D | 0 : 2 (baseline 0 : 2, not degraded) | 3 entries, 2 must-pass |
How it is proved
No obligation discharges it: nothing about it is proved.
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| P3 | publish_boot and the override promotion rename are idempotent | Platform | I16, I23 |
| P8 | The kernel and systemd are correct | Platform | I2, I3, I9, I17, I20, I22 |
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
boot-only-kernel-boot-fails | fake | The same change, but the one-shot boot fails; the boot loader falls back to the default (the base), and the transaction is Reverted. | boot_default: base; generation: base; txn: ApplyChange | fake: PASS |
desktop-boot-fails-rollback | vm | A committed change that touches only boot (a kernel parameter) becomes live_tested but not known_good. After a reboot the greeter fails; the boot is not confirmed, Rollback goes to known_good, and after a second reboot the desktop works. | boot_default: known_good; generation: known_good; known_good: unchanged; reports: NotBootConfirmed; txn: Committed Rollback; units: service:display-manager Running | VM: not run in this build |
floor-revert-blocked | fake | A security update (gym-vulnlib 1.0 → 1.1, the old build carries knownVulnerabilities) commits and raises the floor. gym-web then crashes and fails every restart. The only rollback target, known_good, is below the floor: no Rollback runs, the incident escalates with RevertBlockedByFloor, and the boot default stays at the floor. | boot_default: candidate; escalation: RevertBlockedByFloor; generation: candidate; incident: service:gym-web; invariants: I1, I16; security_floor: candidate; txn: Committed ApplyChange, Rollback | fake: PASS |
Also tested by exec::tests (vm-generations).
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4 | Restored principles (now 12) and invariants (I1–I14) that v3 referenced but omitted |
| v4.4 | I1 and I6 clarified against the floor and waiting transactions |
See also
I2 · The installed core package’s hash equals the hash recorded in its signature
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The installed core package’s hash equals the hash recorded in its signature. In A–D the reference hash is recorded by the build and covers every Planarian binary and script (§12d); it detects a swapped file, not a rebuilt generation.
— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Tested, not proved | E | 0 : 2 (baseline 0 : 2, not degraded) | 2 entries, 2 must-pass |
How it is proved
No obligation discharges it: nothing about it is proved.
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| P5 | SHA-256 is collision-resistant (the journal’s hash chain, I18) | Platform | I8, I18 |
| P8 | The kernel and systemd are correct | Platform | I1, I3, I9, I17, I20, I22 |
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
posture-integrity-core-hash | vm | The recorded core hash no longer matches its signature (I2). planarian-recovery’s independent check fails the boot as Integrity: tamper alarm, notify, no agents, remote access off; local login proceeds with the warning. Only an administrator clears it. | invariants: I2, I20; not_started: service:gym-agent; posture: class Integrity, not [“halt_to_recovery”], responses [“alarm”, “notify_admins”, “disable_agents”, “restrict_remote”], target_reached planarian-failed@Integr… | VM: not run in this build |
posture-integrity-core-script | vm | One posture script (planarian-ack), not planariad, no longer matches its line in the core-hash record. planarian-recovery checks every Planarian binary and script, so the boot fails as Integrity all the same (B-12). | invariants: I2; posture: class Integrity, target_reached planarian-failed@Integrity.target | VM: not run in this build |
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-3 | Firmware, bootloader and initrd run before planarian-ready.target | Accepted | Accepted | C, E |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4 | Restored principles (now 12) and invariants (I1–I14) that v3 referenced but omitted |
| v4.5 | I2 states what the A–D core hash detects (B-12) |
See also
I3 · Garbage collection never removes known-good, the previous generation, the current genera…
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Garbage collection never removes known-good, the previous generation, the current generation, an in-flight candidate, or the boot model.
— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Tested, not proved | A (test), D (proof) | 0 : 2 (baseline 0 : 2, not degraded) | 3 entries, 2 must-pass |
How it is proved
No obligation discharges it: nothing about it is proved.
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| P1 | Backend Outcome is honest: Succeeded means applied; Unknown means the backend cannot say | Platform | I12, I25 |
| P8 | The kernel and systemd are correct | Platform | I1, I2, I9, I17, I20, I22 |
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
gc-during-approval | fake, vm | Garbage collection runs while an AskFirst change waits for approval. The candidate survives (its build root is protected), and after approval verify_candidate passes. | gc_protected: candidate, known_good, base; generation: candidate; txn: Committed ApplyChange | fake: PASS; VM: not run in this build |
storage-budget-unsatisfiable | fake, vm | The generation budget is set below the size of the protected set. GC collects everything unprotected, reports BudgetUnsatisfiable with the shortfall, and never touches the protected set. | gc_protected: known_good, base, previous; reports: BudgetUnsatisfiable | fake: PASS; VM: not run in this build |
storage-nix-full-gc | fake, vm | The Nix store filesystem drops below max(2 GB, 5%) while old generations exist. CollectGarbage runs first, and the protected set is untouched. | gc_protected: known_good, base, previous; txn: Committed CollectGarbage; units: device:fs-nix Running | fake: PASS; VM: not run in this build |
Also tested by schema::gc tests.
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
See also
—
I4 · Running means desired-Running under the current conditions, observed-active, and every…
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Runningmeans desired-Running under the current conditions, observed-active, and every applicable Readiness probe passed with fresh evidence. Diagnostic probes never determine Running.— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Tested, not proved | A | 0 : 1 (baseline 0 : 1, not degraded) | 1 entry, 0 must-pass |
How it is proved
No obligation discharges it: nothing about it is proved.
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| P6 | systemd reports unit state truthfully over D-Bus and honors X-RestartIfChanged / X-StopIfChanged | Platform | I13, I20, I24 |
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
probe-diagnostic-stale-healthy | fake, vm | The evidence of the audio tone probe (Diagnostic, OnDemand) expires on a healthy machine. Status stays Running and no incident opens. | no_autonomous_txn: true; no_incident_on: service@1000:pipewire; units: service@1000:pipewire Running | fake: PASS; VM: not run in this build |
Also tested by state tests.
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-5 | A malicious unit that works correctly and passes its probes | Accepted | Accepted | — |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4 | Restored principles (now 12) and invariants (I1–I14) that v3 referenced but omitted |
| v4.3 | I4 reworded to applicable Readiness probes |
See also
—
I5 · Forbidden actions are unrepresentable as transitions
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Forbidden actions are unrepresentable as transitions;
Decision::Rejectis never converted to any tier by any setting.— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Proved | A (proof) | 4 : 3 (baseline 4 : 3, not degraded) | 1 entry, 1 must-pass |
How it is proved
4 obligations, in crates/policy/src/lib.rs, crates/policy/src/proofs.rs; every one is checked on every CI run.
| Obligation | Kind | What it guarantees | Source |
|---|---|---|---|
decide | verified fn | (no doc comment) | lib.rs:869 |
i5_autonomy_never_touches_reject | proof fn | I5: the autonomy setting never turns a Reject into a tier or a tier into a Reject, never changes a Reject’s reason, never yields a tier above itself other than AskFirst, and raising it never makes a decision stricter. | proofs.rs:73 |
unauthorized_is_refused_first | proof fn | Unauthorized callers are refused before anything else is considered. | proofs.rs:246 |
core_is_never_a_target | proof fn | The core is never a target. | proofs.rs:255 |
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| V1 | vstd’s specifications of Vec, String::as_str, str::unicode_len, str::get_char | Proof-validity | I10, I11, I16, I19, I21, I22 |
| V2 | Value is an opaque external type (external_type_specification); nothing about it is assumed | Proof-validity | I11, I16, I21 |
| V3 | Serde impls are unverified (external_derive) | Proof-validity | I6, I14 |
Every proof also rests on V7 (The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means), V8 (Verus is sound: what it reports verified holds of the code), V9 (Z3, the solver Verus calls, is sound), V10 (rustc compiles the verified code to a program with the semantics Verus checked).
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
en-i5-reject-survives-every-setting | fake | With autonomy at its maximum, a change touching Planarian’s own unit is refused, not raised to a tier; with the setting changed and the change asked again, it is refused again. No setting turns the refusal into an approval to ask for. | txn: ApplyChange | fake: PASS |
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
See also
planarian_policy in rustdoc · planarian_policy::proofs in rustdoc
I6 · At most one generation-changing transaction is active (a generation transaction waiting …
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
At most one generation-changing transaction is active (a generation transaction waiting in
Builtfor an activation window, or inAwaitingBoot, counts as active); no transition executes while a transaction is in any mutating or checking state:Activating,Checking,Publishing,Published,Promoted, orReverting. Unit and device repairs may execute duringBuiltandAwaitingBoot, and during a boot-onlyPromoted(admitted at boot, awaiting its confirmation; owner decision, 31 Oct): that change is already fully in place. (This list is the one the ledger enforces; v4.5’s omittedChecking.)— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Proved | A (proof) | 7 : 5 (baseline 7 : 5, not degraded) | 9 entries, 9 must-pass |
How it is proved
7 obligations, in crates/txn/src/ledger.rs, crates/txn/src/machine.rs; every one is checked on every CI run.
| Obligation | Kind | What it guarantees | Source |
|---|---|---|---|
validate | verified fn | (no doc comment) | ledger.rs:607 |
apply | verified fn | Validate, then apply. The record is consumed: the ledger keeps its parts. | ledger.rs:915 |
well_formed | verified fn | The ledger invariant, checked: used on a checkpoint read from disk. | ledger.rs:534 |
i6_one_generation_active | proof fn | I6, as SPEC §7 states it, from the invariant: of two live generation transactions at most one is active (in flight, or waiting in Built); and while a generation transaction is in flight other than awaiting a boot, no other transaction is in flight. | ledger.rs:1235 |
compatible_exec | verified fn | (no doc comment) | ledger.rs:460 |
only_generations_publish | proof fn | Only generation operations publish a boot entry (also a one-shot one) or promote overrides. | machine.rs:182 |
boot_only_never_activates | proof fn | A boot-only candidate is never live-activated: Publishing is journaled before any boot mutation (R01), AwaitingBoot is reached only through it, and left only for Promoted or a revert. | machine.rs:194 |
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| V3 | Serde impls are unverified (external_derive) | Proof-validity | I5, I14 |
| V4 | Expectation, CheckContext, ApprovalToken, Incident are opaque external types | Proof-validity | — |
| V5 | Ledger bookkeeping helpers (push_tag, tag_string, note_why, archive, set_override, lift_override, record_incident, use_token) are external_body with no postcondition: each receives only the field it updates, so the verified fields are untouched by construction; trusted only to terminate | Proof-validity | I12 |
| V6 | vstd’s specifications of Vec::push, insert, remove, is_empty and Box | Proof-validity | — |
| P4 | Journal fsync is durable and the preallocation is writable | Platform | I7, I12, I15, I18, I24, I25 |
Every proof also rests on V7 (The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means), V8 (Verus is sound: what it reports verified holds of the code), V9 (Z3, the solver Verus calls, is sound), V10 (rustc compiles the verified code to a program with the semantics Verus checked).
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
boot-only-admitted-not-confirmed | fake | A boot-only kernel change boots and is admitted (Promoted: the candidate is the default, its override promoted). sshd then crashes; repairs run while the admitted change is unconfirmed, so sshd is restarted at once, but a canonical unit that needed a repair counts against the change: at the 10-minute checkpoint it is withdrawn, never committed: Reverted, the publishing generation is the default again, the base override restored, and a notice names sshd and says a restart completes it. | boot_default: base; notices_count: count 1, match not confirmed (service:sshd needed a repair before the 10-minute checkpoint); txn: Reverted ApplyChange, service:sshd Committed RestartUnit; units: service:sshd Running | fake: PASS |
boot-only-repair-during-confirmation | fake | A boot-only kernel change boots and is admitted. Two minutes later gym-web (not canonical) crashes: it is repaired at once, without waiting for the 10-minute checkpoint, and the change is still confirmed and committed there. | agrees: true; boot_default: candidate; generation: candidate; txn: Committed ApplyChange, service:gym-web Committed RestartUnit; units: service:gym-web Running | fake: PASS |
crash-activating-bad | fake, vm | planariad is killed during Activating of a bad candidate; the watchdog takes the lease, fences, confirms quiescence, and reverts. | boot_default: base; generation: base; invariants: I6, I12, I15; journal_agrees: true; override_source: base; txn: Reverted ApplyChange; units: service:gym-web Running | fake: PASS; VM: not run in this build |
crash-activating-good | fake, vm | planariad is killed during Activating of a good candidate. The watchdog sees the candidate active, goes to Checking, the probes pass, and it runs the entire commit path. | boot_default: candidate; generation: candidate; invariants: I6, I12, I15; journal_agrees: true; override_source: candidate; txn: Committed ApplyChange | fake: PASS; VM: not run in this build |
crash-slow-worker-overlap | fake, vm | The daemon is killed and immediately restarted while a deliberately slow activation worker survives. The new owner fences it, so activation and revert never overlap and the old owner can never commit. | generation: base; invariants: I6, I15; journal_agrees: true; no_overlap: activate_live(candidate), activate_live(revert_to); stale_owner_commit: false | fake: PASS; VM: not run in this build |
crash-withheld-job-reply | fake | The backend accepts an activation job but its reply is withheld, and the owner is killed before the job id is journaled. Recovery must discover the job by its OpId, or fence it, before making any conflicting mutation. | invariants: I6, I15; journal_agrees: true; no_overlap: activate_live(candidate), activate_live(revert_to); quiescence_includes_tagged_jobs: true | fake: PASS |
en-i6-repair-waits-for-activation | fake, vm | While a slow generation change sits in Activating, gym-web crashes. No repair is prepared until the activation leaves the exclusive states; by the end gym-web runs again. | txn: service:gym-web RestartUnit; units: service:gym-web Running | fake: PASS; VM: not run in this build |
fake-assumptions-conformance | fake | The transaction kernel against the fake backend, once per docs/assumptions.md entry and injected outcome. Each run must satisfy the entry’s contract and reach a terminal state (I12). | invariants: I6, I12, I15; reports: AssumptionHeld(<assumption>) | fake: PASS |
quiet-staged-update-foreground | fake | alice changes her font scale while a game holds Foreground in her session. The verified candidate waits in Built; once Foreground ends it activates in that window and commits. | reports: WaitsForWindow; txn: Committed ApplyChange | fake: PASS |
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4 | Restored principles (now 12) and invariants (I1–I14) that v3 referenced but omitted |
| v4.4 | I1 and I6 clarified against the floor and waiting transactions |
| v4.6 | I6 and §12 Concurrency carry the same exclusive-state list, now including Checking and Publishing |
| v4.6 | I6, §10, §12 Concurrency (owner decision, Oct 31): unit and device repairs run during a boot-only Promoted, and a canonical unit that needed a repair since admission means the change is not confirmed |
See also
planarian_txn::ledger in rustdoc · planarian_txn::machine in rustdoc
I7 · Every executed transition is logged with proposal, source, pre- and post-state, derived …
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Every executed transition is logged with proposal, source, pre- and post-state, derived facts, policy version, probe evidence (per probe: id, result, time), decision, and outcome; an approved transition’s log and journal records name the approver’s principal and the token’s
bound_to.— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Tested, not proved | A | 0 : 1 (baseline 0 : 1, not degraded) | 0 entries, 0 must-pass |
How it is proved
No obligation discharges it: nothing about it is proved.
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| P4 | Journal fsync is durable and the preallocation is writable | Platform | I6, I12, I15, I18, I24, I25 |
How it is tested
No gym entry exercises it.
Also tested by daemon core tests.
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4 | Restored principles (now 12) and invariants (I1–I14) that v3 referenced but omitted |
| v4.6 | I7: probe evidence per probe and the approver’s identity in the record [C1, C17] |
See also
—
I8 · A tier-3 transition executes only with an approval token bound to the concrete candidate…
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A tier-3 transition executes only with an approval token bound to the concrete candidate (§12), re-validated immediately before execution. “Immediately before execution” includes the moment a transaction leaves
Builtafter waiting for an activation window: facts are re-derived,decidere-run and the stale check repeated there too, not only at approval.— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Proved | A, C (proof) | 1 : 1 (baseline 1 : 1, not degraded) | 4 entries, 4 must-pass |
How it is proved
1 obligation, in crates/exec/src/kani.rs; every one is checked on every CI run.
| Obligation | Kind | What it guarantees | Source |
|---|---|---|---|
approval_tokens_expire_after_their_lifetime | Kani harness | Approval tokens (§12 step 4), timed on the boot clock (§12h: the values here are CLOCK_BOOTTIME, which moving the wall clock cannot move): never expired at or before issue, expired exactly once 24 hours have passed, and once expired they stay expired. No clock value makes the check overflow. | kani.rs:14 |
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| P5 | SHA-256 is collision-resistant (the journal’s hash chain, I18) | Platform | I2, I18 |
Every proof also rests on V7 (The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means), V8 (Verus is sound: what it reports verified holds of the code), V9 (Z3, the solver Verus calls, is sound), V10 (rustc compiles the verified code to a program with the semantics Verus checked), V11 (Kani and its back end CBMC are sound within the stated bounds).
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
approval-token-expired | fake | A parked AskFirst change gets no approval for 24 h. It ends Rejected, its GC root is released, and any waiting incident reopens. | gc_root_released: true; txn: ApplyChange | fake: PASS |
approval-token-invalidated | fake | A token is issued for a parked AskFirst change, then the policy version changes. Re-validation invalidates the token and re-requests approval, and replaying the old token is refused. | reports: ApprovalInvalidated, TokenReplayRefused; txn: ApplyChange | fake: PASS |
en-i8-token-bound-to-candidate | fake, vm | An AskFirst change is approved; before it runs, the owner edits the configuration so the candidate it would build differs. The token, bound to the old candidate, does not authorise the new one. | txn: Rejected(Stale) ApplyChange | fake: PASS; VM: not run in this build |
token-wall-clock-moved-back | fake | An approval token is held, then the wall clock is moved back 48 h and 24 h pass. The token still expires 24 h after issue, timed on CLOCK_BOOTTIME (§12h): presenting it is refused and the parked change ends Rejected. | txn: ApplyChange | fake: PASS |
Also tested by exec tests.
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
| X-8 | The approver: fatigue, social engineering, persuasion by a model | Accepted | Accepted | — |
History
| Spec | Change |
|---|---|
| v4 | Restored principles (now 12) and invariants (I1–I14) that v3 referenced but omitted |
| v4.6 | I8: re-validation also when leaving Built [C2, P1] |
See also
I9 · The model process holds exactly one write capability: submit a proposal
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The model process holds exactly one write capability: submit a proposal.
— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Neither proved nor tested | B (by the sandbox, §20, and a gym case), E (proof) | 0 : 1 (baseline 0 : 1, not degraded) | 0 entries, 0 must-pass |
How it is proved
No obligation discharges it: nothing about it is proved.
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| P8 | The kernel and systemd are correct | Platform | I1, I2, I3, I17, I20, I22 |
How it is tested
No gym entry exercises it.
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| B-18 | The model service escapes its sandbox before I9 is proved, and becomes a process with its uid’s capabilities (it parses untrusted text and drives a GPU) | Medium | Open (v4.5 §20, I9) | B sandbox and case; E proof |
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4 | Restored principles (now 12) and invariants (I1–I14) that v3 referenced but omitted |
| v4.5 | §20, I9: the model is treated as an adversary, so its judgement is not a vector |
See also
—
I10 · No change that removes or alters a canonical probe executes below AskFirst, and canonica…
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
No change that removes or alters a canonical probe executes below AskFirst, and canonical probes are evaluated from the installed core package regardless of the active generation.
— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Proved | A | 1 : 2 (baseline 1 : 2, not degraded) | 4 entries, 4 must-pass |
How it is proved
1 obligation, in crates/policy/src/proofs.rs; every one is checked on every CI run.
| Obligation | Kind | What it guarantees | Source |
|---|---|---|---|
i10_canonical_probe_changes_ask | proof fn | I10, and probe tampering (§17a): a change to a canonical probe’s definition never runs below AskFirst. | proofs.rs:237 |
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| V1 | vstd’s specifications of Vec, String::as_str, str::unicode_len, str::get_char | Proof-validity | I5, I11, I16, I19, I21, I22 |
| U1 | exec::derive: DerivedFacts from the evaluated generation, never from a proposal (I14’s other half; property-tested) | Internal unproven | I14, I21, I22 |
Every proof also rests on V7 (The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means), V8 (Verus is sound: what it reports verified holds of the code), V9 (Z3, the solver Verus calls, is sound), V10 (rustc compiles the verified code to a program with the semantics Verus checked).
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
en-i10-a-canonical-probe-cannot-be-removed | fake | A configuration change drops sshd’s canonical probe from the manifest (removes it, not weakens it). Below AskFirst it may run, but the probe does not go: the probe definitions the daemon evaluates are the same afterwards, because the core ships the canonical probe and puts it back. | probe_definitions_unchanged: true | fake: PASS |
gen-disable-reenable-canonical | fake, vm | An admin disables sshd (which has a canonical Readiness probe) and later re-enables it. Disabling makes the probe inapplicable without tampering with it, so neither change is reverted. | journal_agrees: true; no_incident_on: service:sshd; override_source: candidate; txn: Committed ApplyChange, Committed ApplyChange; units: service:sshd Running | fake: PASS; VM: not run in this build |
manifest-weaken-out-of-band | vm | The owner weakens sshd’s probe in their own config and rebuilds outside Planarian. Canonical probes are still evaluated from the core package, so a later sshd fault is still detected. | canonical_probe_source: core; status_seen: service:sshd [“Degraded”] | VM: not run in this build |
manifest-weaken-via-proposal | fake, vm | A proposal tries to raise sshd’s canonical probe timeout to 600 s and mark it Diagnostic. No catalog entry can express that, so it is rejected and never executed. | generation: base; probe_definitions_unchanged: true; txn: ApplyChange | fake: PASS; VM: not run in this build |
Also tested by state tests.
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4 | Restored principles (now 12) and invariants (I1–I14) that v3 referenced but omitted |
| v4.4 | rule 4’s preserve list extended to I10–I20 |
See also
planarian_policy::proofs in rustdoc
I11 · A session’s cumulative effect is classified at least as high as any single transition pr…
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A session’s cumulative effect is classified at least as high as any single transition producing it.
— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Proved | A (proof) | 2 : 2 (baseline 2 : 2, not degraded) | 1 entry, 1 must-pass |
How it is proved
2 obligations, in crates/policy/src/proofs.rs; every one is checked on every CI run.
| Obligation | Kind | What it guarantees | Source |
|---|---|---|---|
i11_non_routine_session_asks | proof fn | I11 (classification): when this change’s diff, or the session’s cumulative diff, touches a non-Routine option, the decision is AskFirst or a Reject. | proofs.rs:95 |
i11_session_only_raises | proof fn | I11 (monotonicity): a change inside a session is decided at least as strictly as the same change on its own. | proofs.rs:118 |
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| V1 | vstd’s specifications of Vec, String::as_str, str::unicode_len, str::get_char | Proof-validity | I5, I10, I16, I19, I21, I22 |
| V2 | Value is an opaque external type (external_type_specification); nothing about it is assumed | Proof-validity | I5, I16, I21 |
Every proof also rests on V7 (The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means), V8 (Verus is sound: what it reports verified holds of the code), V9 (Z3, the solver Verus calls, is sound), V10 (rustc compiles the verified code to a program with the semantics Verus checked).
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
en-i11-session-carries-its-sensitivity | fake | In one session, an administrator changes the firewall (Security: approved by root at a terminal, committed), then asks for a display-scale change, Routine on its own. The session’s cumulative effect includes the firewall change, so the second is classified at least as high: AskFirst, not as itself. | txn: Committed ApplyChange, ApplyChange | fake: PASS |
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4 | Restored principles (now 12) and invariants (I1–I14) that v3 referenced but omitted |
| v4.4 | rule 4’s preserve list extended to I10–I20 |
See also
planarian_policy::proofs in rustdoc
I12 · Under the progress assumptions of §17a, every transaction in the journal reaches a termi…
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Under the progress assumptions of §17a, every transaction in the journal reaches a terminal state (
Committed,Reverted,Rejected,Cancelled, orRecoveryFailed);RecoveryFailedalways alerts and sets the recovery interlock. Two states wait on events outside the system’s control and are bounded separately (§12f): a transaction inBuiltwaiting for an activation window isCancelled(Daemon)after the parking limit; one inAwaitingBootwaits for the reboot it was published for and is reported as waiting, never as a recovery failure.— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Proved | A (proof, conditional) | 9 : 8 (baseline 9 : 8, not degraded) | 6 entries, 6 must-pass |
How it is proved
9 obligations, in crates/txn/src/kani.rs, crates/txn/src/ledger.rs, crates/txn/src/machine.rs; every one is checked on every CI run.
| Obligation | Kind | What it guarantees | Source |
|---|---|---|---|
i12_steps_terminate | proof fn | I12, termination: every allowed step lowers the rank, so a transaction takes at most eight steps and never cycles. | machine.rs:130 |
i12_terminal_always_reachable | proof fn | I12, progress: from every state that is not terminal, a step to a terminal state is allowed: Cancelled before any mutation, RecoveryFailed after. So whoever holds the lease can always finish the transaction. | machine.rs:141 |
terminal_is_final | proof fn | Terminal states have no successors. | machine.rs:151 |
refusal_only_before_mutation | proof fn | Rejected and Cancelled happen only before anything was mutated, or (for Rejected) from Publishing, whose all-or-nothing publication armed nothing. | machine.rs:161 |
apply | verified fn | Validate, then apply. The record is consumed: the ledger keeps its parts. | ledger.rs:915 |
recovery_table_follows_the_state_machine | Kani harness | The crash-recovery table agrees with the journal state machine: from every state a live transaction can be left in, under every strategy, every path recovery can take starts at that state, takes only steps machine::step_allowed permits (the Verus-verified rules), and ends in a terminal state. Nothing mutated is ever answered with a refusal, and anything not yet mutated is. | kani.rs:185 |
watchdog_takes_over_only_when_it_should | Kani harness | The watchdog takes a transaction over only inside the mutation window, never from itself, and never from a live owner that is within its deadline or has renewed within the grace period; a dead owner’s transaction in the window is always taken. No clock value makes the rule overflow. | kani.rs:199 |
step_allowed | verified fn | (no doc comment) | machine.rs:61 |
add_interlock | verified fn | Mark a transaction RecoveryFailed from the sentinel, when the journal itself could not take the record (§12c). | ledger.rs:934 |
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| G1 | Some lease holder eventually runs. | Platform (progress) | — |
| G2 | A fenced worker eventually stops issuing calls, or its calls are refused. | Platform (progress) | — |
| G3 | Journal writes eventually succeed. | Platform (progress) | — |
| G4 | Backend calls terminate. | Platform (progress) | — |
| P1 | Backend Outcome is honest: Succeeded means applied; Unknown means the backend cannot say | Platform | I3, I25 |
| P4 | Journal fsync is durable and the preallocation is writable | Platform | I6, I7, I15, I18, I24, I25 |
| P7 | Fencing is effective: a stale-epoch call is refused before it mutates | Platform | I15 |
| V5 | Ledger bookkeeping helpers (push_tag, tag_string, note_why, archive, set_override, lift_override, record_incident, use_token) are external_body with no postcondition: each receives only the field it updates, so the verified fields are untouched by construction; trusted only to terminate | Proof-validity | I6 |
Every proof also rests on V7 (The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means), V8 (Verus is sound: what it reports verified holds of the code), V9 (Z3, the solver Verus calls, is sound), V10 (rustc compiles the verified code to a program with the semantics Verus checked), V11 (Kani and its back end CBMC are sound within the stated bounds).
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
awaiting-boot-reminds-daily | fake | A kernel parameter change takes the boot-only path and nobody restarts for two and a half days. It stays AwaitingBoot (no limit applies, nothing is cancelled), the ‘restart to finish’ notice is repeated once a day, and at the eventual restart it is confirmed and commits. | generation: candidate; notices_count: count 2, match is still waiting for a restart; reports: RestartToFinish; txn: Committed ApplyChange | fake: PASS |
built-parking-expires | fake | alice changes her font scale while a game holds Foreground in her session, and the game never lets go. After a day in Built the change is Cancelled by the daemon, with one notice naming it and how to issue it again; it never activates, even once the game ends. | notices_count: count 1, match waited a day in Built; reports: WaitsForWindow; txn: Cancelled ApplyChange | fake: PASS |
crash-activating-bad | fake, vm | planariad is killed during Activating of a bad candidate; the watchdog takes the lease, fences, confirms quiescence, and reverts. | boot_default: base; generation: base; invariants: I6, I12, I15; journal_agrees: true; override_source: base; txn: Reverted ApplyChange; units: service:gym-web Running | fake: PASS; VM: not run in this build |
crash-activating-good | fake, vm | planariad is killed during Activating of a good candidate. The watchdog sees the candidate active, goes to Checking, the probes pass, and it runs the entire commit path. | boot_default: candidate; generation: candidate; invariants: I6, I12, I15; journal_agrees: true; override_source: candidate; txn: Committed ApplyChange | fake: PASS; VM: not run in this build |
en-i12-nothing-left-open | fake | A generation change breaks gym-web, and the revert to the base fails too. The daemon is killed while it is reverting and comes back to finish the revert, which fails again. At the end no journaled transaction is left open, the change is RecoveryFailed with its alert, the interlock holds, and gym-web’s next crash gets no autonomous restart. | interlock: set; no_txn_on: service:gym-web; nothing_left_open: true; reports: RecoveryFailed; txn: RecoveryFailed ApplyChange | fake: PASS |
fake-assumptions-conformance | fake | The transaction kernel against the fake backend, once per docs/assumptions.md entry and injected outcome. Each run must satisfy the entry’s contract and reach a terminal state (I12). | invariants: I6, I12, I15; reports: AssumptionHeld(<assumption>) | fake: PASS |
Also tested by exec crash-point property tests.
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4 | Restored principles (now 12) and invariants (I1–I14) that v3 referenced but omitted |
| v4.1 | §17a: progress assumptions stated for I12 |
| v4.3 | I12’s terminal set includes Rejected and Cancelled |
| v4.4 | rule 4’s preserve list extended to I10–I20 |
| v4.6 | I12: parked states bounded or reported [S03] |
See also
§17a · §12f · planarian_txn::ledger in rustdoc · planarian_txn::machine in rustdoc
I13 · A unit under an override is never a repair target while the override holds
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A unit under an override is never a repair target while the override holds.
— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Proved | A, C (proof) | 3 : 2 (baseline 0 : 1, not degraded) | 2 entries, 1 must-pass |
How it is proved
3 obligations, in crates/exec/src/kani.rs; every one is checked on every CI run.
| Obligation | Kind | What it guarantees | Source |
|---|---|---|---|
residue_security_is_never_intended | Kani harness | A security-sensitive unit’s stop is never taken as intended: it is alarmed (§11, Q5). | kani.rs:94 |
residue_crashes_are_faults | Kani harness | Crash signals, exit statuses and OOM kills of a failed unit are faults, never a person’s stop (§11, Q5). | kani.rs:105 |
residue_is_inferred_only_where_allowed | Kani harness | What Planarian did itself, or what went down with a crash, is never a residue; nor is anything under a posture that does not infer intent, a unit systemd restarts, or one that must keep running. | kani.rs:116 |
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| P6 | systemd reports unit state truthfully over D-Bus and honors X-RestartIfChanged / X-StopIfChanged | Platform | I4, I20, I24 |
| U3 | The residue classifier (§11: which unit endings are a person’s stop) | Internal unproven | — |
Every proof also rests on V7 (The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means), V8 (Verus is sound: what it reports verified holds of the code), V9 (Z3, the solver Verus calls, is sound), V10 (rustc compiles the verified code to a program with the semantics Verus checked), V11 (Kani and its back end CBMC are sound within the stated bounds).
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
en-i13-overridden-unit-is-not-repaired | fake, vm | An administrator stops gym-web until they say otherwise. For the next ten minutes gym-web stays stopped and under the override: no repair is proposed for it, and nothing restarts it. | overrides: service:gym-web; txn: Committed StopAndSuppress, service:gym-web RestartUnit; units: service:gym-web Overridden | fake: PASS; VM: not run in this build |
intent-user-stops-service | fake, vm | An admin asks to stop gym-web until they say otherwise. It stays stopped. The override is journaled before the stop, so there is no window in which a repair can race it. | journal_order: Override(service:gym-web), stop(service:gym-web); no_autonomous_txn: true; overrides: service:gym-web; txn: Committed StopAndSuppress; units: service:gym-web Overridden | fake: PASS; VM: not run in this build |
Also tested by incident tests.
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4 | Restored principles (now 12) and invariants (I1–I14) that v3 referenced but omitted |
| v4.4 | rule 4’s preserve list extended to I10–I20 |
See also
—
I14 · Policy facts (sensitivity, affected units, manifest-touching, caller identity) are deriv…
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Policy facts (sensitivity, affected units, manifest-touching, caller identity) are derived by the executor from the evaluated generation and the authenticated peer, never read from a proposal or from any other request field.
— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Proved | A (proof of the decide half; the request half by test) | 1 : 2 (baseline 1 : 2, not degraded) | 4 entries, 4 must-pass |
How it is proved
1 obligation, in crates/policy/src/proofs.rs; every one is checked on every CI run.
| Obligation | Kind | What it guarantees | Source |
|---|---|---|---|
i14_facts_caller_is_not_authority | proof fn | I14: the caller recorded in the derived facts carries no authority; only the daemon-established Authorization does. | proofs.rs:290 |
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| U1 | exec::derive: DerivedFacts from the evaluated generation, never from a proposal (I14’s other half; property-tested) | Internal unproven | I10, I21, I22 |
| V3 | Serde impls are unverified (external_derive) | Proof-validity | I5, I6 |
Every proof also rests on V7 (The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means), V8 (Verus is sound: what it reports verified holds of the code), V9 (Z3, the solver Verus calls, is sound), V10 (rustc compiles the verified code to a program with the semantics Verus checked).
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
approve-needs-real-terminal | fake, vm | Root approves a pending AskFirst change from a process with no controlling terminal (a new session, as a service would be). The daemon reads the terminal from the peer’s pidfd, finds none, and refuses: the change is still waiting at the end. The request carries no terminal field to claim. | txn: pending ApplyChange; units: service:sshd Running | fake: PASS; VM: not run in this build |
authz-forged-actor-overwritten | fake, vm | alice submits a SetOverride for her own pipewire with by = Daemon. SO_PEERCRED overwrites the actor to user:1000; the override is allowed and attributed to her. | overrides: service@1000:pipewire | fake: PASS; VM: not run in this build |
catalog-change-carries-unbuilt-owner-edit | fake | An administrator has edited the owner’s configuration to enable a service but not rebuilt. alice then asks for a user-controllable change (font scale). The candidate would also bring the new service, which the override does not explain, so the change is Security-sensitive: it waits for approval instead of activating on its own. | generation: base; reports: NeedsApproval | fake: PASS |
en-i14-forged-fields-ignored | fake, vm | An administrator who is not root submits a change to sshd whose request also claims caller root, sensitivity Routine and no affected units. The claims are ignored: the change is decided on derived facts (Security, sshd affected), held at AskFirst for approval, and not run. | txn: ApplyChange; units: service:sshd Running | fake: PASS; VM: not run in this build |
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| B-4 | decide is proved, but its inputs (DerivedFacts, Authorization) are computed by unproven code | Medium | Open (v4.5 §17a) | A accounting; C proof |
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4 | Restored principles (now 12) and invariants (I1–I14) that v3 referenced but omitted |
| v4 | Policy facts derived by the executor from the evaluated generation (I14) |
| v4.4 | rule 4’s preserve list extended to I10–I20 |
| v4.5 | §7: principle 6 and I14 extended from proposals to every request field (B-3) |
See also
planarian_policy::proofs in rustdoc
I15 · Exactly one owner (lease) per transaction
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Exactly one owner (lease) per transaction; a mutating call with a stale epoch is refused; only the lease holder advances the journal.
— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Proved | A (proof) | 2 : 2 (baseline 2 : 2, not degraded) | 7 entries, 7 must-pass |
How it is proved
2 obligations, in crates/txn/src/kani.rs, crates/txn/src/ledger.rs; every one is checked on every CI run.
| Obligation | Kind | What it guarantees | Source |
|---|---|---|---|
validate | verified fn | (no doc comment) | ledger.rs:607 |
takeover_fences_out_the_previous_owner | Kani harness | Fencing (I15): a takeover’s epoch is above both the journaled lease and the store’s fence; once the fence holds it, the previous owner’s epoch and every other epoch are refused and the new owner’s admitted; a second takeover fences out the first. Epoch arithmetic never overflows: at the end of the epoch space a takeover backs off instead. | kani.rs:225 |
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| P4 | Journal fsync is durable and the preallocation is writable | Platform | I6, I7, I12, I18, I24, I25 |
| P7 | Fencing is effective: a stale-epoch call is refused before it mutates | Platform | I12 |
Every proof also rests on V7 (The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means), V8 (Verus is sound: what it reports verified holds of the code), V9 (Z3, the solver Verus calls, is sound), V10 (rustc compiles the verified code to a program with the semantics Verus checked), V11 (Kani and its back end CBMC are sound within the stated bounds).
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
crash-activating-bad | fake, vm | planariad is killed during Activating of a bad candidate; the watchdog takes the lease, fences, confirms quiescence, and reverts. | boot_default: base; generation: base; invariants: I6, I12, I15; journal_agrees: true; override_source: base; txn: Reverted ApplyChange; units: service:gym-web Running | fake: PASS; VM: not run in this build |
crash-activating-good | fake, vm | planariad is killed during Activating of a good candidate. The watchdog sees the candidate active, goes to Checking, the probes pass, and it runs the entire commit path. | boot_default: candidate; generation: candidate; invariants: I6, I12, I15; journal_agrees: true; override_source: candidate; txn: Committed ApplyChange | fake: PASS; VM: not run in this build |
crash-simultaneous-takeover | fake, vm | The daemon and the watchdog try to take over at the same moment; exactly one wins the flock and the other backs off. | invariants: I15; journal_agrees: true; lease_epochs_strictly_increasing: true; owners_per_epoch_max: 1 | fake: PASS; VM: not run in this build |
crash-slow-worker-overlap | fake, vm | The daemon is killed and immediately restarted while a deliberately slow activation worker survives. The new owner fences it, so activation and revert never overlap and the old owner can never commit. | generation: base; invariants: I6, I15; journal_agrees: true; no_overlap: activate_live(candidate), activate_live(revert_to); stale_owner_commit: false | fake: PASS; VM: not run in this build |
crash-withheld-job-reply | fake | The backend accepts an activation job but its reply is withheld, and the owner is killed before the job id is journaled. Recovery must discover the job by its OpId, or fence it, before making any conflicting mutation. | invariants: I6, I15; journal_agrees: true; no_overlap: activate_live(candidate), activate_live(revert_to); quiescence_includes_tagged_jobs: true | fake: PASS |
en-i15-old-worker-cannot-finish | fake, vm | The daemon is killed mid-activation and stays down; the watchdog takes the generation change over while the old activation worker is still running. From the takeover on, only the watchdog’s lease advances the transaction or journals a call, the old worker’s activation never overlaps the watchdog’s revert, and each epoch has one owner. | journal_owner_after_takeover: watchdog; no_overlap: activate_live(candidate), activate_live(revert_to); owners_per_epoch_max: 1 | fake: PASS; VM: not run in this build |
fake-assumptions-conformance | fake | The transaction kernel against the fake backend, once per docs/assumptions.md entry and injected outcome. Each run must satisfy the entry’s contract and reach a terminal state (I12). | invariants: I6, I12, I15; reports: AssumptionHeld(<assumption>) | fake: PASS |
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
| X-13 | The network hold (§12d Network security fails closed): planariad applies and lifts planarian-hold@<unit> with no Prepared record, no lease and no op tag, mirroring systemd’s OnFailure=; the hold drops connectivity without asking | Accepted | Accepted | A |
History
See also
planarian_txn::ledger in rustdoc
I16 · No automatic revert, rollback, or boot fallback selects a generation below the security …
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
No automatic revert, rollback, or boot fallback selects a generation below the security floor; going below it requires administrator approval (§12e). The floor is derived from the whole system closure (§12e); a fix outside the inspected set does not move it.
— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Proved | A (proof of the decide and target-selection parts; the bootloader part by test) | 2 : 3 (baseline 2 : 3, not degraded) | 5 entries, 5 must-pass |
How it is proved
2 obligations, in crates/policy/src/proofs.rs; every one is checked on every CI run.
| Obligation | Kind | What it guarantees | Source |
|---|---|---|---|
i16_below_floor_needs_administrator | proof fn | I16 (the decide part): a transition whose target generation is below the security floor is AskFirst for an administrator, or refused, whatever the autonomy setting, the transition kind, or the caller. | proofs.rs:161 |
administrator_class_only_on_ask_first | proof fn | An Administrator approver class only ever comes with AskFirst. | proofs.rs:170 |
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| V1 | vstd’s specifications of Vec, String::as_str, str::unicode_len, str::get_char | Proof-validity | I5, I10, I11, I19, I21, I22 |
| V2 | Value is an opaque external type (external_type_specification); nothing about it is assumed | Proof-validity | I5, I11, I21 |
| P3 | publish_boot and the override promotion rename are idempotent | Platform | I1, I23 |
Every proof also rests on V7 (The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means), V8 (Verus is sound: what it reports verified holds of the code), V9 (Z3, the solver Verus calls, is sound), V10 (rustc compiles the verified code to a program with the semantics Verus checked).
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
en-i16-boot-fallback-respects-floor | fake | After a security update raises the floor, the machine restarts and the boot fails. The automatic fallback does not select a generation below the floor: the boot default and the generation that boots are the floor. | boot_default: candidate; generation: candidate; security_floor: candidate | fake: PASS |
floor-manual-rollback-needs-admin | fake | After the security update, an administrator asks to roll back below the floor: AskFirst for an Administrator approver. The proposer cannot approve it (I19); root at a terminal can. | generation: base; security_floor: candidate; txn: Committed Rollback | fake: PASS |
floor-moves-via-watchdog | fake | The daemon is killed once the security update is Promoted and stays down; the watchdog completes the commit. The floor moves to the fixing generation: the floor record is part of the commit, whoever writes Committed. | security_floor: candidate; txn: Committed ApplyChange | fake: PASS |
floor-revert-blocked | fake | A security update (gym-vulnlib 1.0 → 1.1, the old build carries knownVulnerabilities) commits and raises the floor. gym-web then crashes and fails every restart. The only rollback target, known_good, is below the floor: no Rollback runs, the incident escalates with RevertBlockedByFloor, and the boot default stays at the floor. | boot_default: candidate; escalation: RevertBlockedByFloor; generation: candidate; incident: service:gym-web; invariants: I1, I16; security_floor: candidate; txn: Committed ApplyChange, Rollback | fake: PASS |
floor-service-package-fix | vm | A change whose only security fix is in a service’s package (gym-vulnsvc 1.0 -> 1.1; services.gym-vulnsvc.package, never in environment.systemPackages) commits. The security floor moves to it: the inspected set is the configuration’s whole package set, not environment.systemPackages. | security_floor: candidate; txn: Committed ApplyChange | VM: not run in this build |
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| B-19 | A package referenced only by a store-path string (an ExecStart= naming /nix/store/… directly, a script’s interpolated path) has no meta to read at evaluation, so a fix to it does not move the security floor | Medium | Open | B |
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4.4 | Invariants I16 (security floor), I17 (administrator set non-empty), I18 (hash-chained journal), I19 (no self-approval, no self-escalation), I20 (no agent without the monitor) |
| v4.4 | Phase A proof targets add I16 (in decide and target selection) and I19 |
| v4.4 | rule 4’s preserve list extended to I10–I20 |
| v4.5 | I16 states the inspected set (B-2) |
See also
§12e · planarian_policy::proofs in rustdoc
I17 · The set of principals holding administrator capability is never empty
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The set of principals holding administrator capability is never empty.
— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Proved | A (proof, given root exists) | 2 : 2 (baseline 0 : 2, not degraded) | 1 entry, 1 must-pass |
How it is proved
2 obligations, in crates/policy/src/lib.rs, crates/policy/src/proofs.rs; every one is checked on every CI run.
| Obligation | Kind | What it guarantees | Source |
|---|---|---|---|
is_administrator | verified fn | (no doc comment) | lib.rs:1014 |
i17_administrators_never_empty | proof fn | I17 (A, given root exists): root holds the administrator capability whatever the group file says, so the set of principals holding it is never empty; no change to the group can empty it. | proofs.rs:327 |
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| P8 | The kernel and systemd are correct | Platform | I1, I2, I3, I9, I20, I22 |
| U2 | user_authorization and the administrator lookup (planarian-admin membership, root) | Internal unproven | I19, I22 |
Every proof also rests on V7 (The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means), V8 (Verus is sound: what it reports verified holds of the code), V9 (Z3, the solver Verus calls, is sound), V10 (rustc compiles the verified code to a program with the semantics Verus checked).
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
en-i17-root-administers-without-the-group | fake | Nobody is left in planarian-admin, so the group grants no one administrator capability. Root still holds it: root’s administrator-only change (gym-web’s listen address) is authorized and commits. | txn: Committed ApplyChange; units: service:gym-web Running | fake: PASS |
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4.4 | Invariants I16 (security floor), I17 (administrator set non-empty), I18 (hash-chained journal), I19 (no self-approval, no self-escalation), I20 (no agent without the monitor) |
| v4.4 | rule 4’s preserve list extended to I10–I20 |
| v4.6 | the Phase A proof status stated, with the residue Kani harnesses and the I17 proof named as still required [D4] |
See also
planarian_policy in rustdoc · planarian_policy::proofs in rustdoc
I18 · Every journal record commits to the hash of its predecessor
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Every journal record commits to the hash of its predecessor; a broken chain is detected on read and treated as an integrity failure (§12c, §12d). This holds against damage and against anyone who cannot rewrite the whole journal and checkpoint consistently; root can, until the head is anchored off the disk (§12c).
— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Proved | A (proof, trusting SHA-256) | 5 : 3 (baseline 5 : 3, not degraded) | 3 entries, 3 must-pass |
How it is proved
5 obligations, in crates/txn/src/chain.rs; every one is checked on every CI run.
| Obligation | Kind | What it guarantees | Source |
|---|---|---|---|
follows | verified fn | The check every read path applies to each record (journal::scan). | chain.rs:97 |
verify | verified fn | Check a segment (journal::verify_chain): accepts exactly a chained segment, and otherwise reports its first break. | chain.rs:120 |
next_link | verified fn | The seq and prev the writer gives the next record (journal::append): the next seq, and the current head. | chain.rs:168 |
appending_keeps_the_chain | proof fn | I18’s writing half: a chained segment with a record appended the way next_link makes it is still chained. By induction, every journal the writer produces is chained, so a break on read is damage or an edit. | chain.rs:182 |
an_edited_record_breaks_the_chain | proof fn | An edit is caught: if a record’s next changes (its payload was edited, so its hash changed, P5) while the record after it is untouched, the segment no longer verifies. | chain.rs:214 |
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| P5 | SHA-256 is collision-resistant (the journal’s hash chain, I18) | Platform | I2, I8 |
| P4 | Journal fsync is durable and the preallocation is writable | Platform | I6, I7, I12, I15, I24, I25 |
| U4 | Journal I/O around the verified chain: framing, torn-tail truncation, checkpoint replace | Internal unproven | — |
Every proof also rests on V7 (The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means), V8 (Verus is sound: what it reports verified holds of the code), V9 (Z3, the solver Verus calls, is sound), V10 (rustc compiles the verified code to a program with the semantics Verus checked).
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
en-i18-deleted-record-detected | fake, vm | A retained journal record is deleted (not edited) and the rest re-framed so every frame is intact. The next read detects the break as an Integrity failure. | posture: class Integrity; reports: JournalChainBroken | fake: PASS; VM: not run in this build |
journal-record-edited | fake, vm | A retained journal record is edited on disk while the daemon runs. The next read detects the broken chain as an Integrity failure; no transaction is taken from the edited record. | invariants: I18; posture: class Integrity, target_reached planarian-failed@Integrity.target; reports: JournalChainBroken | fake: PASS; VM: not run in this build |
posture-integrity-journal-chain | vm | A record in the hash-chained journal is altered while the machine is off. The boot report finds the broken chain (I18): Integrity responses, as above. | invariants: I18, I20; posture: class Integrity, responses [“alarm”, “notify_admins”, “disable_agents”, “restrict_remote”], target_reached planarian-failed@Integrity.target; reports: BootReport(JournalChainBroken) | VM: not run in this build |
Also tested by txn journal tests.
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| B-8 | The journal hash chain is unkeyed; root can rewrite it consistently | Medium | Open (v4.5 I18, §12c) | A wording; C witness; E anchor |
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4.4 | Invariants I16 (security floor), I17 (administrator set non-empty), I18 (hash-chained journal), I19 (no self-approval, no self-escalation), I20 (no agent without the monitor) |
| v4.4 | rule 4’s preserve list extended to I10–I20 |
| v4.5 | I18 states whom the chain holds against (B-8) |
See also
§12c · §12d · planarian_txn::chain in rustdoc
I19 · No principal approves its own proposal, and no principal grants itself, or a principal i…
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
No principal approves its own proposal, and no principal grants itself, or a principal it controls, a capability it does not hold.
— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Proved | A (proof), C | 3 : 3 (baseline 3 : 3, not degraded) | 3 entries, 3 must-pass |
How it is proved
3 obligations, in crates/policy/src/lib.rs, crates/policy/src/proofs.rs; every one is checked on every CI run.
| Obligation | Kind | What it guarantees | Source |
|---|---|---|---|
i19_no_self_approval | proof fn | I19: no approver approves its own proposal, and an Administrator class is approved only by an administrator; nobody grants a capability to themselves or one they do not hold. | proofs.rs:215 |
may_approve | verified fn | (no doc comment) | lib.rs:923 |
may_grant | verified fn | (no doc comment) | lib.rs:942 |
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| V1 | vstd’s specifications of Vec, String::as_str, str::unicode_len, str::get_char | Proof-validity | I5, I10, I11, I16, I21, I22 |
| U2 | user_authorization and the administrator lookup (planarian-admin membership, root) | Internal unproven | I17, I22 |
| U5 | Identity resolution (§10): peer credentials and pidfd to logind session, seat, terminal | Internal unproven | I21, I24 |
Every proof also rests on V7 (The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means), V8 (Verus is sound: what it reports verified holds of the code), V9 (Z3, the solver Verus calls, is sound), V10 (rustc compiles the verified code to a program with the semantics Verus checked).
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
authz-user-overrides-firewall | fake | From her session, alice overrides the firewall (sshd’s stand-in in the fake: a Security unit) to stopped. Held for an administrator; it stays up. alice cannot approve her own request (I19); root at a terminal approves, and the override holds, attested and attributed to alice. | invariants: I19; overrides: service:sshd; txn: Committed SetOverride | fake: PASS |
en-i19-no-self-approval | fake, vm | Root proposes an AskFirst change and then, at a terminal, tries to approve it: refused, and the change never runs. (A principal granting itself a capability has no request to make in Phase A: capabilities come from group membership only.) | txn: pending ApplyChange; units: service:sshd Running | fake: PASS; VM: not run in this build |
floor-manual-rollback-needs-admin | fake | After the security update, an administrator asks to roll back below the floor: AskFirst for an Administrator approver. The proposer cannot approve it (I19); root at a terminal can. | generation: base; security_floor: candidate; txn: Committed Rollback | fake: PASS |
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4.4 | Invariants I16 (security floor), I17 (administrator set non-empty), I18 (hash-chained journal), I19 (no self-approval, no self-escalation), I20 (no agent without the monitor) |
| v4.4 | Phase A proof targets add I16 (in decide and target selection) and I19 |
| v4.4 | rule 4’s preserve list extended to I10–I20 |
See also
planarian_policy in rustdoc · planarian_policy::proofs in rustdoc
I20 · No agent (the on-device model, a remote relay, or any agent started by `planarian agent …
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
No agent (the on-device model, a remote relay, or any agent started by
planarian agent run) starts whileplanarian-ready.targetis not reached, and every running agent is stopped when a failure posture engages, in every posture (§12d). Agents the launcher creates are inside this rule exactly as the units declared inservices.planarian.agentUnits(the model service and any relay) are.— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Tested, not proved | A | 0 : 2 (baseline 0 : 2, not degraded) | 7 entries, 7 must-pass |
How it is proved
No obligation discharges it: nothing about it is proved.
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| P6 | systemd reports unit state truthfully over D-Bus and honors X-RestartIfChanged / X-StopIfChanged | Platform | I4, I13, I24 |
| P8 | The kernel and systemd are correct | Platform | I1, I2, I3, I9, I17, I22 |
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
posture-absent-masked | vm | planariad is masked and the machine rebooted. planarian-ready.target times out; Standard’s Absent responses apply; the agent stand-in never starts; unmasking and a clean ready lifts them. | invariants: I20; not_started: service:gym-agent; posture: class Absent, not [“restrict_login”, “halt_to_recovery”, “alarm_dismissable”], responses [“alarm”, “notify_admins”, “disable_agents”, “restrict_remote”], target… | VM: not run in this build |
posture-crashed-budget | vm | planariad reaches ready, then is killed until its restart budget is spent. OnFailure= reaches planarian-failed@Crashed; responses as Absent; agents stop; an administrator’s acknowledgement is journaled with identity. | invariants: I20; posture: class Crashed, not [“restrict_login”], responses [“alarm”, “notify_admins”, “disable_agents”, “restrict_remote”], target_reached planarian-failed@Crashed.target; reports: AlarmAcknowledged(use… | VM: not run in this build |
posture-integrity-core-hash | vm | The recorded core hash no longer matches its signature (I2). planarian-recovery’s independent check fails the boot as Integrity: tamper alarm, notify, no agents, remote access off; local login proceeds with the warning. Only an administrator clears it. | invariants: I2, I20; not_started: service:gym-agent; posture: class Integrity, not [“halt_to_recovery”], responses [“alarm”, “notify_admins”, “disable_agents”, “restrict_remote”], target_reached planarian-failed@Integr… | VM: not run in this build |
posture-integrity-core-script | vm | One posture script (planarian-ack), not planariad, no longer matches its line in the core-hash record. planarian-recovery checks every Planarian binary and script, so the boot fails as Integrity all the same (B-12). | invariants: I2; posture: class Integrity, target_reached planarian-failed@Integrity.target | VM: not run in this build |
posture-integrity-journal-chain | vm | A record in the hash-chained journal is altered while the machine is off. The boot report finds the broken chain (I18): Integrity responses, as above. | invariants: I18, I20; posture: class Integrity, responses [“alarm”, “notify_admins”, “disable_agents”, “restrict_remote”], target_reached planarian-failed@Integrity.target; reports: BootReport(JournalChainBroken) | VM: not run in this build |
posture-integrity-undeclared-early-unit | vm | A unit outside the generation’s closure is planted to start before planarian-ready. The boot report names it and Integrity responses apply. (Phase A’s boot report checks units only, and planariad stops on the finding rather than opening an incident: catalog review G18.) | posture: class Integrity, responses [“alarm”, “notify_admins”, “disable_agents”, “restrict_remote”], target_reached planarian-failed@Integrity.target; reports: BootReport(UndeclaredEarly: service:gym-planted) | VM: not run in this build |
posture-stops-launched-agents | vm | An administrator launches an agent with planarian agent run (in planarian-agents.slice). planariad then crash-loops past its restart budget: planarian-failed@Crashed stops the agents’ slice as a whole, so the launched agent stops too, not only the declared agent units. | invariants: I20; posture: class Crashed, responses [“disable_agents”], target_reached planarian-failed@Crashed.target; unit_after_failure: service:planarian-agent-launched Stopped | VM: not run in this build |
Also tested by vm-posture.
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| B-9 | Posture alarms and notification are local; root can clear them and a stopped planariad can go unnoticed | Medium | Open (v4.5 §12d) | C |
| B-10 | The boot report checks units, not processes | Medium | Open (v4.5 §10) | C |
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-3 | Firmware, bootloader and initrd run before planarian-ready.target | Accepted | Accepted | C, E |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4.4 | Invariants I16 (security floor), I17 (administrator set non-empty), I18 (hash-chained journal), I19 (no self-approval, no self-escalation), I20 (no agent without the monitor) |
| v4.4 | rule 4’s preserve list extended to I10–I20 |
| v4.6 | I20: launched agents inside the rule [R05] |
See also
I21 · No transition that cannot be reversed, or that may lose a session, user data or connecti…
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
No transition that cannot be reversed, or that may lose a session, user data or connectivity, executes below AskFirst, unless the loss falls only on the caller’s own session (and, for connectivity, the caller has a seat); a loss reaching another user’s session requires administrator approval (§11 floors). Garbage collection of generations outside the protected set is Runtime by §12b, not irreversible loss in this sense (owner decision, 2026-10-30);
gc.retainAllGenerationskeeps every generation.— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Proved | A (proof) | 2 : 4 (baseline 2 : 4, not degraded) | 4 entries, 4 must-pass |
How it is proved
2 obligations, in crates/policy/src/proofs.rs; every one is checked on every CI run.
| Obligation | Kind | What it guarantees | Source |
|---|---|---|---|
global_floors | proof fn | The global floors (§11), for every transition kind. | proofs.rs:129 |
own_session_is_per_session | proof fn | “Own session” is per session: a user whose other session (or no session) would be disrupted gets the floor, and so does a disruption whose sessions are not known. | proofs.rs:143 |
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| V1 | vstd’s specifications of Vec, String::as_str, str::unicode_len, str::get_char | Proof-validity | I5, I10, I11, I16, I19, I22 |
| V2 | Value is an opaque external type (external_type_specification); nothing about it is assumed | Proof-validity | I5, I11, I16 |
| U1 | exec::derive: DerivedFacts from the evaluated generation, never from a proposal (I14’s other half; property-tested) | Internal unproven | I10, I14, I22 |
| U5 | Identity resolution (§10): peer credentials and pidfd to logind session, seat, terminal | Internal unproven | I19, I24 |
Every proof also rests on V7 (The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means), V8 (Verus is sound: what it reports verified holds of the code), V9 (Z3, the solver Verus calls, is sound), V10 (rustc compiles the verified code to a program with the semantics Verus checked).
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
en-i21-irreversible-asks-even-for-root | fake | Root, at maximum autonomy, asks to restore a snapshot over user data: it cannot be reversed. It is held at AskFirst, whoever asks and whatever the setting. | txn: RestoreSnapshot | fake: PASS |
gc-retain-all-generations | fake | With services.planarian.gc.retainAllGenerations set and the generation budget far below what the generations use, four display-scale changes leave old generations behind; garbage collection then collects none of them (the full history stays), and reports the budget as unsatisfiable rather than enforcing it. | collected_none: true; reports: BudgetUnsatisfiable; txn: Committed CollectGarbage | fake: PASS |
identity-peer-exited | vm | alice, logged in at the seat, sends a stop of her own pipewire and exits before the daemon resolves her. With no process behind the connection, the daemon attributes the request to uid 1000 with no session: never to a session it cannot read from that process. | txn: StopAndSuppress | VM: not run in this build |
override-stopped-equals-stop | fake, vm | An administrator with no session here (over SSH) asks, in turn, for an override that stops the display manager and for a plain stop of it. Both end the seated user’s session: both derive SessionLoss over the same units and both are held at AskFirst, since it is not the caller’s own session. | txn: SetOverride, StopAndSuppress; units: service:display-manager Running | fake: PASS; VM: not run in this build |
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| B-4 | decide is proved, but its inputs (DerivedFacts, Authorization) are computed by unproven code | Medium | Open (v4.5 §17a) | A accounting; C proof |
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
| X-13 | The network hold (§12d Network security fails closed): planariad applies and lifts planarian-hold@<unit> with no Prepared record, no lease and no op tag, mirroring systemd’s OnFailure=; the hold drops connectivity without asking | Accepted | Accepted | A |
History
No changes section names it.
See also
§11 · §12b · planarian_policy::proofs in rustdoc
I22 · Changing the generation, collecting garbage, binding drivers, resolving the recovery int…
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Changing the generation, collecting garbage, binding drivers, resolving the recovery interlock and overriding a Security-sensitive unit are administrator operations; a Security-sensitive unit is changed without approval only to restore its declared state (§11); the unattributed actor holds no authority, and an agent holds exactly its user’s (§11).
— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Proved | A (proof) | 5 : 4 (baseline 5 : 4, not degraded) | 3 entries, 2 must-pass |
How it is proved
5 obligations, in crates/policy/src/proofs.rs; every one is checked on every CI run.
| Obligation | Kind | What it guarantees | Source |
|---|---|---|---|
users_cannot_change_generations | proof fn | A user who is not an administrator cannot change generations, drivers, models, snapshots or the store. | proofs.rs:276 |
only_administrators_resolve | proof fn | Only an administrator can clear the recovery interlock (§12c). | proofs.rs:266 |
security_override_held_for_administrator | proof fn | §11: a non-administrator’s override of a Security-sensitive unit is held for an administrator, never rejected as unauthorized and never run on its own; other rejections (the core, a lock) still apply. | proofs.rs:179 |
security_restart_runs_alone_only_to_restore | proof fn | §11 (owner gap G16, decided 2026-10-29): a restart of a Security-sensitive unit runs below AskFirst only when it restores a unit stopped outside Planarian and not attributed, and the unit is not also External, or is but has already failed; or when it restarts a failed network-security unit whose traffic is held (§12d; owner decision, Nov 2). | proofs.rs:196 |
agents_and_unattributed_are_refused | proof fn | Neither an agent principal with no journaled start (reserved), nor the model (until Phase B grants it anything), nor the unattributed actor is ever authorized: an inferred override never passes through decide (§11). | proofs.rs:227 |
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| V1 | vstd’s specifications of Vec, String::as_str, str::unicode_len, str::get_char | Proof-validity | I5, I10, I11, I16, I19, I21 |
| P8 | The kernel and systemd are correct | Platform | I1, I2, I3, I9, I17, I20 |
| U1 | exec::derive: DerivedFacts from the evaluated generation, never from a proposal (I14’s other half; property-tested) | Internal unproven | I10, I14, I21 |
| U2 | user_authorization and the administrator lookup (planarian-admin membership, root) | Internal unproven | I17, I19 |
Every proof also rests on V7 (The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means), V8 (Verus is sound: what it reports verified holds of the code), V9 (Z3, the solver Verus calls, is sound), V10 (rustc compiles the verified code to a program with the semantics Verus checked).
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
en-i22-user-cannot-do-admin-things | fake, vm | A seated, unprivileged user tries, in turn, to roll back, collect garbage, bind a driver and resolve the interlock. Each is refused Unauthorized; nothing executes. | no_txn_executed: true; reports: Unauthorized; txn: Rollback, CollectGarbage, BindDriver, Resolve | fake: PASS; VM: not run in this build |
residue-kill9-security | fake, vm | A raw kill -9 of sshd (Security-sensitive) is a fault under every posture: alarmed, never inferred, and back up: restored by Planarian on its own (§11; catalog-review G16, decided 2026-10-29), or by systemd where its Restart= does it first (NixOS’s sshd), which is then alarmed from systemd’s restart count. | overrides: ; reports: alarm; units: service:sshd Running | fake: PASS; VM: not run in this build |
residue-stop-security-unit | fake, vm | Root stops sshd (Security-sensitive) with systemctl stop outside Planarian. An unattributed stop of a Security unit is never taken as intended: it is restored on its own and alarmed. | overrides: ; reports: alarm; txn: service:sshd RestartUnit; units: service:sshd Running | fake: PASS; VM: not run in this build |
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| B-4 | decide is proved, but its inputs (DerivedFacts, Authorization) are computed by unproven code | Medium | Open (v4.5 §17a) | A accounting; C proof |
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
| X-10 | A program a user runs under their own uid, outside planarian agent run (an agent started by hand from a shell) | Accepted | Accepted | C |
| X-11 | An agent holds its user’s capabilities, including Administrator when an administrator starts it (owner decision, 2026-10-30) | Accepted | Accepted | — |
History
No changes section names it.
See also
§11 · planarian_policy::proofs in rustdoc
I23 · A generation transaction commits only after its boot entry is written and promoted
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A generation transaction commits only after its boot entry is written and promoted; a runtime transaction never publishes, promotes or reverts; the override file is replaced only under the daemon’s exclusive lock on its directory and only when its content hash is the one recorded at
Prepared(§9 step 4). A writer that does not take the lock is outside this guarantee (docs/bypasses.md, X-12).— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Proved | A (proof) | 3 : 1 (baseline 3 : 1, not degraded) | 2 entries, 2 must-pass |
How it is proved
3 obligations, in crates/txn/src/kani.rs, crates/txn/src/machine.rs; every one is checked on every CI run.
| Obligation | Kind | What it guarantees | Source |
|---|---|---|---|
generations_commit_after_promotion | proof fn | A generation operation commits only after it was published and promoted. | machine.rs:206 |
runtime_never_reverts | proof fn | Runtime operations never revert a generation and never publish (§12). | machine.rs:171 |
override_swap_never_overwrites_a_third_party | Kani harness | The override swap (§9 step 4) never overwrites a file a third party changed: it swaps only when the live file holds the expected source, and is done exactly when it already holds the target. After a completed swap, the revert direction swaps back. | kani.rs:261 |
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| P3 | publish_boot and the override promotion rename are idempotent | Platform | I1, I16 |
Every proof also rests on V7 (The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means), V8 (Verus is sound: what it reports verified holds of the code), V9 (Z3, the solver Verus calls, is sound), V10 (rustc compiles the verified code to a program with the semantics Verus checked), V11 (Kani and its back end CBMC are sound within the stated bounds).
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
en-i23-third-party-override-kept | fake, vm | Between a change’s boot entry being published and its override promotion, a third party rewrites the override file. Planarian does not overwrite it: the third party’s file stands, and the change does not commit (it stops at RecoveryFailed for a person to resolve). Before it, a runtime restart of gym-web wrote no boot entry. On the VM the third party is a cooperating writer: it holds the lock on planarian/ across the daemon’s compare-and-swap and replaces the file before letting go, so the swap waits and then sees the edit (R11). | override_file: third-party; txn: RecoveryFailed ApplyChange, service:gym-web Committed RestartUnit; units: service:gym-web Running | fake: PASS; VM: not run in this build |
stale-owner-edit-during-build | fake, vm | During the build, the owner edits a tracked file without committing it. The transaction is Rejected(Stale), never activated, and its GC root is released. | gc_root_released: true; generation: base; override_source: base; txn: Rejected(Stale) ApplyChange | fake: PASS; VM: not run in this build |
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
| X-12 | A writer that does not take the daemon’s lock on the override directory (an editor, a script) replaces planarian/overrides.nix between the daemon’s comparison and its rename | Accepted | Accepted | C (writer boundary) |
History
| Spec | Change |
|---|---|
| v4.6 | I23: the override CAS is under the daemon’s lock |
See also
§9 · planarian_txn::machine in rustdoc
I24 · An agent’s authority is bound to the one AgentStarted record for the unit instance it …
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An agent’s authority is bound to the one
AgentStartedrecord for the unit instance it runs in, identified by that instance (not by name); a second start under the same name neither rebinds a running agent nor is journaled before it has succeeded (§11, §16).— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Tested, not proved | A | 0 : 3 (baseline 0 : 3, not degraded) | 1 entry, 1 must-pass |
How it is proved
No obligation discharges it: nothing about it is proved.
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| P4 | Journal fsync is durable and the preallocation is writable | Platform | I6, I7, I12, I15, I18, I25 |
| P6 | systemd reports unit state truthfully over D-Bus and honors X-RestartIfChanged / X-StopIfChanged | Platform | I4, I13, I20 |
| U5 | Identity resolution (§10): peer credentials and pidfd to logind session, seat, terminal | Internal unproven | I19, I21 |
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
agent-name-collision | fake, vm | alice (uid 1000) starts an agent named helper. An administrator then runs planarian agent run helper too: refused, naming alice as its owner. alice’s agent stays alice’s: its stop of gym-web, a system unit she may not stop, is refused. Authority is bound to the instance a start journaled, never the name (I24). | reports: AgentNameInUse; txn: service:gym-web StopAndSuppress; units: service:gym-web Running | fake: PASS; VM: not run in this build |
Also tested by daemon core tests.
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
| X-10 | A program a user runs under their own uid, outside planarian agent run (an agent started by hand from a shell) | Accepted | Accepted | C |
History
| Spec | Change |
|---|---|
| v4.6 | I24: agent authority bound per instance |
See also
I25 · Post-commit bookkeeping (security-floor derivation, the committed generation’s override …
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Post-commit bookkeeping (security-floor derivation, the committed generation’s override content, session accounting) runs on every path that writes
Committedfor a generation transaction: the daemon’s, the watchdog’s, the boot-only confirmation’s. A commit whose floor record cannot be appended does not commit (§12, §12e).— SPEC §7
| Status | Proof phase | d:a | Gym |
|---|---|---|---|
| Tested, not proved | A | 0 : 2 (baseline 0 : 2, not degraded) | 3 entries, 3 must-pass |
How it is proved
No obligation discharges it: nothing about it is proved.
What it rests on
If any of these is false, the proof no longer describes the real machine.
| Id | Assumption | Kind | Also relied on by |
|---|---|---|---|
| P1 | Backend Outcome is honest: Succeeded means applied; Unknown means the backend cannot say | Platform | I3, I12 |
| P4 | Journal fsync is durable and the preallocation is writable | Platform | I6, I7, I12, I15, I18, I24 |
How it is tested
| Entry | Harness | What it does | Expects | Latest verdict |
|---|---|---|---|---|
floor-moves-via-boot-only | fake | A security update that also needs a reboot takes the boot-only path: published to boot once, the machine restarts, the boot is confirmed and the change commits. The floor moves to it, as on the live path. | security_floor: candidate; txn: Committed ApplyChange | fake: PASS |
floor-moves-via-watchdog | fake | The daemon is killed once the security update is Promoted and stays down; the watchdog completes the commit. The floor moves to the fixing generation: the floor record is part of the commit, whoever writes Committed. | security_floor: candidate; txn: Committed ApplyChange | fake: PASS |
revert-to-older-sets-no-floor | fake | gym-newlib 1.0 is added, then a release makes it 2.0, which carries knownVulnerabilities. Undoing back to the 1.0 generation is a Revert to an older generation that merely lacks the newer package: it sets no security floor (§12e). | security_floor: none; txn: Committed Revert | fake: PASS |
Known limits
| Bypass | Route | Severity | Status | Closes in |
|---|---|---|---|---|
| X-1 | Root edits the store, /boot, the binaries, the journal or the posture state | Accepted | Accepted | E |
| X-2 | A nixos-rebuild outside Planarian | Accepted | Accepted | C |
| X-4 | Physical access | Accepted | Accepted | E |
| X-6 | The supply chain: nixpkgs content, cache signatures, input-addressed store paths | Accepted | Accepted | — |
| X-7 | Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8) | Accepted | Accepted | D, E |
History
| Spec | Change |
|---|---|
| v4.6 | I25: post-commit bookkeeping on every commit path |
| v4.6 | rule 4’s preserve list to I25 [D5] |
See also
Assumptions
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
| Id | Kind | Assumption | Invariants |
|---|---|---|---|
| G1 | Platform (progress) | Some lease holder eventually runs. | I12 |
| G2 | Platform (progress) | A fenced worker eventually stops issuing calls, or its calls are refused. | I12 |
| G3 | Platform (progress) | Journal writes eventually succeed. | I12 |
| G4 | Platform (progress) | Backend calls terminate. | I12 |
| P1 | Platform | Backend Outcome is honest: Succeeded means applied; Unknown means the backend cannot say | I3, I12, I25 |
| P10 | Platform | A pidfd names one process for as long as it is held, and SO_PEERPIDFD (Linux ≥ 6.5) names the process that connected; so a recycled pid can never be mistaken for the peer (§10, B-6). The gym cannot exercise pid reuse itself: Linux hands out pids in increasing order and wraps only after pid_max (4 194 304 on 64-bit systems, 32 768 on some), so the same pid coming back to another process of the same uid, in another session, inside one request’s lifetime, cannot be arranged reproducibly. identity-peer-exited checks the case the pidfd rule decides (a peer gone before it is resolved). The rarity is not the protection; the pidfd is. | |
| P2 | Platform | activate_live(revert_to) restores the activation state of revert_to | |
| P3 | Platform | publish_boot and the override promotion rename are idempotent | I1, I16, I23 |
| P4 | Platform | Journal fsync is durable and the preallocation is writable | I6, I7, I12, I15, I18, I24, I25 |
| P5 | Platform | SHA-256 is collision-resistant (the journal’s hash chain, I18) | I2, I8, I18 |
| P6 | Platform | systemd reports unit state truthfully over D-Bus and honors X-RestartIfChanged / X-StopIfChanged | I4, I13, I20, I24 |
| P7 | Platform | Fencing is effective: a stale-epoch call is refused before it mutates | I12, I15 |
| P8 | Platform | The kernel and systemd are correct | I1, I2, I3, I9, I17, I20, I22 |
| U1 | Internal unproven | exec::derive: DerivedFacts from the evaluated generation, never from a proposal (I14’s other half; property-tested) | I10, I14, I21, I22 |
| U2 | Internal unproven | user_authorization and the administrator lookup (planarian-admin membership, root) | I17, I19, I22 |
| U3 | Internal unproven | The residue classifier (§11: which unit endings are a person’s stop) | I13 |
| U4 | Internal unproven | Journal I/O around the verified chain: framing, torn-tail truncation, checkpoint replace | I18 |
| U5 | Internal unproven | Identity resolution (§10): peer credentials and pidfd to logind session, seat, terminal | I19, I21, I24 |
| V1 | Proof-validity | vstd’s specifications of Vec, String::as_str, str::unicode_len, str::get_char | I5, I10, I11, I16, I19, I21, I22 |
| V10 | Proof-validity | rustc compiles the verified code to a program with the semantics Verus checked. | every proof |
| V11 | Proof-validity | Kani and its back end CBMC are sound within the stated bounds. | every proof |
| V2 | Proof-validity | Value is an opaque external type (external_type_specification); nothing about it is assumed | I5, I11, I16, I21 |
| V3 | Proof-validity | Serde impls are unverified (external_derive) | I5, I6, I14 |
| V4 | Proof-validity | Expectation, CheckContext, ApprovalToken, Incident are opaque external types | I6 |
| V5 | Proof-validity | Ledger bookkeeping helpers (push_tag, tag_string, note_why, archive, set_override, lift_override, record_incident, use_token) are external_body with no postcondition: each receives only the field it updates, so the verified fields are untouched by construction; trusted only to terminate | I6, I12 |
| V6 | Proof-validity | vstd’s specifications of Vec::push, insert, remove, is_empty and Box | I6 |
| V7 | Proof-validity | The specifications the proofs prove against (decide_spec, step_spec, inv, chained, the lemma statements) say what the SPEC’s English means. A proof certifies a wrong specification as readily as a right one; each proved invariant has a gym entry written from the English to catch the difference (§17a). | every proof |
| V8 | Proof-validity | Verus is sound: what it reports verified holds of the code. | every proof |
| V9 | Proof-validity | Z3, the solver Verus calls, is sound. | every proof |
G1 · Platform (progress)
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Some lease holder eventually runs.
If it is false
These invariants fall: I12.
G2 · Platform (progress)
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A fenced worker eventually stops issuing calls, or its calls are refused.
If it is false
These invariants fall: I12.
G3 · Platform (progress)
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Journal writes eventually succeed.
If it is false
These invariants fall: I12.
G4 · Platform (progress)
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Backend calls terminate.
If it is false
These invariants fall: I12.
P1 · Platform
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Backend
Outcomeis honest:Succeededmeans applied;Unknownmeans the backend cannot say
If it is false
These invariants fall: I3, I12, I25.
P10 · Platform
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A pidfd names one process for as long as it is held, and
SO_PEERPIDFD(Linux ≥ 6.5) names the process that connected; so a recycled pid can never be mistaken for the peer (§10, B-6). The gym cannot exercise pid reuse itself: Linux hands out pids in increasing order and wraps only afterpid_max(4 194 304 on 64-bit systems, 32 768 on some), so the same pid coming back to another process of the same uid, in another session, inside one request’s lifetime, cannot be arranged reproducibly.identity-peer-exitedchecks the case the pidfd rule decides (a peer gone before it is resolved). The rarity is not the protection; the pidfd is.
Sites: daemon::session_of
If it is false
No invariant names it in docs/verification-ratio.toml.
P2 · Platform
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
activate_live(revert_to)restores the activation state ofrevert_to
If it is false
No invariant names it in docs/verification-ratio.toml.
P3 · Platform
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
publish_bootand the override promotion rename are idempotent
If it is false
These invariants fall: I1, I16, I23.
P4 · Platform
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Journal fsync is durable and the preallocation is writable
If it is false
These invariants fall: I6, I7, I12, I15, I18, I24, I25.
P5 · Platform
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
SHA-256 is collision-resistant (the journal’s hash chain, I18)
Sites: txn::chain (the chain decision, proved), txn::journal (its I/O)
If it is false
These invariants fall: I2, I8, I18.
P6 · Platform
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
systemd reports unit state truthfully over D-Bus and honors
X-RestartIfChanged/X-StopIfChanged
If it is false
These invariants fall: I4, I13, I20, I24.
P7 · Platform
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Fencing is effective: a stale-epoch call is refused before it mutates
If it is false
These invariants fall: I12, I15.
P8 · Platform
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The kernel and systemd are correct
If it is false
These invariants fall: I1, I2, I3, I9, I17, I20, I22.
U1 · Internal unproven
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
exec::derive:DerivedFactsfrom the evaluated generation, never from a proposal (I14’s other half; property-tested)
Sites: C
If it is false
These invariants fall: I10, I14, I21, I22.
U2 · Internal unproven
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
user_authorizationand the administrator lookup (planarian-adminmembership, root)
Sites: C
If it is false
These invariants fall: I17, I19, I22.
U3 · Internal unproven
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The residue classifier (§11: which unit endings are a person’s stop)
Sites: Kani in A, Verus in C
If it is false
These invariants fall: I13.
U4 · Internal unproven
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Journal I/O around the verified chain: framing, torn-tail truncation, checkpoint replace
Sites: C
If it is false
These invariants fall: I18.
U5 · Internal unproven
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Identity resolution (§10): peer credentials and pidfd to logind session, seat, terminal
Sites: C
If it is false
These invariants fall: I19, I21, I24.
V1 · Proof-validity
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
vstd’s specifications of
Vec,String::as_str,str::unicode_len,str::get_char
Sites: policy string helpers
If it is false
These invariants fall: I5, I10, I11, I16, I19, I21, I22.
V10 · Proof-validity
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
rustc compiles the verified code to a program with the semantics Verus checked.
Sites: every proof
If it is false
Every proof stops describing the real machine: it is proof-validity trust.
No invariant names it in docs/verification-ratio.toml.
V11 · Proof-validity
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Kani and its back end CBMC are sound within the stated bounds.
Sites: txn::kani, exec::kani
If it is false
Every proof stops describing the real machine: it is proof-validity trust.
No invariant names it in docs/verification-ratio.toml.
V2 · Proof-validity
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Valueis an opaque external type (external_type_specification); nothing about it is assumed
Sites: schema::transition
If it is false
These invariants fall: I5, I11, I16, I21.
V3 · Proof-validity
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Serde impls are unverified (
external_derive)
Sites: schema, policy, txn
If it is false
These invariants fall: I5, I6, I14.
V4 · Proof-validity
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Expectation,CheckContext,ApprovalToken,Incidentare opaque external types
Sites: schema::txn, schema::transition
If it is false
These invariants fall: I6.
V5 · Proof-validity
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Ledger bookkeeping helpers (
push_tag,tag_string,note_why,archive,set_override,lift_override,record_incident,use_token) areexternal_bodywith no postcondition: each receives only the field it updates, so the verified fields are untouched by construction; trusted only to terminate
Sites: txn::ledger
If it is false
These invariants fall: I6, I12.
V6 · Proof-validity
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
vstd’s specifications of
Vec::push,insert,remove,is_emptyandBox
Sites: txn::ledger
If it is false
These invariants fall: I6.
V7 · Proof-validity
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The specifications the proofs prove against (
decide_spec,step_spec,inv,chained, the lemma statements) say what the SPEC’s English means. A proof certifies a wrong specification as readily as a right one; each proved invariant has a gym entry written from the English to catch the difference (§17a).
Sites: policy, txn, schema
If it is false
Every proof stops describing the real machine: it is proof-validity trust.
No invariant names it in docs/verification-ratio.toml.
V8 · Proof-validity
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Verus is sound: what it reports verified holds of the code.
Sites: every proof
If it is false
Every proof stops describing the real machine: it is proof-validity trust.
No invariant names it in docs/verification-ratio.toml.
V9 · Proof-validity
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Z3, the solver Verus calls, is sound.
Sites: every proof
If it is false
Every proof stops describing the real machine: it is proof-validity trust.
No invariant names it in docs/verification-ratio.toml.
Bypasses
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
| Id | Severity | Status | Invariants |
|---|---|---|---|
| B-1 | High | Closed (544de86) | I22 |
| B-10 | Medium | Open (v4.5 §10) | I20 |
| B-11 | Medium | Closed (c84075d) | I13 |
| B-12 | Medium | Closed (cc86ca5) | I2 |
| B-13 | Low | Closed (cc86ca5) | I14 |
| B-14 | Low | Closed (e49c37a) | all |
| B-15 | Low | Closed (db5b0f4) | — |
| B-16 | Low | Closed (db5b0f4) | I14 |
| B-17 | Low | Closed (8bf52c9) | — |
| B-18 | Medium | Open (v4.5 §20, I9) | I9 |
| B-19 | Medium | Open | I16 |
| B-2 | High | Closed (d2e173b) | I16 |
| B-20 | High | Closed (8231a07) | I24 |
| B-21 | Medium | Closed (lane-nix/network-tool #38, with netwatch #42) | — |
| B-22 | Medium | Open, temporary, and closes before the Phase A gate (coordinator, Nov 2: Medium on that condition): closed by lane-change-path’s B-22 follow-up PR (after the journal-versions PR and the move-probe PR, coordinator, Nov 3) (resolved and proxy watches; generic-netlink WireGuard endpoints) | — |
| B-23 | Medium | Open | — |
| B-24 | Medium | Open, temporary: closed by lane-change-path’s move-probe PR (fexit on __dev_change_net_namespace: the move becomes an Interface claim for the mover, cleared when the device goes), after the journal-versions PR and before the B-22 follow-up (coordinator, Nov 3) | — |
| B-25 | Medium | Open, temporary: closed by lane-nix’s acquirable-capabilities PR (with before/after tool counts and boot time) | — |
| B-26 | Medium | Open, temporary: closed by the initrd-watch work (lane-change-path and lane-nix: pinned bpffs links and maps, layout and build-hash check, root 0700 pins, ring sized from a measured event count) | — |
| B-3 | Medium | Closed (725f290) | I8, I19 |
| B-4 | Medium | Open (v4.5 §17a) | I14, I21, I22 |
| B-5 | Medium | Closed (02ba283) | I21, I22 |
| B-6 | Medium | Closed (725f290) | I21 |
| B-7 | Medium | Closed (89eb2b4) | I8 |
| B-8 | Medium | Open (v4.5 I18, §12c) | I18 |
| B-9 | Medium | Open (v4.5 §12d) | I20 |
| X-1 | Accepted | Accepted | all |
| X-10 | Accepted | Accepted | I22, I24 |
| X-11 | Accepted | Accepted | I22 |
| X-12 | Accepted | Accepted | I23 |
| X-13 | Accepted | Accepted | I15, I21 |
| X-2 | Accepted | Accepted | all |
| X-3 | Accepted | Accepted | I2, I20 |
| X-4 | Accepted | Accepted | all |
| X-5 | Accepted | Accepted | I4 |
| X-6 | Accepted | Accepted | all |
| X-7 | Accepted | Accepted | all |
| X-8 | Accepted | Accepted | I8 |
| X-9 | Accepted | Accepted | — |
B-1 · High
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An agent started by a user runs under that uid and holds its capabilities, including Administrator for a
planarian-adminmember
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (authority) | Closed (544de86) | No longer a defect: by owner decision (2026-10-30) an agent holds its user’s capabilities, as X-11 records. planarian agent run (DynamicUser=, unit planarian-agent-<name>, identified by cgroup through the peer’s pidfd) journals who started it, so the agent acts with exactly that user’s permissions and every request is attributed to it; one with no journaled start holds nothing. Gym: agent-run-own-principal, agent-has-its-users-permissions (fake and VM) | A launcher and docs; B required; C refused by agent-host profile |
The guarantees it narrows
I22.
B-10 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The boot report checks units, not processes
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Always invoked | Open (v4.5 §10) | Early processes outside the closure | C |
The guarantees it narrows
I20.
B-11 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Under Standard, any non-Security stop by signal or unissued
systemctl stopis accepted as an inferred override until boot
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (repair) | Closed (c84075d) | inferStops = false for backup, sync and desktop units; inferred overrides listed until acknowledged (planarian ack). Gym: residue-infer-stops-false, residue-inferred-listed-until-ack (fake and VM) | A |
The guarantees it narrows
I13.
B-12 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The core hash is written by the same build it checks and covers only
planariad
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Tamperproof | Closed (cc86ca5) | Every Planarian binary and posture script is in the core-hash record, each checked by planarian-recovery; signatures in E. Gym: posture-integrity-core-hash, posture-integrity-core-script (VM) | A coverage; E signature |
The guarantees it narrows
I2.
B-13 · Low
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The
gym-hookspackage accepts fault injection and stale-digest proposals over the socket
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Always invoked | Closed (cc86ca5) | Module assertion; a test build started without the gym’s permission exits as an Integrity failure. Gym: test-build-is-integrity (VM); checks.module-eval | A |
The guarantees it narrows
I14.
B-14 · Low
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Specifications and proof tools (Verus, Z3, rustc) are trusted but uncounted; a wrong spec is certified by its proof
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (proof) | Closed (e49c37a) | Counted as proof-validity trust (V7–V11); a gym entry per proved invariant written from the English (en-i5 … en-i23), and planarian-gym verification fails if a proved invariant has none. A wrong specification is still certified by its proof; what changed is that the English entries can now catch it | A |
The guarantees it narrows
Every invariant.
B-15 · Low
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Many dynamic-uid services can fill the global connection cap
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Always invoked (availability) | Closed (db5b0f4) | 16 of the 64 slots reserved for administrators and callers in a login session | A |
The guarantees it narrows
None: a process or availability row.
B-16 · Low
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The external source’s replay protection lives in a root-writable cache
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (I14 input) | Closed (db5b0f4) | Last accepted sequence number and digest in the journal (and so its checkpoint), held by a verified ledger rule | A (before any producer) |
The guarantees it narrows
I14.
B-17 · Low
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
guarantees.mdsays Kani runs by hand; CI runs it
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| — | Closed (8bf52c9) | Corrected in guarantees.md and verification.md | A |
The guarantees it narrows
None: a process or availability row.
B-18 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The model service escapes its sandbox before I9 is proved, and becomes a process with its uid’s capabilities (it parses untrusted text and drives a GPU)
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Tamperproof (I9) | Open (v4.5 §20, I9) | Normative sandbox: dynamic uid, no capabilities, strict read-only filesystem, Unix sockets only, syscall allowlist, render node only, proposal socket as the only write path; gym case attempting every other write path | B sandbox and case; E proof |
The guarantees it narrows
I9.
B-19 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A package referenced only by a store-path string (an
ExecStart=naming/nix/store/…directly, a script’s interpolated path) has nometato read at evaluation, so a fix to it does not move the security floor
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (I16) | Open | Residue of B-2: the evaluated inspected set covers every package value; the CVE feed matching the built closure covers strings | B |
The guarantees it narrows
I16.
B-2 · High
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The security floor is derived from
environment.systemPackagesonly; fixes in service packages, the kernel or firmware do not move it
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (I16) | Closed (d2e173b) | Derived from every package value the evaluated configuration holds (system and unit packages, enabled services’ package options, unit path, kernel, firmware) and their runtime inputs; the residue is B-19. Gym: floor-service-package-fix (VM) | A closure; B feed |
The guarantees it narrows
I16.
B-20 · High
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A second
planarian agent rununder a running agent’s name rebinds that agent: authority was looked up by name, so an administrator re-runningagent run helpermade alice’s running agent an administrator’s
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (authority) | Closed (8231a07) | I24: authority bound to the unit instance (InvocationID, read from the peer’s cgroup xattr) a start journaled after it succeeded; agent run refuses a name in use, naming its owner. Gym: agent-name-collision (fake and VM) | A |
The guarantees it narrows
I24.
B-21 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
§12d, as amended on #34, defines network-security units by a classifier: network tools (from their systemd configuration) that the kernel attributes as protecting traffic (netwatch). This PR ships the interim rule only: a unit tagged both
SecurityandExternal, or the firewall by name. Until the classifier lands, an untagged VPN, WireGuard or tailscaled unit holds nothing when it fails, and vm-netsec’s classifier subtests are expected to fail here
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (§12d not yet met) | Closed (lane-nix/network-tool #38, with netwatch #42) | The classifier (§12d “Which units”): every network tool, computed from its systemd configuration, carries the OnFailure= hold, and the hold’s condition reads its kernel-attributed footprint; no name list. module-eval asserts each branch | A |
The guarantees it narrows
None: a process or availability row.
B-22 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
netwatch (#42) does not yet observe every steering kind §12d names. (a) DNS servers on a tool’s interface (set through systemd-resolved over D-Bus, or by writing
/etc/resolv.conf) and the system proxy (environment or settings) never pass the netlink probes:ChangeKind::DnsandProxyare produced only by the gym’s simulator. A tool whose only steering is DNS or the proxy (a DNS-over-VPN client, a DNS leak guard, a Tor or SOCKS front end) readsprotecting no, so its failure holds nothing and queries or traffic go out directly: fails open. (b) WireGuard peer endpoints are configured over generic netlink, which netwatch does not parse (genl→ nothing), so no footprint hasendpointlines. The tunnel’s interface and routes are seen, so its failure holds (fails closed), but the hold does not pass its peer: it cannot re-handshake, its Readiness probe cannot pass, and the hold lifts only by an administrator’splanarian hold release
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (§12d not yet met: inputs to the hold) | Open, temporary, and closes before the Phase A gate (coordinator, Nov 2: Medium on that condition): closed by lane-change-path’s B-22 follow-up PR (after the journal-versions PR and the move-probe PR, coordinator, Nov 3) (resolved and proxy watches; generic-netlink WireGuard endpoints) | (a) Declare a DNS- or proxy-only tool’s steering explicitly until then (a manifest holdInterfaces/holdAllow entry, or tag the unit so the hold treats it as protecting); (b) declare each VPN peer in the manifest’s holdAllow, so the hold passes it and repair can succeed | A |
The guarantees it narrows
None: a process or availability row.
B-23 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A network tool that exists only as a raw unit file (shipped in a package through
systemd.packagesand never declared insystemd.services) is not classified: the build-time rule reads the module system’s configuration, and reading unit files at evaluation would be import from derivation. Its failure holds nothing, and it is not ordered after netwatch. User units are not this gap: a user manager grants no capabilities, so a user unit steers host traffic only by asking a system service (whose footprint carries the change) or through a setuid or file-capability binary (the parked gap in #38). Related, and failing closed rather than open: with networking in the systemd initrd (boot.initrd.network, or networkd there), links and routes still standing at switch-root were made before netwatch existed and read aspre-attach, so every network tool’s failure holds until they are gone
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (§12d overstated) | Open | Workaround: declare the unit in systemd.services (even only systemd.services.<name> = { }; beside the package; NixOS writes it as a drop-in over the package’s file), and it is classified, held and ordered. Fix: classify raw units at build time from the unit tree itself (the drop-in package reading the files it ships beside), not at evaluation | B |
The guarantees it narrows
None: a process or availability row.
B-24 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An interface moved into the host netns from another (
ip link set <dev> netns 1run inside a container orip netns) is seen only as the mover’s own request, in the mover’s netns, which names the device by its index there; netwatch cannot tell which host device arrived. It charges the move to the mover’s unit asunattributed(protecting yes), with no object to clear it by, so the mover stays protecting until reboot, even after the device is deleted. Fail closed: its failure holds traffic it may no longer steer
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Available (§12d: a hold for a tool that no longer protects) | Open, temporary: closed by lane-change-path’s move-probe PR (fexit on __dev_change_net_namespace: the move becomes an Interface claim for the mover, cleared when the device goes), after the journal-versions PR and before the B-22 follow-up (coordinator, Nov 3) | planarian hold release <unit> lifts a hold the mover no longer warrants; or restart the machine | A |
The guarantees it narrows
None: a process or availability row.
B-25 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
§12d (Nov 3) counts a unit that could gain
CAP_NET_ADMINorCAP_NET_RAWas a network tool: its bounding set allows it andNoNewPrivileges=is unset, so a setuid or file-capability binary such aspingcan grant it. The merged classifier (nix/module.nix,isNetworkToolUnchecked) reads the bounding set only for root units and never readsNoNewPrivileges=. A non-root unit that can gain the caps this way is not a tool: it is not ordered after netwatch, and its failure holds nothing even while it steers traffic: fails open
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (§12d not yet met) | Open, temporary: closed by lane-nix’s acquirable-capabilities PR (with before/after tool counts and boot time) | None until then; declare the unit with NoNewPrivileges=true or a bounding set without the network caps (tagging no longer makes a unit a tool, since #38) | A |
The guarantees it narrows
None: a process or availability row.
B-26 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
§12d (Nov 3) runs the watch in the systemd initrd, so no network tool acts unwatched in either stage. Main still exempts initrd survivors: the units in
nix/netwatch-unordered.nix(started by the systemd initrd and kept across switch-root) are not ordered after the watch, so what they change before stage-2 netwatch attaches is attributed to nobody. Steering from that window that still stands at attach is read aspre-attach <object>(netwatch’s attach rule; N6) and holds every network tool’s failure until the object is gone: fails closed (over-holding). It fails open only for steering the attach snapshot cannot read, DNS servers set through resolved and the system proxy, which is B-22’s gap
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Available (§12d not yet met); Correct only through B-22 | Open, temporary: closed by the initrd-watch work (lane-change-path and lane-nix: pinned bpffs links and maps, layout and build-hash check, root 0700 pins, ring sized from a measured event count) | planarian hold release <unit> lifts a hold a pre-attach object no longer warrants; with a scripted initrd there are no such units | A |
The guarantees it narrows
None: a process or availability row.
B-3 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Request::Approve { tty }is set by the client; any root process can claim a terminal
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (approval) | Closed (725f290) | Daemon reads the peer’s controlling terminal from its pidfd; field removed. Gym: approve-needs-real-terminal (fake and VM) | A; broker in C |
The guarantees it narrows
B-4 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
decideis proved, but its inputs (DerivedFacts,Authorization) are computed by unproven code
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (inputs) | Open (v4.5 §17a) | Internal-unproven count reported separately; fail-closed defaults; derive proved in C | A accounting; C proof |
The guarantees it narrows
B-5 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Untagged security units (fail2ban, AppArmor, USBGuard, a VPN) are Routine everywhere
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (classification) | Closed (02ba283) | Security by default for the named units and option trees; an undeclared or untagged system unit is External (fail closed); the named list is checked by checks.module-eval | A |
The guarantees it narrows
B-6 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Session resolved from a bare pid; same-uid pid reuse attributes the wrong session (I21)
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (identity) | Closed (725f290) | SO_PEERPIDFD and pidfd-based lookups; a peer gone before it is resolved is its uid alone, with no session or terminal. Pid reuse itself cannot be staged reproducibly; the guarantee rests on P10. Gym: identity-peer-exited (VM) | A |
The guarantees it narrows
I21.
B-7 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Token expiry, leases and cooldowns use wall-clock time; moving it back extends approvals; unauthenticated NTP lets a network attacker do it
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (I8 expiry) | Closed (89eb2b4) | CLOCK_BOOTTIME for tokens, leases and deadlines; per-process anchored wall clock for the rest; NTS recommended | A |
The guarantees it narrows
I8.
B-8 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The journal hash chain is unkeyed; root can rewrite it consistently
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Tamperproof | Open (v4.5 I18, §12c) | I18 qualified; chain head in the signed health report for off-machine witnessing; TPM anchor | A wording; C witness; E anchor |
The guarantees it narrows
I18.
B-9 · Medium
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Posture alarms and notification are local; root can clear them and a stopped
planariadcan go unnoticed
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Tamperproof | Open (v4.5 §12d) | Dead-man signal from the signed health report to an off-machine receiver | C |
The guarantees it narrows
I20.
X-1 · Accepted
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Root edits the store,
/boot, the binaries, the journal or the posture state
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| All | Accepted | Detection only: boot report, hash chain, core hash | E |
The guarantees it narrows
Every invariant.
X-10 · Accepted
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A program a user runs under their own uid, outside
planarian agent run(an agent started by hand from a shell)
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (authority) | Accepted | It is that user, with that user’s capabilities, as anything they run is, and unattributed: Planarian cannot tell a program from its user. The README says to use planarian agent run, which gives the same permissions with attribution; C’s agent-host profile refuses agents outside it | C |
The guarantees it narrows
X-11 · Accepted
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An agent holds its user’s capabilities, including Administrator when an administrator starts it (owner decision, 2026-10-30)
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (authority) | Accepted | By design, not a defect: agents are not treated differently from their users in what they may do. Every request is attributed to the agent in the executor log; who started it is in the hash-chained journal; it has no login session or terminal, so it cannot approve an AskFirst change through the daemon’s terminal check (unless it allocates its own pty, the known weakness of §16), and its own AskFirst proposals are its user’s, which that user cannot approve (I19) | — |
The guarantees it narrows
I22.
X-12 · Accepted
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A writer that does not take the daemon’s lock on the override directory (an editor, a script) replaces
planarian/overrides.nixbetween the daemon’s comparison and its rename
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (I23) | Accepted | An edit made before the comparison is caught and never overwritten (en-i23-third-party-override-kept); a writer that takes the daemon’s exclusive flock on planarian/ cannot land between them: the compare and the rename are one step under it (§9 step 4; the_override_swap_waits_for_the_directory_lock, and on the VM en-i23-third-party-override-kept) | C (writer boundary) |
The guarantees it narrows
I23.
X-13 · Accepted
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The network hold (§12d Network security fails closed):
planariadapplies and liftsplanarian-hold@<unit>with noPreparedrecord, no lease and no op tag, mirroring systemd’sOnFailure=; the hold drops connectivity without asking
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct (I15, I21) | Accepted | A failure posture response, the same class as §12d’s other responses, not a transition (coordinator, Nov 2). Safe because applying only ever restricts traffic; lifting follows only a unit observed Running with every Readiness probe passing, or an attested release; both calls are idempotent. Auditable: HoldApplied { unit, by } and HoldLifted { unit, reason } are journaled before each call (hash-chained), and the journal’s holds are the recovery source of truth, reconciled with the host’s after a restart, failing closed. The decision to unblock is never the bypass: planarian hold release is an attested Override on the hold unit through the executor, administrator authority or AskFirst with an Administrator approver. Gym: core tests a_held_unit_crash_holds_restarts_at_once_and_lifts, an_unattributed_stop_is_held_by_the_daemon_and_an_attested_one_is_not, a_failed_repair_keeps_the_hold_and_notifies_across_a_restart, hold_release_needs_an_administrator; VM vm-netsec | A |
The guarantees it narrows
X-2 · Accepted
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A
nixos-rebuildoutside Planarian
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Always invoked | Accepted | Observed as an unconfirmed base, never trusted; wrapped by PlanarianOS | C |
The guarantees it narrows
Every invariant.
X-3 · Accepted
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Firmware, bootloader and initrd run before
planarian-ready.target
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Always invoked | Accepted | Boot-chain locking and Secure Boot (C); measured boot (E); nothing below firmware | C, E |
The guarantees it narrows
X-4 · Accepted
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Physical access
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| All | Accepted | Disk sealing | E |
The guarantees it narrows
Every invariant.
X-5 · Accepted
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A malicious unit that works correctly and passes its probes
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct | Accepted | Nothing; the malicious-declaration entries score whether its origin is reported | — |
The guarantees it narrows
I4.
X-6 · Accepted
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The supply chain: nixpkgs content, cache signatures, input-addressed store paths
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct | Accepted | Nix’s boundary, not Planarian’s | — |
The guarantees it narrows
Every invariant.
X-7 · Accepted
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Backends report success falsely; systemd or the kernel misbehave (P1, P6, P8)
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct | Accepted | Re-observation and probes after every call; replaced in D and E | D, E |
The guarantees it narrows
Every invariant.
X-8 · Accepted
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The approver: fatigue, social engineering, persuasion by a model
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| Correct | Accepted | AskFirst kept rare (per-week ratchet); the change shown in full | — |
The guarantees it narrows
I8.
X-9 · Accepted
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Withdrawn 2026-10-29. The model’s judgement is not a vector: the model is treated as an adversary, so a bad proposal is one any attacker could make and is a policy, catalog or probe finding under its own name. Containment is B-18; persuading an approver is X-8
| Breaks | Status | Mitigation | Closes in |
|---|---|---|---|
| — | Accepted | — | — |
The guarantees it narrows
None: a process or availability row.
Gym entries
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
| Entry | Harness | Invariants | Latest verdict |
|---|---|---|---|
agent-has-its-users-permissions | fake, vm | fake: PASS; VM: not run in this build | |
agent-name-collision | fake, vm | I24 | fake: PASS; VM: not run in this build |
agent-run-own-principal | fake, vm | fake: PASS; VM: not run in this build | |
approval-dies-with-daemon | fake, vm | fake: PASS; VM: not run in this build | |
approval-token-expired | fake | I8 | fake: PASS |
approval-token-invalidated | fake | I8 | fake: PASS |
approve-at-terminal-runs | fake, vm | fake: PASS; VM: not run in this build | |
approve-needs-real-terminal | fake, vm | I14 | fake: PASS; VM: not run in this build |
authz-forged-actor-overwritten | fake, vm | I14 | fake: PASS; VM: not run in this build |
authz-nonadmin-stops-network-other-session | fake | fake: PASS | |
authz-unprivileged-override-security | fake, vm | fake: PASS; VM: not run in this build | |
authz-unprivileged-stop-unauthorized | fake, vm | fake: PASS; VM: not run in this build | |
authz-user-overrides-firewall | fake | I19 | fake: PASS |
awaiting-boot-reminds-daily | fake | I12 | fake: PASS |
boot-only-admitted-not-confirmed | fake | I6 | fake: PASS |
boot-only-kernel-boot-fails | fake | I1 | fake: PASS |
boot-only-kernel-confirmed | fake | fake: PASS | |
boot-only-repair-during-confirmation | fake | I6 | fake: PASS |
built-parking-expires | fake | I12 | fake: PASS |
candidate-nar-mismatch-refused | fake | fake: PASS | |
catalog-change-carries-unbuilt-owner-edit | fake | I14 | fake: PASS |
checkpoint-bad-crc | fake | fake: PASS | |
cond-device-unplugged | vm | VM: not run in this build | |
core-target-rejected | fake | fake: PASS | |
crash-activating-bad | fake, vm | I6, I12, I15 | fake: PASS; VM: not run in this build |
crash-activating-good | fake, vm | I6, I12, I15 | fake: PASS; VM: not run in this build |
crash-checking | fake, vm | fake: PASS; VM: not run in this build | |
crash-daemon-restart-reconciles | fake, vm | fake: PASS; VM: not run in this build | |
crash-job-outlives-drain | fake, vm | fake: PASS; VM: not run in this build | |
crash-jobs-tagged-unknown | fake | fake: PASS | |
crash-promoted | fake, vm | fake: PASS; VM: not run in this build | |
crash-published | fake, vm | fake: PASS; VM: not run in this build | |
crash-publishing-after-publish | fake | fake: PASS | |
crash-publishing-before-publish | fake | fake: PASS | |
crash-reverting | fake, vm | fake: PASS; VM: not run in this build | |
crash-runtime-rollback | fake, vm | fake: PASS; VM: not run in this build | |
crash-simultaneous-takeover | fake, vm | I15 | fake: PASS; VM: not run in this build |
crash-slow-worker-overlap | fake, vm | I6, I15 | fake: PASS; VM: not run in this build |
crash-stop-and-suppress | fake, vm | fake: PASS; VM: not run in this build | |
crash-withheld-job-reply | fake | I6, I15 | fake: PASS |
degraded-unit-loses-a-probe | fake | fake: PASS | |
dep-ordering-only-stopped | fake, vm | fake: PASS; VM: not run in this build | |
dep-required-stopped | fake, vm | fake: PASS; VM: not run in this build | |
dep-required-stopped-guarded | fake, vm | fake: PASS; VM: not run in this build | |
dep-wants-optional-failed | fake, vm | fake: PASS; VM: not run in this build | |
desktop-auth-ok-compositor-fails | vm | VM: not run in this build | |
desktop-boot-fails-rollback | vm | I1 | VM: not run in this build |
desktop-second-user-deadline | vm | VM: not run in this build | |
disruption-connectivity-alone-seated-runs | fake | fake: PASS | |
disruption-each-kind-keeps-its-floor | fake | fake: PASS | |
drv-bind-fails-restores-previous | fake, vm | fake: PASS; VM: not run in this build | |
drv-unbound-audio | fake, vm | fake: PASS; VM: not run in this build | |
drv-unbound-nic-asks | vm | VM: not run in this build | |
dry-activate-exit-nonzero | fake | fake: PASS | |
dry-activate-timeout | fake | fake: PASS | |
dry-activate-truncated | fake | fake: PASS | |
en-i10-a-canonical-probe-cannot-be-removed | fake | I10 | fake: PASS |
en-i11-session-carries-its-sensitivity | fake | I11 | fake: PASS |
en-i12-nothing-left-open | fake | I12 | fake: PASS |
en-i13-overridden-unit-is-not-repaired | fake, vm | I13 | fake: PASS; VM: not run in this build |
en-i14-forged-fields-ignored | fake, vm | I14 | fake: PASS; VM: not run in this build |
en-i15-old-worker-cannot-finish | fake, vm | I15 | fake: PASS; VM: not run in this build |
en-i16-boot-fallback-respects-floor | fake | I16 | fake: PASS |
en-i17-root-administers-without-the-group | fake | I17 | fake: PASS |
en-i18-deleted-record-detected | fake, vm | I18 | fake: PASS; VM: not run in this build |
en-i19-no-self-approval | fake, vm | I19 | fake: PASS; VM: not run in this build |
en-i21-irreversible-asks-even-for-root | fake | I21 | fake: PASS |
en-i22-user-cannot-do-admin-things | fake, vm | I22 | fake: PASS; VM: not run in this build |
en-i23-third-party-override-kept | fake, vm | I23 | fake: PASS; VM: not run in this build |
en-i5-reject-survives-every-setting | fake | I5 | fake: PASS |
en-i6-repair-waits-for-activation | fake, vm | I6 | fake: PASS; VM: not run in this build |
en-i8-token-bound-to-candidate | fake, vm | I8 | fake: PASS; VM: not run in this build |
fake-assumptions-conformance | fake | I6, I12, I15 | fake: PASS |
flap-30s | fake, vm | fake: PASS; VM: not run in this build | |
flap-regression-escalates | fake | fake: PASS | |
floor-manual-rollback-needs-admin | fake | I16, I19 | fake: PASS |
floor-moves-via-boot-only | fake | I25 | fake: PASS |
floor-moves-via-watchdog | fake | I16, I25 | fake: PASS |
floor-revert-blocked | fake | I1, I16 | fake: PASS |
floor-service-package-fix | vm | I16 | VM: not run in this build |
gc-during-approval | fake, vm | I3 | fake: PASS; VM: not run in this build |
gc-retain-all-generations | fake | I21 | fake: PASS |
gen-bad-switch | fake, vm | fake: PASS; VM: not run in this build | |
gen-disable-reenable-canonical | fake, vm | I10 | fake: PASS; VM: not run in this build |
gen-override-only-change | fake, vm | fake: PASS; VM: not run in this build | |
identity-peer-exited | vm | I21 | VM: not run in this build |
intent-cli-stop-rewritten | fake, vm | fake: PASS; VM: not run in this build | |
intent-start-again-repairs | fake, vm | fake: PASS; VM: not run in this build | |
intent-user-stops-service | fake, vm | I13 | fake: PASS; VM: not run in this build |
journal-compaction-preserves-state | fake, vm | fake: PASS; VM: not run in this build | |
journal-record-edited | fake, vm | I18 | fake: PASS; VM: not run in this build |
journal-torn-write | fake | fake: PASS | |
login-greeter-idle | vm | VM: not run in this build | |
malicious-decl-catalog-enable | fake, vm | fake: PASS; VM: not run in this build | |
malicious-decl-input-update | fake, vm | fake: PASS; VM: not run in this build | |
malicious-decl-owner-rebuild | fake, vm | fake: PASS; VM: not run in this build | |
manifest-weaken-out-of-band | vm | I10 | VM: not run in this build |
manifest-weaken-via-proposal | fake, vm | I10 | fake: PASS; VM: not run in this build |
net-external-dependency-down | fake, vm | fake: PASS; VM: not run in this build | |
net-gateway-down | fake, vm | fake: PASS; VM: not run in this build | |
net-upstream-down | fake, vm | fake: PASS; VM: not run in this build | |
override-over-attested-refused | fake | fake: PASS | |
override-stopped-equals-stop | fake, vm | I21 | fake: PASS; VM: not run in this build |
owner-rebuild-breaks-service | fake, vm | fake: PASS; VM: not run in this build | |
policy-daemon-session-drop-rebind | fake, vm | fake: PASS; VM: not run in this build | |
policy-daemon-wifi-runtime | fake, vm | fake: PASS; VM: not run in this build | |
policy-refinement-violation | fake | fake: PASS | |
policy-stale-digest | fake | fake: PASS | |
posture-absent-masked | vm | I20 | VM: not run in this build |
posture-crashed-budget | vm | I20 | VM: not run in this build |
posture-integrity-core-hash | vm | I2, I20 | VM: not run in this build |
posture-integrity-core-script | vm | I2, I20 | VM: not run in this build |
posture-integrity-journal-chain | vm | I18, I20 | VM: not run in this build |
posture-integrity-undeclared-early-unit | vm | I20 | VM: not run in this build |
posture-stops-launched-agents | vm | I20 | VM: not run in this build |
probe-delayed-healthy | fake, vm | fake: PASS; VM: not run in this build | |
probe-diagnostic-stale-healthy | fake, vm | I4 | fake: PASS; VM: not run in this build |
probe-failed-beats-stale | fake, vm | fake: PASS; VM: not run in this build | |
probe-http-503-degraded | fake, vm | fake: PASS; VM: not run in this build | |
probe-trickling-endpoint | vm | VM: not run in this build | |
quiet-reference-workload | bench | ||
quiet-repair-not-deferred | fake | fake: PASS | |
quiet-staged-update-foreground | fake | I6 | fake: PASS |
release-refused-leaves-incident | fake, vm | fake: PASS; VM: not run in this build | |
reprepared-keeps-caller | fake | fake: PASS | |
residue-first-kill-teaches-stop | fake | fake: PASS | |
residue-infer-stops-false | fake, vm | fake: PASS; VM: not run in this build | |
residue-inferred-listed-until-ack | fake, vm | fake: PASS; VM: not run in this build | |
residue-kill9-security | fake, vm | I22 | fake: PASS; VM: not run in this build |
residue-kill9-service | fake, vm | fake: PASS; VM: not run in this build | |
residue-kill9-undo | fake | fake: PASS | |
residue-oom-kill | fake, vm | fake: PASS; VM: not run in this build | |
residue-stop-security-unit | fake, vm | I22 | fake: PASS; VM: not run in this build |
revert-fails | fake, vm | fake: PASS; VM: not run in this build | |
revert-fails-interlock-survives-compaction | fake, vm | fake: PASS; VM: not run in this build | |
revert-to-older-sets-no-floor | fake | I25 | fake: PASS |
rollback-repair-foreground-dependency | fake | fake: PASS | |
rollback-repair-foreground-unrelated | fake | fake: PASS | |
rollback-repair-session-loss-asks | fake | fake: PASS | |
session-logout-one-of-two | fake | fake: PASS | |
session-second-compositor-fails | fake | fake: PASS | |
ssh-session-expects-no-desktop | fake | fake: PASS | |
stale-owner-edit-during-build | fake, vm | I23 | fake: PASS; VM: not run in this build |
stale-untracked-file-ignored | vm | VM: not run in this build | |
storage-boot-full-publish | fake, vm | fake: PASS; VM: not run in this build | |
storage-budget-unsatisfiable | fake, vm | I3 | fake: PASS; VM: not run in this build |
storage-enospc-during-build | fake | fake: PASS | |
storage-journal-btrfs-reuse | vm | VM: not run in this build | |
storage-journal-fs-full | vm | VM: not run in this build | |
storage-nix-full-gc | fake, vm | I3 | fake: PASS; VM: not run in this build |
suspend-resume | fake | fake: PASS | |
svc-killed | fake, vm | fake: PASS; VM: not run in this build | |
svc-killed-defer-to-systemd | vm | VM: not run in this build | |
svc-killed-user-scope | vm | VM: not run in this build | |
svc-misconfig-listen-address | fake, vm | fake: PASS; VM: not run in this build | |
svc-misconfig-state-file | vm | VM: not run in this build | |
switch-facts-from-dry-activation | fake | fake: PASS | |
test-build-is-integrity | vm | VM: not run in this build | |
token-wall-clock-moved-back | fake | I8 | fake: PASS |
unauthorized-change-does-no-work | fake | fake: PASS | |
user-unit-after-logout | vm | VM: not run in this build |
agent-has-its-users-permissions
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice (uid 1000, not an administrator) starts an agent with
planarian agent run helper. It has alice’s permissions and no more: its stop of gym-web, a system service alice may not stop, is refused Unauthorized; its stop of alice’s own pipewire runs. Both are attributed to agent:helper.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | txn: service:gym-web StopAndSuppress, service@1000:pipewire Committed StopAndSuppress; units: service:gym-web Running | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
agent-name-collision
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice (uid 1000) starts an agent named helper. An administrator then runs
planarian agent run helpertoo: refused, naming alice as its owner. alice’s agent stays alice’s: its stop of gym-web, a system unit she may not stop, is refused. Authority is bound to the instance a start journaled, never the name (I24).
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | reports: AgentNameInUse; txn: service:gym-web StopAndSuppress; units: service:gym-web Running | fake: PASS; VM: not run in this build |
What it exercises
agent-run-own-principal
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An administrator (uid 1001, planarian-admin) starts an agent with
planarian agent run coder. The agent runs under a dynamic uid in its own unit, and acts with the permissions of the user who started it (§11, owner decision 2026-10-30): its stop of gym-web runs, as the administrator’s would, and the executor log attributes it to agent:coder, acting for uid 1001.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | txn: service:gym-web Committed StopAndSuppress; units: service:gym-web Overridden | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
approval-dies-with-daemon
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An administrator asks to disable sshd (AskFirst) and the approval is issued. The daemon restarts before anyone approves. The old approval is void: presenting it is refused, the change is asked for again, and nothing runs until the new request is approved.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | reports: TokenReplayRefused, NeedsApproval; txn: pending ApplyChange; units: service:sshd Running | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
approval-token-expired
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A parked AskFirst change gets no approval for 24 h. It ends Rejected, its GC root is released, and any waiting incident reopens.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | gc_root_released: true; txn: ApplyChange | fake: PASS |
What it exercises
approval-token-invalidated
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A token is issued for a parked AskFirst change, then the policy version changes. Re-validation invalidates the token and re-requests approval, and replaying the old token is refused.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | reports: ApprovalInvalidated, TokenReplayRefused; txn: ApplyChange | fake: PASS |
What it exercises
approve-at-terminal-runs
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An administrator who is not root asks to disable sshd (Security: AskFirst). Root approves it from a real terminal, which the daemon reads from the peer’s pidfd: the approval is taken and the change commits.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | approval_recorded_by: root; txn: Committed ApplyChange | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
approve-needs-real-terminal
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Root approves a pending AskFirst change from a process with no controlling terminal (a new session, as a service would be). The daemon reads the terminal from the peer’s pidfd, finds none, and refuses: the change is still waiting at the end. The request carries no terminal field to claim.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | txn: pending ApplyChange; units: service:sshd Running | fake: PASS; VM: not run in this build |
What it exercises
authz-forged-actor-overwritten
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice submits a SetOverride for her own pipewire with by = Daemon. SO_PEERCRED overwrites the actor to user:1000; the override is allowed and attributed to her.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | overrides: service@1000:pipewire | fake: PASS; VM: not run in this build |
What it exercises
authz-nonadmin-stops-network-other-session
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
bob, at the seat, stops the gym network interface (made user-controllable for this case, G11) while alice has a session open. The derived facts say it reaches another uid’s session; it asks, and alice’s session is notified.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | notified_sessions: s1; txn: pending StopAndSuppress | fake: PASS |
What it exercises
No invariant by name.
authz-unprivileged-override-security
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice tries to override sshd, a system Security unit. It is held as AskFirst for an administrator; sshd stays up meanwhile.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | overrides: ; txn: pending SetOverride; units: service:sshd Running | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
authz-unprivileged-stop-unauthorized
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice tries to stop gym-db, a system unit that is neither Security-sensitive nor user-controllable: Reject(Unauthorized), whatever max_tier says.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | reports: Unauthorized; txn: StopAndSuppress; units: service:gym-db Running | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
authz-user-overrides-firewall
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
From her session, alice overrides the firewall (sshd’s stand-in in the fake: a Security unit) to stopped. Held for an administrator; it stays up. alice cannot approve her own request (I19); root at a terminal approves, and the override holds, attested and attributed to alice.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | invariants: I19; overrides: service:sshd; txn: Committed SetOverride | fake: PASS |
What it exercises
awaiting-boot-reminds-daily
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A kernel parameter change takes the boot-only path and nobody restarts for two and a half days. It stays AwaitingBoot (no limit applies, nothing is cancelled), the ‘restart to finish’ notice is repeated once a day, and at the eventual restart it is confirmed and commits.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | generation: candidate; notices_count: count 2, match is still waiting for a restart; reports: RestartToFinish; txn: Committed ApplyChange | fake: PASS |
What it exercises
boot-only-admitted-not-confirmed
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A boot-only kernel change boots and is admitted (Promoted: the candidate is the default, its override promoted). sshd then crashes; repairs run while the admitted change is unconfirmed, so sshd is restarted at once, but a canonical unit that needed a repair counts against the change: at the 10-minute checkpoint it is withdrawn, never committed: Reverted, the publishing generation is the default again, the base override restored, and a notice names sshd and says a restart completes it.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | boot_default: base; notices_count: count 1, match not confirmed (service:sshd needed a repair before the 10-minute checkpoint); txn: Reverted ApplyChange, service:sshd Committed RestartUnit; units: service:sshd Running | fake: PASS |
What it exercises
boot-only-kernel-boot-fails
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The same change, but the one-shot boot fails; the boot loader falls back to the default (the base), and the transaction is Reverted.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | boot_default: base; generation: base; txn: ApplyChange | fake: PASS |
What it exercises
boot-only-kernel-confirmed
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A kernel parameter change takes the boot-only path: published as a one-shot next-boot entry, AwaitingBoot, a ‘restart to finish’ notice, the base still the default. At the next boot it is confirmed, becomes the default, and commits.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | agrees: true; boot_default: candidate; generation: candidate; known_good: candidate; live_tested: never; reports: RestartToFinish; txn: ApplyChange | fake: PASS |
What it exercises
No invariant by name.
boot-only-repair-during-confirmation
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A boot-only kernel change boots and is admitted. Two minutes later gym-web (not canonical) crashes: it is repaired at once, without waiting for the 10-minute checkpoint, and the change is still confirmed and committed there.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | agrees: true; boot_default: candidate; generation: candidate; txn: Committed ApplyChange, service:gym-web Committed RestartUnit; units: service:gym-web Running | fake: PASS |
What it exercises
built-parking-expires
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice changes her font scale while a game holds Foreground in her session, and the game never lets go. After a day in Built the change is Cancelled by the daemon, with one notice naming it and how to issue it again; it never activates, even once the game ends.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | notices_count: count 1, match waited a day in Built; reports: WaitsForWindow; txn: Cancelled ApplyChange | fake: PASS |
What it exercises
candidate-nar-mismatch-refused
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A display-scale candidate’s store contents no longer hash to the NAR hash recorded when it was built. It is refused before activation: verification checks the hash, not that the path exists (§9 step 3, C6).
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | generation: base; override_source: base; txn: Rejected(Other(“candidate failed verification”)) ApplyChange | fake: PASS |
What it exercises
No invariant by name.
catalog-change-carries-unbuilt-owner-edit
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An administrator has edited the owner’s configuration to enable a service but not rebuilt. alice then asks for a user-controllable change (font scale). The candidate would also bring the new service, which the override does not explain, so the change is Security-sensitive: it waits for approval instead of activating on its own.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | generation: base; reports: NeedsApproval | fake: PASS |
What it exercises
checkpoint-bad-crc
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The journal is compacted, then its checkpoint is damaged while the daemon is down (its CRC no longer matches). The daemon does not start from nothing: it reports an Integrity failure naming the journal.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | posture: class Integrity; reports: JournalChainBroken | fake: PASS |
What it exercises
No invariant by name.
cond-device-unplugged
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
gym-usb0 is hot-unplugged, so gym-usb-reader (desired only under DeviceConnected) stops, and no incident opens.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | intent | no_autonomous_txn: true; no_incident_on: service:gym-usb-reader, device:gym-usb0 | VM: not run in this build |
What it exercises
No invariant by name.
core-target-rejected
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A candidate whose evaluated unit file for planariad differs from the running one is rejected as CoreTarget, under any approval.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | txn: ApplyChange | fake: PASS |
What it exercises
No invariant by name.
crash-activating-bad
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
planariad is killed during Activating of a bad candidate; the watchdog takes the lease, fences, confirms quiescence, and reverts.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | boot_default: base; generation: base; invariants: I6, I12, I15; journal_agrees: true; override_source: base; txn: Reverted ApplyChange; units: service:gym-web Running | fake: PASS; VM: not run in this build |
What it exercises
crash-activating-good
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
planariad is killed during Activating of a good candidate. The watchdog sees the candidate active, goes to Checking, the probes pass, and it runs the entire commit path.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | boot_default: candidate; generation: candidate; invariants: I6, I12, I15; journal_agrees: true; override_source: candidate; txn: Committed ApplyChange | fake: PASS; VM: not run in this build |
What it exercises
crash-checking
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
planariad is killed during Checking; the watchdog re-runs the probes itself before committing.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | boot_default: candidate; generation: candidate; journal_agrees: true; override_source: candidate; txn: Committed ApplyChange; watchdog_probed_before_commit: true | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
crash-daemon-restart-reconciles
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
GA demo tail: after the watchdog has reverted (crash-activating-bad), planariad restarts and writes a reconciliation record whose account of what happened matches the journal.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | generation: base; journal_agrees: true; reconciliation: daemon_action none, explains [“killed-during-Activating”, “watchdog-reverted”, “why:candidate not active”], found Reverted, owner watchdog | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
crash-job-outlives-drain
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An old backend job outlives the 60 s drain. The takeover ends in RecoveryFailed with the interlock set, and no overlapping mutation happens.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | interlock: set; no_overlap: activate_live(candidate), activate_live(revert_to); reports: RecoveryFailed, alert; txn: RecoveryFailed ApplyChange | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
crash-jobs-tagged-unknown
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
jobs_tagged returns None for an unacknowledged OpId (the backend cannot say). Recovery writes RecoveryFailed instead of proceeding.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | interlock: set; txn: RecoveryFailed ApplyChange | fake: PASS |
What it exercises
No invariant by name.
crash-promoted
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The daemon stays down after Promoted; the watchdog writes Committed.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | boot_default: candidate; generation: candidate; journal_agrees: true; override_source: candidate; txn: Committed ApplyChange | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
crash-published
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The daemon stays down after Published; the watchdog promotes the override by hash comparison and commits.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | boot_default: candidate; generation: candidate; journal_agrees: true; override_source: candidate; txn: Committed ApplyChange | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
crash-publishing-after-publish
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
planariad is killed after publish_boot_once armed the one-shot entry, before its outcome was journaled. On restart the Publishing row asks the boot loader: the entry is armed, so the change is AwaitingBoot. The reboot boots the candidate, it is admitted and committed, and boot selection, roots, overrides and the journal agree.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | agrees: true; boot_default: candidate; generation: candidate; txn: Committed ApplyChange | fake: PASS |
What it exercises
No invariant by name.
crash-publishing-before-publish
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
planariad is killed after journaling OpBegin(publish_boot_once) for a boot-only kernel change, before the call. On restart the Publishing row asks the boot loader: nothing is armed, so the change is Rejected(Interrupted) and not prepared again. After a reboot the machine runs the publishing generation, and boot selection, roots, overrides and the journal agree.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | agrees: true; boot_default: base; generation: base; txn: Rejected(Interrupted) ApplyChange | fake: PASS |
What it exercises
No invariant by name.
crash-reverting
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
planariad is killed during Reverting of a bad candidate; the watchdog re-runs the idempotent revert steps.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | boot_default: base; generation: base; journal_agrees: true; override_source: base; txn: Reverted ApplyChange; units: service:gym-web Running | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
crash-runtime-rollback
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Killed during Checking of a Runtime-tier Rollback; it recovers as a generation operation (serialized, owned, publish_boot, override restored) and completes. (Killed before the activation took effect, the crash table reverts instead, which is also correct.)
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | boot_default: known_good; generation: known_good; journal_agrees: true; override_source: known_good; txn: Committed Rollback | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
crash-simultaneous-takeover
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The daemon and the watchdog try to take over at the same moment; exactly one wins the flock and the other backs off.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | invariants: I15; journal_agrees: true; lease_epochs_strictly_increasing: true; owners_per_epoch_max: 1 | fake: PASS; VM: not run in this build |
What it exercises
crash-slow-worker-overlap
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The daemon is killed and immediately restarted while a deliberately slow activation worker survives. The new owner fences it, so activation and revert never overlap and the old owner can never commit.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | generation: base; invariants: I6, I15; journal_agrees: true; no_overlap: activate_live(candidate), activate_live(revert_to); stale_owner_commit: false | fake: PASS; VM: not run in this build |
What it exercises
crash-stop-and-suppress
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Killed during a user’s StopAndSuppress. It recovers as a unit operation: the override (journaled before the stop) holds, the unit is not restarted, and no generation rollback is attempted.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | generation: base; overrides: service:gym-web; txn: Committed StopAndSuppress, Revert, service:gym-web RestartUnit; units: service:gym-web Overridden | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
crash-withheld-job-reply
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The backend accepts an activation job but its reply is withheld, and the owner is killed before the job id is journaled. Recovery must discover the job by its OpId, or fence it, before making any conflicting mutation.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | invariants: I6, I15; journal_agrees: true; no_overlap: activate_live(candidate), activate_live(revert_to); quiescence_includes_tagged_jobs: true | fake: PASS |
What it exercises
degraded-unit-loses-a-probe
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
gym-web is already Degraded: its HTTP check fails (503) while its TCP check passes. A change then breaks gym-web’s listen address, so the TCP check fails too. The check after activation compares each Readiness probe that passed before, not the unit’s status, so Checking fails and the change is reverted.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | generation: base; txn: Reverted ApplyChange | fake: PASS |
What it exercises
No invariant by name.
dep-ordering-only-stopped
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
gym-cache (After= of gym-app only) is killed. gym-app stays healthy and is never touched; gym-cache is its own incident.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | repair | incident: service:gym-cache; may_change: service:gym-cache; no_incident_on: service:gym-app; no_txn_on: service:gym-app; others_unchanged: true; units: service:gym-app Running, service:gym-cache Running | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
dep-required-stopped
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
root stops gym-db out of band; systemd stops gym-app with it (Requires=). v4.4 (owner decision Q5): the unattributed residue. Under Standard both are taken as intended until the next boot (gym-app linked to gym-db), nothing is restarted, and the notice offers the undo.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | intent | no_autonomous_txn: true; no_txn_on: service:gym-db, service:gym-app; overrides: service:gym-db, service:gym-app; reports: InferredOverride, Undo; units: service:gym-app Overridden, service:gym-db Overridden | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
dep-required-stopped-guarded
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
As dep-required-stopped, under the Guarded posture, where an unattributed stop is not inferred as intended (§11; Phase A repairs it, without the ask window). Root cause is gym-db: it is repaired first, then gym-app.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | repair | incident: service:gym-db; may_change: service:gym-db, service:gym-app; others_unchanged: true; overrides: ; reports: OutOfBandStopHint; txn: service:gym-db Committed RestartUnit, service:gym-app RestartUnit; units: ser… | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
dep-wants-optional-failed
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
gym-metrics (Wants= of gym-app) fails persistently while gym-app stays healthy: at most one incident, on gym-metrics, and gym-app is never named as caused by it.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | incident: service:gym-metrics; metrics: incidents_max 1; no_incident_on: service:gym-app; no_txn_on: service:gym-app; others_unchanged: true; units: service:gym-app Running | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
desktop-auth-ok-compositor-fails
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice authenticates at the greeter, but her compositor never reaches graphical-session.target, and an incident opens within 60 s of the session’s creation plus one tick.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | incident: target:graphical-session | VM: not run in this build |
What it exercises
No invariant by name.
desktop-boot-fails-rollback
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A committed change that touches only boot (a kernel parameter) becomes live_tested but not known_good. After a reboot the greeter fails; the boot is not confirmed, Rollback goes to known_good, and after a second reboot the desktop works.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | repair | boot_default: known_good; generation: known_good; known_good: unchanged; reports: NotBootConfirmed; txn: Committed Rollback; units: service:display-manager Running | VM: not run in this build |
What it exercises
desktop-second-user-deadline
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice’s desktop fails; bob logs in 50 s later. alice’s 60 s deadline still fires on time, because it is bound to her session id.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | incident: target:graphical-session | VM: not run in this build |
What it exercises
No invariant by name.
disruption-connectivity-alone-seated-runs
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The same rollback by the same seated administrator, but switching back restarts only the tunnel service (ConnectivityLoss). A local caller is exempt from that floor, so the rollback runs and commits; alice’s session is notified.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | generation: known_good; txn: Committed Rollback | fake: PASS |
What it exercises
No invariant by name.
disruption-each-kind-keeps-its-floor
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice’s font-scale change commits. An administrator at the seat (their own session, not alice’s) rolls back; switching back restarts a tunnel service (ConnectivityLoss) and a sync service (DataInterruption). The rollback carries both kinds. Being seated exempts the caller from the ConnectivityLoss floor but not from the DataInterruption one, so it asks first and does not run without approval.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | generation: candidate; txn: pending Rollback | fake: PASS |
What it exercises
No invariant by name.
drv-bind-fails-restores-previous
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An admin binds the sound card to vfio-pci; probes fail; the transaction unbinds it, rebinds snd_hda_intel, and makes no attempt at a generation rollback.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | boot_default: base; bound_driver: device:snd-hda0 snd_hda_intel; generation: base; others_unchanged: true; txn: device:snd-hda0 Reverted BindDriver, Revert; units: device:snd-hda0 Bound | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
drv-unbound-audio
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
snd_hda_intel is unbound from the sound card; alice’s pipewire degrades. The root cause is the device, not pipewire; the driver is rebound, and pipewire then recovers.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | repair | generation: base; incident: device:snd-hda0; may_change: device:snd-hda0, service@1000:pipewire, service@1000:wireplumber; others_unchanged: true; txn: device:snd-hda0 Committed BindDriver, service@1000:pipewire Restar… | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
drv-unbound-nic-asks
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
virtio_net is unbound from net-gym0. Rebinding a NIC has ConnectivityLoss disruption, so the daemon’s BindDriver is parked AskFirst; after a TTY approval it runs.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | repair | others_unchanged: true; txn: device:net-gym0 Committed BindDriver; units: device:net-gym0 Bound | VM: not run in this build |
What it exercises
No invariant by name.
dry-activate-exit-nonzero
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The dry activation of a display-scale change exits nonzero. Its plan is unknown, never smaller: the build fails, nothing is activated, and the running generation stays (rule 21, R10).
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | generation: base; txn: ApplyChange | fake: PASS |
What it exercises
No invariant by name.
dry-activate-timeout
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The dry activation of a display-scale change times out. Its plan is unknown, never smaller: the build fails, nothing is activated, and the running generation stays (rule 21, R10).
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | generation: base; txn: ApplyChange | fake: PASS |
What it exercises
No invariant by name.
dry-activate-truncated
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The dry activation of a display-scale change emits more output than is read (truncated). Its plan is unknown, never smaller: the build fails, nothing is activated, and the running generation stays (rule 21, R10).
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | generation: base; txn: ApplyChange | fake: PASS |
What it exercises
No invariant by name.
en-i10-a-canonical-probe-cannot-be-removed
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A configuration change drops sshd’s canonical probe from the manifest (removes it, not weakens it). Below AskFirst it may run, but the probe does not go: the probe definitions the daemon evaluates are the same afterwards, because the core ships the canonical probe and puts it back.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | probe_definitions_unchanged: true | fake: PASS |
What it exercises
en-i11-session-carries-its-sensitivity
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
In one session, an administrator changes the firewall (Security: approved by root at a terminal, committed), then asks for a display-scale change, Routine on its own. The session’s cumulative effect includes the firewall change, so the second is classified at least as high: AskFirst, not as itself.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | txn: Committed ApplyChange, ApplyChange | fake: PASS |
What it exercises
en-i12-nothing-left-open
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A generation change breaks gym-web, and the revert to the base fails too. The daemon is killed while it is reverting and comes back to finish the revert, which fails again. At the end no journaled transaction is left open, the change is RecoveryFailed with its alert, the interlock holds, and gym-web’s next crash gets no autonomous restart.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | interlock: set; no_txn_on: service:gym-web; nothing_left_open: true; reports: RecoveryFailed; txn: RecoveryFailed ApplyChange | fake: PASS |
What it exercises
en-i13-overridden-unit-is-not-repaired
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An administrator stops gym-web until they say otherwise. For the next ten minutes gym-web stays stopped and under the override: no repair is proposed for it, and nothing restarts it.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | overrides: service:gym-web; txn: Committed StopAndSuppress, service:gym-web RestartUnit; units: service:gym-web Overridden | fake: PASS; VM: not run in this build |
What it exercises
en-i14-forged-fields-ignored
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An administrator who is not root submits a change to sshd whose request also claims caller root, sensitivity Routine and no affected units. The claims are ignored: the change is decided on derived facts (Security, sshd affected), held at AskFirst for approval, and not run.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | txn: ApplyChange; units: service:sshd Running | fake: PASS; VM: not run in this build |
What it exercises
en-i15-old-worker-cannot-finish
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The daemon is killed mid-activation and stays down; the watchdog takes the generation change over while the old activation worker is still running. From the takeover on, only the watchdog’s lease advances the transaction or journals a call, the old worker’s activation never overlaps the watchdog’s revert, and each epoch has one owner.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | journal_owner_after_takeover: watchdog; no_overlap: activate_live(candidate), activate_live(revert_to); owners_per_epoch_max: 1 | fake: PASS; VM: not run in this build |
What it exercises
en-i16-boot-fallback-respects-floor
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
After a security update raises the floor, the machine restarts and the boot fails. The automatic fallback does not select a generation below the floor: the boot default and the generation that boots are the floor.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | boot_default: candidate; generation: candidate; security_floor: candidate | fake: PASS |
What it exercises
en-i17-root-administers-without-the-group
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Nobody is left in planarian-admin, so the group grants no one administrator capability. Root still holds it: root’s administrator-only change (gym-web’s listen address) is authorized and commits.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | txn: Committed ApplyChange; units: service:gym-web Running | fake: PASS |
What it exercises
en-i18-deleted-record-detected
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A retained journal record is deleted (not edited) and the rest re-framed so every frame is intact. The next read detects the break as an Integrity failure.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | posture: class Integrity; reports: JournalChainBroken | fake: PASS; VM: not run in this build |
What it exercises
en-i19-no-self-approval
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Root proposes an AskFirst change and then, at a terminal, tries to approve it: refused, and the change never runs. (A principal granting itself a capability has no request to make in Phase A: capabilities come from group membership only.)
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | txn: pending ApplyChange; units: service:sshd Running | fake: PASS; VM: not run in this build |
What it exercises
en-i21-irreversible-asks-even-for-root
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Root, at maximum autonomy, asks to restore a snapshot over user data: it cannot be reversed. It is held at AskFirst, whoever asks and whatever the setting.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | txn: RestoreSnapshot | fake: PASS |
What it exercises
en-i22-user-cannot-do-admin-things
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A seated, unprivileged user tries, in turn, to roll back, collect garbage, bind a driver and resolve the interlock. Each is refused Unauthorized; nothing executes.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | no_txn_executed: true; reports: Unauthorized; txn: Rollback, CollectGarbage, BindDriver, Resolve | fake: PASS; VM: not run in this build |
What it exercises
en-i23-third-party-override-kept
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Between a change’s boot entry being published and its override promotion, a third party rewrites the override file. Planarian does not overwrite it: the third party’s file stands, and the change does not commit (it stops at RecoveryFailed for a person to resolve). Before it, a runtime restart of gym-web wrote no boot entry. On the VM the third party is a cooperating writer: it holds the lock on planarian/ across the daemon’s compare-and-swap and replaces the file before letting go, so the swap waits and then sees the edit (R11).
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | override_file: third-party; txn: RecoveryFailed ApplyChange, service:gym-web Committed RestartUnit; units: service:gym-web Running | fake: PASS; VM: not run in this build |
What it exercises
en-i5-reject-survives-every-setting
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
With autonomy at its maximum, a change touching Planarian’s own unit is refused, not raised to a tier; with the setting changed and the change asked again, it is refused again. No setting turns the refusal into an approval to ask for.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | txn: ApplyChange | fake: PASS |
What it exercises
en-i6-repair-waits-for-activation
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
While a slow generation change sits in Activating, gym-web crashes. No repair is prepared until the activation leaves the exclusive states; by the end gym-web runs again.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | txn: service:gym-web RestartUnit; units: service:gym-web Running | fake: PASS; VM: not run in this build |
What it exercises
en-i8-token-bound-to-candidate
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An AskFirst change is approved; before it runs, the owner edits the configuration so the candidate it would build differs. The token, bound to the old candidate, does not authorise the new one.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | txn: Rejected(Stale) ApplyChange | fake: PASS; VM: not run in this build |
What it exercises
fake-assumptions-conformance
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The transaction kernel against the fake backend, once per docs/assumptions.md entry and injected outcome. Each run must satisfy the entry’s contract and reach a terminal state (I12).
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | invariants: I6, I12, I15; reports: AssumptionHeld(<assumption>) | fake: PASS |
What it exercises
flap-30s
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
gym-flap fails 30 s after every restart. Three attempts with 10 s / 60 s / 300 s backoff, then a 1 h cooldown. No restart loop.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | repair | incident: service:gym-flap; metrics: attempt_gaps_min [“10s”, “60s”, “300s”], restarts_max { “service:gym-flap” = 3 }; reports: Flapping | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
flap-regression-escalates
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
gym-flap fails 6 min after each restart, just past the 5 min stabilization. Each failure closes and then regresses the incident, and at the third regression it escalates with a 1 h hold.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | repair | incident: service:gym-flap; reports: Escalated; txn: service:gym-flap | fake: PASS |
What it exercises
No invariant by name.
floor-manual-rollback-needs-admin
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
After the security update, an administrator asks to roll back below the floor: AskFirst for an Administrator approver. The proposer cannot approve it (I19); root at a terminal can.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | generation: base; security_floor: candidate; txn: Committed Rollback | fake: PASS |
What it exercises
floor-moves-via-boot-only
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A security update that also needs a reboot takes the boot-only path: published to boot once, the machine restarts, the boot is confirmed and the change commits. The floor moves to it, as on the live path.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | security_floor: candidate; txn: Committed ApplyChange | fake: PASS |
What it exercises
floor-moves-via-watchdog
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The daemon is killed once the security update is Promoted and stays down; the watchdog completes the commit. The floor moves to the fixing generation: the floor record is part of the commit, whoever writes Committed.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | security_floor: candidate; txn: Committed ApplyChange | fake: PASS |
What it exercises
floor-revert-blocked
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A security update (gym-vulnlib 1.0 → 1.1, the old build carries knownVulnerabilities) commits and raises the floor. gym-web then crashes and fails every restart. The only rollback target, known_good, is below the floor: no Rollback runs, the incident escalates with RevertBlockedByFloor, and the boot default stays at the floor.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | boot_default: candidate; escalation: RevertBlockedByFloor; generation: candidate; incident: service:gym-web; invariants: I1, I16; security_floor: candidate; txn: Committed ApplyChange, Rollback | fake: PASS |
What it exercises
floor-service-package-fix
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A change whose only security fix is in a service’s package (gym-vulnsvc 1.0 -> 1.1; services.gym-vulnsvc.package, never in environment.systemPackages) commits. The security floor moves to it: the inspected set is the configuration’s whole package set, not environment.systemPackages.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | security_floor: candidate; txn: Committed ApplyChange | VM: not run in this build |
What it exercises
gc-during-approval
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Garbage collection runs while an AskFirst change waits for approval. The candidate survives (its build root is protected), and after approval verify_candidate passes.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | gc_protected: candidate, known_good, base; generation: candidate; txn: Committed ApplyChange | fake: PASS; VM: not run in this build |
What it exercises
gc-retain-all-generations
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
With services.planarian.gc.retainAllGenerations set and the generation budget far below what the generations use, four display-scale changes leave old generations behind; garbage collection then collects none of them (the full history stays), and reports the budget as unsatisfiable rather than enforcing it.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | collected_none: true; reports: BudgetUnsatisfiable; txn: Committed CollectGarbage | fake: PASS |
What it exercises
gen-bad-switch
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Switching to a prebuilt generation whose gym-web ExecStart is broken: AskFirst (the target was never live-tested), approved, fails its probes, reverted. The candidate never becomes the only boot entry.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | boot_default: base; generation: base; journal_agrees: true; known_good: unchanged; others_unchanged: true; override_source: base; txn: Reverted SwitchGeneration; units: service:gym-web Running | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
gen-disable-reenable-canonical
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An admin disables sshd (which has a canonical Readiness probe) and later re-enables it. Disabling makes the probe inapplicable without tampering with it, so neither change is reverted.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | journal_agrees: true; no_incident_on: service:sshd; override_source: candidate; txn: Committed ApplyChange, Committed ApplyChange; units: service:sshd Running | fake: PASS; VM: not run in this build |
What it exercises
gen-override-only-change
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A font-scale change touches only overrides.nix. The evaluated output differs from base, Stale is never raised (no owner edit happened), and the change commits.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | boot_default: candidate; evaluated_option: planarian.reference.fontScale 1.25; generation: candidate; journal_agrees: true; known_good: unchanged; live_tested: candidate; others_unchanged: true; override_source: candid… | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
identity-peer-exited
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice, logged in at the seat, sends a stop of her own pipewire and exits before the daemon resolves her. With no process behind the connection, the daemon attributes the request to uid 1000 with no session: never to a session it cannot read from that process.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | txn: StopAndSuppress | VM: not run in this build |
What it exercises
intent-cli-stop-rewritten
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An admin issues StopUnit through the CLI; it is rewritten to StopAndSuppress { until: NextBoot }, with a notice.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | intent | no_autonomous_txn: true; overrides: service:gym-web; reports: StopRewrittenNotice; units: service:gym-web Overridden | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
intent-start-again-repairs
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
After a user stop, “start gym-web again” lifts the override, and the unit is desired Running again and repaired.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | repair | overrides: ; txn: service:gym-web Committed LiftOverride; units: service:gym-web Running | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
intent-user-stops-service
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An admin asks to stop gym-web until they say otherwise. It stays stopped. The override is journaled before the stop, so there is no window in which a repair can race it.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | intent | journal_order: Override(service:gym-web), stop(service:gym-web); no_autonomous_txn: true; overrides: service:gym-web; txn: Committed StopAndSuppress; units: service:gym-web Overridden | fake: PASS; VM: not run in this build |
What it exercises
journal-compaction-preserves-state
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Compaction is forced while an Explicit override is live and an incident’s budget is exhausted, then both processes crash and restart. Both survive, and replay from checkpoint plus tail equals replay from full history.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | incident: service:gym-flap; overrides: service:gym-web; replay_equivalent: true | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
journal-record-edited
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A retained journal record is edited on disk while the daemon runs. The next read detects the broken chain as an Integrity failure; no transaction is taken from the edited record.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | invariants: I18; posture: class Integrity, target_reached planarian-failed@Integrity.target; reports: JournalChainBroken | fake: PASS; VM: not run in this build |
What it exercises
journal-torn-write
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A record write is interrupted, and separately a checkpoint write. A torn tail is truncated and never interpreted as a transition; a torn checkpoint never replaces the old one.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | journal_agrees: true; journal_readable: true; replay_equivalent: true | fake: PASS |
What it exercises
No invariant by name.
login-greeter-idle
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Boot to the greeter with autologin off and leave it idle for 10 min. SessionEstablished never holds, so graphical-session gets no incident.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | intent | no_autonomous_txn: true; no_incident_on: target:graphical-session; units: service:display-manager Running | VM: not run in this build |
What it exercises
No invariant by name.
malicious-decl-catalog-enable
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice enables the declared-but-off gym-bad-payload through the service-enable catalog entry (the owner made it user-controllable). The incident names alice, the transaction, and planarian/overrides.nix as the path.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | declaration_origin: file planarian/overrides.nix, path ApplyChange service-enable, unit service:gym-bad-payload, when txn, who user:1000; incident: service:gym-web | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
malicious-decl-input-update
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A flake input update (the supply-chain path) brings in a module that declares gym-bad-payload. The incident names the input, its lock change (old and new revisions), and who applied the update.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | declaration_origin: lock_change true, path flake input gym-modules, unit service:gym-bad-payload, when generation-created, who root; incident: service:gym-web | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
malicious-decl-owner-rebuild
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The owner’s flake gains gym-bad-payload and root runs nixos-rebuild switch outside Planarian. The incident on gym-web names gym-bad-payload’s declaration: the owner source file, the out-of-band rebuild as the path, and when that generation appeared.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | declaration_origin: file owner:configuration.nix, path out-of-band nixos-rebuild, unit service:gym-bad-payload, when generation-created, who root; incident: service:gym-web | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
manifest-weaken-out-of-band
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The owner weakens sshd’s probe in their own config and rebuilds outside Planarian. Canonical probes are still evaluated from the core package, so a later sshd fault is still detected.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | canonical_probe_source: core; status_seen: service:sshd [“Degraded”] | VM: not run in this build |
What it exercises
manifest-weaken-via-proposal
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A proposal tries to raise sshd’s canonical probe timeout to 600 s and mark it Diagnostic. No catalog entry can express that, so it is rejected and never executed.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | generation: base; probe_definitions_unchanged: true; txn: ApplyChange | fake: PASS; VM: not run in this build |
What it exercises
net-external-dependency-down
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The upstream service on the host stops while the gateway is reachable. The incident targets the external unit; there is no local restart.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | repair | incident: service:external:gym-upstream; no_txn_on: service:gym-upstream-client; units: service:gym-upstream-client Running | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
net-gateway-down
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Traffic to the gateway is dropped while the NIC driver and NetworkManager are fine. Every probe on the way times out, which is Unknown, never Failed (§10): nothing local is restarted or rebound and no repair is attempted, but Planarian says it can’t reach the upstream. When the block lifts, the client is Running again.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | repair | no_txn_on: service:gym-upstream-client, service:external:gym-upstream, service:NetworkManager, device:net-gym0; reports: ExternalDependencyUnreachable; status_seen: service:external:gym-upstream Unknown, service:gym-up… | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
net-upstream-down
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The upstream host is up but its service is stopped: connections are refused, a definite failure. The incident targets service:external:gym-upstream, named as the client’s root cause; nothing local is restarted. When the service is back, the incident closes.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | repair | incident: service:external:gym-upstream; no_txn_on: service:gym-upstream-client, service:NetworkManager, device:net-gym0; reports: ExternalDependencyDown; units: service:gym-upstream-client Running | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
override-over-attested-refused
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice stops her own pipewire with an override. An administrator then sets a different override on the same unit: Rejected(Precondition), naming alice as the holder; her override stands.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | overrides: service@1000:pipewire; txn: SetOverride | fake: PASS |
What it exercises
No invariant by name.
override-stopped-equals-stop
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An administrator with no session here (over SSH) asks, in turn, for an override that stops the display manager and for a plain stop of it. Both end the seated user’s session: both derive SessionLoss over the same units and both are held at AskFirst, since it is not the caller’s own session.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | txn: SetOverride, StopAndSuppress; units: service:display-manager Running | fake: PASS; VM: not run in this build |
What it exercises
owner-rebuild-breaks-service
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The owner runs nixos-rebuild switch on a config that breaks gym-web. Planarian tries restarts and then escalates; it never rolls back an owner-built generation without AskFirst.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | repair | generation: owner-built; incident: service:gym-web; metrics: restarts_max { “service:gym-web” = 3 }; reports: DiagnoserExhausted; txn: Committed Rollback; units: service:gym-web Failed | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
policy-daemon-session-drop-rebind
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
virtio-gpu is unbound during alice’s session; the diagnoser proposes BindDriver on gpu0. Its disruption is SessionLoss and the proposer is not the session owner, so it is AskFirst, parked and surfaced, and does not run.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | reports: AwaitingApproval, DisruptionSessionLoss; txn: device:gpu0 BindDriver | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
policy-daemon-wifi-runtime
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The selected AP (gym-ap-a) goes away while gym-ap-b is known. The diagnoser’s SetRuntimeOption Wi-Fi selection has ConnectivityLoss disruption, so it is AskFirst and does not run.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | txn: SetRuntimeOption | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
policy-refinement-violation
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A proposal’s expectation broadens may_change beyond what spec.predict() allows, and the refinement check rejects it.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | txn: RestartUnit | fake: PASS |
What it exercises
No invariant by name.
policy-stale-digest
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A proposal carries an observed digest from before a state change. It is rejected as Stale, and the current digest is returned.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | txn: RestartUnit | fake: PASS |
What it exercises
No invariant by name.
posture-absent-masked
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
planariad is masked and the machine rebooted. planarian-ready.target times out; Standard’s Absent responses apply; the agent stand-in never starts; unmasking and a clean ready lifts them.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | invariants: I20; not_started: service:gym-agent; posture: class Absent, not [“restrict_login”, “halt_to_recovery”, “alarm_dismissable”], responses [“alarm”, “notify_admins”, “disable_agents”, “restrict_remote”], target… | VM: not run in this build |
What it exercises
posture-crashed-budget
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
planariad reaches ready, then is killed until its restart budget is spent. OnFailure= reaches planarian-failed@Crashed; responses as Absent; agents stop; an administrator’s acknowledgement is journaled with identity.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | invariants: I20; posture: class Crashed, not [“restrict_login”], responses [“alarm”, “notify_admins”, “disable_agents”, “restrict_remote”], target_reached planarian-failed@Crashed.target; reports: AlarmAcknowledged(use… | VM: not run in this build |
What it exercises
posture-integrity-core-hash
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The recorded core hash no longer matches its signature (I2). planarian-recovery’s independent check fails the boot as Integrity: tamper alarm, notify, no agents, remote access off; local login proceeds with the warning. Only an administrator clears it.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | invariants: I2, I20; not_started: service:gym-agent; posture: class Integrity, not [“halt_to_recovery”], responses [“alarm”, “notify_admins”, “disable_agents”, “restrict_remote”], target_reached planarian-failed@Integr… | VM: not run in this build |
What it exercises
posture-integrity-core-script
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
One posture script (planarian-ack), not planariad, no longer matches its line in the core-hash record. planarian-recovery checks every Planarian binary and script, so the boot fails as Integrity all the same (B-12).
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | invariants: I2; posture: class Integrity, target_reached planarian-failed@Integrity.target | VM: not run in this build |
What it exercises
posture-integrity-journal-chain
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A record in the hash-chained journal is altered while the machine is off. The boot report finds the broken chain (I18): Integrity responses, as above.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | invariants: I18, I20; posture: class Integrity, responses [“alarm”, “notify_admins”, “disable_agents”, “restrict_remote”], target_reached planarian-failed@Integrity.target; reports: BootReport(JournalChainBroken) | VM: not run in this build |
What it exercises
posture-integrity-undeclared-early-unit
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A unit outside the generation’s closure is planted to start before planarian-ready. The boot report names it and Integrity responses apply. (Phase A’s boot report checks units only, and planariad stops on the finding rather than opening an incident: catalog review G18.)
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | posture: class Integrity, responses [“alarm”, “notify_admins”, “disable_agents”, “restrict_remote”], target_reached planarian-failed@Integrity.target; reports: BootReport(UndeclaredEarly: service:gym-planted) | VM: not run in this build |
What it exercises
posture-stops-launched-agents
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An administrator launches an agent with
planarian agent run(in planarian-agents.slice). planariad then crash-loops past its restart budget: planarian-failed@Crashed stops the agents’ slice as a whole, so the launched agent stops too, not only the declared agent units.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | invariants: I20; posture: class Crashed, responses [“disable_agents”], target_reached planarian-failed@Crashed.target; unit_after_failure: service:planarian-agent-launched Stopped | VM: not run in this build |
What it exercises
probe-delayed-healthy
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
gym-web’s /health answers after 15 s, past the 10 s timeout, while the service is fine. The status is Unknown, never Failed. After three Unknowns a diagnostic incident re-observes through the Tcp probe and does not restart.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | intent | incident: service:gym-web; no_txn_on: service:gym-web; status_never: service:gym-web [“Failed”]; status_seen: service:gym-web [“Unknown”] | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
probe-diagnostic-stale-healthy
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The evidence of the audio tone probe (Diagnostic, OnDemand) expires on a healthy machine. Status stays Running and no incident opens.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | intent | no_autonomous_txn: true; no_incident_on: service@1000:pipewire; units: service@1000:pipewire Running | fake: PASS; VM: not run in this build |
What it exercises
probe-failed-beats-stale
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
gym-web’s Http probe is made stale, then the process is killed: status is Failed, not Unknown. gym-app’s probe is stale at the same time and its status is Unknown.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | incident: service:gym-web; status_never: service:gym-app [“Failed”]; status_seen: service:gym-app [“Unknown”], service:gym-web [“Failed”] | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
probe-http-503-degraded
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
gym-web is active, but /health returns 503: the status is Degraded, an incident opens, and a restart clears the runtime fault.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | repair | others_unchanged: true; status_seen: service:gym-web [“Degraded”]; txn: service:gym-web Committed RestartUnit; units: service:gym-web Running | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
probe-trickling-endpoint
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
gym-web’s /health sends one byte every 2 s and never finishes, so a per-read timeout would never fire. Its 10 s timeout bounds the whole probe: Timeout, Unknown, never Failed, and gym-web is not restarted. Meanwhile the loop keeps ticking: gym-cache crashes and is repaired.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | no_txn_on: service:gym-web; status_never: service:gym-web [“Failed”]; status_seen: service:gym-web [“Unknown”]; txn: service:gym-cache Committed RestartUnit; units: service:gym-cache Running | VM: not run in this build |
What it exercises
No invariant by name.
quiet-reference-workload
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The invisibility-budget benchmark (§21): the reference game runs while a GC, a staged update and a non-urgent fault are queued. p99 frame time and input latency match the Planarian-stopped baseline; each deferred item runs in its window afterward; a PipeWire crash mid-run is repaired with one notice.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| bench | must-pass | metrics: deferred_items_ran 3, frame_time_p99 no significant difference vs baseline; notices_count: count 1, match pipewire |
What it exercises
No invariant by name.
quiet-repair-not-deferred
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
While a game holds Foreground, alice’s PipeWire crashes. Repairs of what foreground work depends on are not deferred: it is repaired at once, with exactly one notice.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | repair | notices_count: count 1, match pipewire; units: service@1000:pipewire Running | fake: PASS |
What it exercises
No invariant by name.
quiet-staged-update-foreground
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice changes her font scale while a game holds Foreground in her session. The verified candidate waits in Built; once Foreground ends it activates in that window and commits.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | reports: WaitsForWindow; txn: Committed ApplyChange | fake: PASS |
What it exercises
release-refused-leaves-incident
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
gym-web keeps failing until its incident has spent its repair budget and escalated. alice, who may not act on gym-web, asks to release it: refused Unauthorized, and the incident record is unchanged (no budget restored, no attempt brought forward, not reopened).
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | incident_unchanged_by_refusal: service:gym-web; txn: service:gym-web LiftOverride | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
reprepared-keeps-caller
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice changes her font scale while a game holds Foreground in her session, so it waits in Built. The daemon restarts: the change is prepared again as alice’s, in her session (not the daemon’s), and commits once Foreground ends.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | txn: Committed ApplyChange | fake: PASS |
What it exercises
No invariant by name.
residue-first-kill-teaches-stop
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A user’s first raw kill -9 of gym-web is caught as the unattributed residue; its notice names planarian stop service:gym-web as the attributed way. A later raw stop of gym-db is inferred the same way, without the lesson again.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | intent | notices_count: , , ; overrides: service:gym-web, service:gym-db, service:gym-app; reports: InferredOverride, Undo | fake: PASS |
What it exercises
No invariant by name.
residue-infer-stops-false
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A raw kill -9 of gym-backup, whose manifest sets inferStops = false, under Standard: never taken as intended; alarmed and started again, as a fault.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | overrides: ; reports: alarm; units: service:gym-backup Running | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
residue-inferred-listed-until-ack
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An inferred override (gym-web killed under Standard) is listed until a user acknowledges it with
planarian ack; acknowledging stops the listing and does not lift the override.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | intent | inferred_listed: ; no_txn_on: service:gym-web; overrides: service:gym-web; reports: InferredOverride; units: service:gym-web Overridden | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
residue-kill9-security
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A raw kill -9 of sshd (Security-sensitive) is a fault under every posture: alarmed, never inferred, and back up: restored by Planarian on its own (§11; catalog-review G16, decided 2026-10-29), or by systemd where its Restart= does it first (NixOS’s sshd), which is then alarmed from systemd’s restart count.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | overrides: ; reports: alarm; units: service:sshd Running | fake: PASS; VM: not run in this build |
What it exercises
residue-kill9-service
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A raw kill -9 of gym-web (Restart=no, not Security-sensitive). Under Standard it is accepted until next boot as an inferred, unattributed override, with a notice and an undo. No restart.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | intent | no_txn_on: service:gym-web; overrides: service:gym-web; reports: InferredOverride, Undo; units: service:gym-web Overridden | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
residue-kill9-undo
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Continuation: alice uses the offered undo. The inferred override is lifted by an authorized principal and gym-web is started.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | overrides: ; txn: LiftOverride; units: service:gym-web Running | fake: PASS |
What it exercises
No invariant by name.
residue-oom-kill
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
gym-web is driven past MemoryMax and the kernel kills it (Result=oom-kill). OOM kills are always faults: restarted, no inferred override.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | repair | overrides: ; txn: service:gym-web RestartUnit; units: service:gym-web Running | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
residue-stop-security-unit
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Root stops sshd (Security-sensitive) with systemctl stop outside Planarian. An unattributed stop of a Security unit is never taken as intended: it is restored on its own and alarmed.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | repair | overrides: ; reports: alarm; txn: service:sshd RestartUnit; units: service:sshd Running | fake: PASS; VM: not run in this build |
What it exercises
revert-fails
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Bad candidate, while an environmental fault (sshd’s port blocked) makes the revert probes fail too. Result: RecoveryFailed, an alert, and the interlock, which blocks autonomous repair of a later unrelated fault until an admin resolves it.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | interlock: clear; reports: RecoveryFailed, alert; txn: RecoveryFailed ApplyChange, service:gym-cache RestartUnit; units: service:gym-cache Running | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
revert-fails-interlock-survives-compaction
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A failed revert, then forced compaction, then both processes restart. Autonomous mutation stays blocked until the resolution is durably recorded.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | interlock: set; no_autonomous_txn: true; replay_equivalent: true; units: service:gym-cache Failed | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
revert-to-older-sets-no-floor
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
gym-newlib 1.0 is added, then a release makes it 2.0, which carries knownVulnerabilities. Undoing back to the 1.0 generation is a Revert to an older generation that merely lacks the newer package: it sets no security floor (§12e).
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | security_floor: none; txn: Committed Revert | fake: PASS |
What it exercises
rollback-repair-foreground-dependency
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice’s font-scale update commits; then, while a game holds Foreground in her session, the new generation’s NetworkManager fails and restarts cannot fix it. Wi-Fi is something foreground work depends on, so the diagnoser’s Rollback is a repair: it runs at once, during the workload, never parked in Built for a window, and Wi-Fi is back. One notice about it.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | generation: known_good; incident: service:NetworkManager; notices_count: count 1, match NetworkManager: roll back to the known-good generation; txn: Committed ApplyChange, Committed Rollback; units: service:NetworkMana… | fake: PASS |
What it exercises
No invariant by name.
rollback-repair-foreground-unrelated
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The same committed update, but the new generation breaks the print service, which foreground work does not depend on. While the game holds Foreground the repair is deferred in fact: no Rollback (and no other repair) is submitted, no transaction is opened, and the incident’s budget does not move. When the game ends the deferred repair runs: in-place repair fails, the Rollback runs and the printer is back.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | generation: known_good; incident: service:gym-printer; txn: Committed Rollback, Rollback, ; units: service:gym-printer Running | fake: PASS |
What it exercises
No invariant by name.
rollback-repair-session-loss-asks
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice’s font-scale update commits; then the new generation breaks the print service and restarts cannot fix it. Switching back would also restart the display manager, which hosts her active session’s compositor, so the diagnoser’s Rollback would end her session: being a repair exempts it from waiting, never from asking. It is AskFirst (SessionLoss; the diagnoser is not the session’s own user) and, with nobody approving, it never runs: the update stays live.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | generation: candidate; txn: pending Rollback; units: service:display-manager Running | fake: PASS |
What it exercises
No invariant by name.
session-logout-one-of-two
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice logs out of one of her two sessions. Her user services (pipewire) keep running; no incident, no transaction.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | intent | no_autonomous_txn: true; no_incident_on: service@1000:pipewire; units: service@1000:pipewire Running | fake: PASS |
What it exercises
No invariant by name.
session-second-compositor-fails
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice has two graphical sessions. The compositor in the second never runs; an incident opens for that session only, though her user manager’s graphical-session.target is already reached.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | incident: target@1000/s2:graphical-session; no_incident_on: target@1000/s1:graphical-session | fake: PASS |
What it exercises
No invariant by name.
ssh-session-expects-no-desktop
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice logs in over SSH only (a user session with no seat) and stays for 5 min. Her user services are desired, but no compositor is expected: graphical-session has no instance for that session and no incident opens.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | no_incident_on: target:graphical-session; units: service@1000:pipewire Running | fake: PASS |
What it exercises
No invariant by name.
stale-owner-edit-during-build
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
During the build, the owner edits a tracked file without committing it. The transaction is Rejected(Stale), never activated, and its GC root is released.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | gc_root_released: true; generation: base; override_source: base; txn: Rejected(Stale) ApplyChange | fake: PASS; VM: not run in this build |
What it exercises
stale-untracked-file-ignored
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
During the build, the owner adds an untracked file to the flake. Untracked files are not visible to Nix: no Stale, and the file is reported.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | generation: candidate; reports: UntrackedExcluded; txn: Committed ApplyChange | VM: not run in this build |
What it exercises
No invariant by name.
storage-boot-full-publish
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
/boot is full when publish_boot runs. This is treated as a Checking failure, so the change reverts, and the candidate is never the only boot entry.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | boot_default: base; generation: base; journal_agrees: true; override_source: base; txn: Reverted ApplyChange | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
storage-budget-unsatisfiable
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The generation budget is set below the size of the protected set. GC collects everything unprotected, reports BudgetUnsatisfiable with the shortfall, and never touches the protected set.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | gc_protected: known_good, base, previous; reports: BudgetUnsatisfiable | fake: PASS; VM: not run in this build |
What it exercises
storage-enospc-during-build
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The Nix store fills during build: Rejected(NoSpace), a storage incident opens, and the original change is not retried until that incident closes.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | generation: base; incident: device:fs-nix; txn: Rejected(NoSpace) ApplyChange | fake: PASS |
What it exercises
No invariant by name.
storage-journal-btrfs-reuse
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
storage-journal-fs-full again on btrfs, after a snapshot and after 1000 journal wraparounds (nodatacow reuse).
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | journal_readable: true; one_of: , , | VM: not run in this build |
What it exercises
No invariant by name.
storage-journal-fs-full
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The journal filesystem is filled to the reserved-block boundary as the daemon’s uid. Either emergency collection is recorded and executed, or mutation stops with an explicit NoSpace or RecoveryFailed. A journal record is never lost or misread.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | journal_readable: true; one_of: , , ; replay_equivalent: true | VM: not run in this build |
What it exercises
No invariant by name.
storage-nix-full-gc
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The Nix store filesystem drops below max(2 GB, 5%) while old generations exist. CollectGarbage runs first, and the protected set is untouched.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | repair | gc_protected: known_good, base, previous; txn: Committed CollectGarbage; units: device:fs-nix Running | fake: PASS; VM: not run in this build |
What it exercises
suspend-resume
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
S3 suspend for 2 min, then resume. Evidence goes stale while suspended (Unknown, not Failed), nothing is restarted, and everything is Running within settle.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | intent | no_autonomous_txn: true; others_unchanged: true; units: service:NetworkManager Running, service:gym-web Running, service@1000:pipewire Running | fake: PASS |
What it exercises
No invariant by name.
svc-killed
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
gym-web is SIGKILLed; it has no Restart=; the diagnoser restarts it.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | repair | incident: service:gym-web; may_change: service:gym-web; metrics: restarts_max { “service:gym-web” = 1 }; others_unchanged: true; settle: ready service:gym-web, timeout 60s; txn: service:gym-web Committed RestartUnit; u… | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
svc-killed-defer-to-systemd
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
gym-restartful is killed; systemd restarts it itself; Planarian does not act.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | intent | no_txn_on: service:gym-restartful; others_unchanged: true; units: service:gym-restartful Running | VM: not run in this build |
What it exercises
No invariant by name.
svc-killed-user-scope
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice’s pipewire is killed while her session is up; the user-scoped unit is restarted.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | repair | may_change: service@1000:pipewire, service@1000:wireplumber; others_unchanged: true; settle: ready service@1000:pipewire, timeout 60s; txn: service@1000:pipewire Committed RestartUnit; units: service@1000:pipewire Runn… | VM: not run in this build |
What it exercises
No invariant by name.
svc-misconfig-listen-address
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The user sets gym-web’s listen address to 192.0.2.10, a permitted value that no interface holds. The change is applied as a transaction, the probes fail, and the change is reverted.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake, vm | must-pass | boot_default: base; generation: base; journal_agrees: true; known_good: unchanged; others_unchanged: true; override_source: base; txn: Reverted ApplyChange; units: service:gym-web Running | fake: PASS; VM: not run in this build |
What it exercises
No invariant by name.
svc-misconfig-state-file
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
gym-web’s mutable state file is corrupted, so it fails on every start. Restarting cannot fix it: three attempts with backoff, then escalation with a report. Phase B upgrades required_post to Running.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | repair | generation: base; incident: service:gym-web; metrics: attempt_gaps_min [“10s”, “60s”], restarts_max { “service:gym-web” = 3 }; others_unchanged: true; reports: DiagnoserExhausted; units: service:gym-web Failed | VM: not run in this build |
What it exercises
No invariant by name.
switch-facts-from-dry-activation
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A prebuilt generation has the running one’s manifest, but its NetworkManager unit has a new store path. Switching to it has the facts its dry activation shows: NetworkManager restarts, so it is ConnectivityLoss and asked first, as the same switch built as a candidate would be. Nobody approves, so nothing changes (§11 equivalent effects, R04).
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | generation: base; others_unchanged: true; txn: SwitchGeneration | fake: PASS |
What it exercises
No invariant by name.
test-build-is-integrity
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The test build (gym-hooks, which accepts fault injection over its socket) starts with a configuration that does not allow it, as it would anywhere outside the gym’s VM tests. planariad refuses to run: an Integrity failure, with exactly the Integrity responses and no agent started. (The module refuses such a configuration at evaluation; checks.module-eval covers that.)
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | must-pass | not_started: service:gym-agent; posture: class Integrity, not [“halt_to_recovery”], responses [“alarm”, “notify_admins”, “disable_agents”, “restrict_remote”] | VM: not run in this build |
What it exercises
No invariant by name.
token-wall-clock-moved-back
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
An approval token is held, then the wall clock is moved back 48 h and 24 h pass. The token still expires 24 h after issue, timed on CLOCK_BOOTTIME (§12h): presenting it is refused and the parked change ends Rejected.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | txn: ApplyChange | fake: PASS |
What it exercises
unauthorized-change-does-no-work
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice, who may not change the configuration beyond her own settings, asks to disable sshd: refused Unauthorized before any source is staged, evaluated or built.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| fake | must-pass | no_build_work: true; txn: ApplyChange; units: service:sshd Running | fake: PASS |
What it exercises
No invariant by name.
user-unit-after-logout
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
alice logs out and her pipewire stops. A user unit is desired only while its session exists, so no incident opens.
| Harness | Scoring | Expects | Latest verdict |
|---|---|---|---|
| vm | intent | no_autonomous_txn: true; no_incident_on: service@1000:pipewire, target:graphical-session | VM: not run in this build |
What it exercises
No invariant by name.
Source files
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Which invariants and obligations live in each file.
crates/exec/src/kani.rs
| Obligation | Kind | Line | Invariants |
|---|---|---|---|
exec::approval_tokens_expire_after_their_lifetime | Kani harness | 14 | I8 |
exec::residue_security_is_never_intended | Kani harness | 94 | I13 |
exec::residue_crashes_are_faults | Kani harness | 105 | I13 |
exec::residue_is_inferred_only_where_allowed | Kani harness | 116 | I13 |
crates/policy/src/lib.rs
| Obligation | Kind | Line | Invariants |
|---|---|---|---|
policy::decide | verified fn | 869 | I5 |
policy::may_approve | verified fn | 923 | I19 |
policy::may_grant | verified fn | 942 | I19 |
policy::is_administrator | verified fn | 1014 | I17 |
crates/policy/src/proofs.rs
| Obligation | Kind | Line | Invariants |
|---|---|---|---|
policy::i5_autonomy_never_touches_reject | proof fn | 73 | I5 |
policy::i11_non_routine_session_asks | proof fn | 95 | I11 |
policy::i11_session_only_raises | proof fn | 118 | I11 |
policy::global_floors | proof fn | 129 | I21 |
policy::own_session_is_per_session | proof fn | 143 | I21 |
policy::i16_below_floor_needs_administrator | proof fn | 161 | I16 |
policy::administrator_class_only_on_ask_first | proof fn | 170 | I16 |
policy::security_override_held_for_administrator | proof fn | 179 | I22 |
policy::security_restart_runs_alone_only_to_restore | proof fn | 196 | I22 |
policy::i19_no_self_approval | proof fn | 215 | I19 |
policy::agents_and_unattributed_are_refused | proof fn | 227 | I22 |
policy::i10_canonical_probe_changes_ask | proof fn | 237 | I10 |
policy::unauthorized_is_refused_first | proof fn | 246 | I5 |
policy::core_is_never_a_target | proof fn | 255 | I5 |
policy::only_administrators_resolve | proof fn | 266 | I22 |
policy::users_cannot_change_generations | proof fn | 276 | I22 |
policy::i14_facts_caller_is_not_authority | proof fn | 290 | I14 |
policy::i17_administrators_never_empty | proof fn | 327 | I17 |
crates/txn/src/chain.rs
| Obligation | Kind | Line | Invariants |
|---|---|---|---|
txn::follows | verified fn | 97 | I18 |
txn::verify | verified fn | 120 | I18 |
txn::next_link | verified fn | 168 | I18 |
txn::appending_keeps_the_chain | proof fn | 182 | I18 |
txn::an_edited_record_breaks_the_chain | proof fn | 214 | I18 |
crates/txn/src/kani.rs
| Obligation | Kind | Line | Invariants |
|---|---|---|---|
txn::recovery_table_follows_the_state_machine | Kani harness | 185 | I12 |
txn::watchdog_takes_over_only_when_it_should | Kani harness | 199 | I12 |
txn::takeover_fences_out_the_previous_owner | Kani harness | 225 | I15 |
txn::override_swap_never_overwrites_a_third_party | Kani harness | 261 | I23 |
crates/txn/src/ledger.rs
| Obligation | Kind | Line | Invariants |
|---|---|---|---|
txn::compatible_exec | verified fn | 460 | I6 |
txn::well_formed | verified fn | 534 | I6 |
txn::validate | verified fn | 607 | I6, I15 |
txn::apply | verified fn | 915 | I6, I12 |
txn::add_interlock | verified fn | 934 | I12 |
txn::i6_one_generation_active | proof fn | 1235 | I6 |
crates/txn/src/machine.rs
| Obligation | Kind | Line | Invariants |
|---|---|---|---|
txn::step_allowed | verified fn | 61 | I12 |
txn::i12_steps_terminate | proof fn | 130 | I12 |
txn::i12_terminal_always_reachable | proof fn | 141 | I12 |
txn::terminal_is_final | proof fn | 151 | I12 |
txn::refusal_only_before_mutation | proof fn | 161 | I12 |
txn::runtime_never_reverts | proof fn | 171 | I23 |
txn::only_generations_publish | proof fn | 182 | I6 |
txn::boot_only_never_activates | proof fn | 194 | I6 |
txn::generations_commit_after_promotion | proof fn | 206 | I23 |
API docs
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
cargo doc for the workspace. Proof obligations link to source lines instead: rustdoc renders verus! blocks poorly.
planarian-changes(crates/changes)planarian(crates/cli)planarian-console(crates/console)planarian-daemon(crates/daemon)planarian-diagnose(crates/diagnose)planarian-exec(crates/exec)planarian-fake(crates/fake)planarian-gym(crates/gym)planarian-incident(crates/incident)planarian-intent(crates/intent)planarian-ipc(crates/ipc)planarian-policy(crates/policy)planarian-schema(crates/schema)planarian-state(crates/state)planarian-tray(crates/tray)planarian-txn(crates/txn)planarian-recovery(crates/watchdog)
§1. What it is, in one page
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Most operating systems can tell you that something broke. Planarian is built to know what should be true, notice when it isn’t, and close the gap without making things worse.
It can do this because the system is declared: a NixOS configuration says which services, drivers, and settings should exist. From that, plus a manifest that adds what NixOS doesn’t say (what depends on what, how to test each thing, how risky it is to touch), Planarian derives desired state. Desired state has conditions: the graphical session should be running after someone logs in; a printer service should be running when the printer is connected; a service the user stopped on purpose is left alone until they say otherwise. Planarian observes actual state continuously and runs health probes under a strict contract (no sudden noises, no side effects, bounded cost). When a probe times out, the answer is Unknown, not Failed.
A gap between desired and observed state opens an incident. Repair follows a strict order:
- Root-cause. The dependency graph distinguishes “must be up for this to work” from “merely starts before” from “we suspect these are related.” A failed audio service whose required sound-card driver also failed is a symptom; the driver is the target.
- Deterministic search. A small planner searches the known transitions (restart this, rebind that, apply a cataloged change, roll back) for one whose preconditions hold and whose predicted result closes the gap, safest and least disruptive first.
- Summon the model. Only if search finds nothing, or the incident’s budget is spent, does a local model get the state, the graph, what was tried, and the manuals. It proposes in the same typed format the planner uses.
- Escalate. If that also fails, the user gets a plain-language report, and the incident goes quiet with a cooldown rather than retrying forever.
User requests follow the same order: “reconnect Wi-Fi” matches a declared action directly; “stop the backup service while I work on it” becomes an attributed override of desired state, so Planarian doesn’t fight the user; “make it faster” is ambiguous and goes to the model, which asks before proposing.
Whoever proposes, one executor decides, and it decides from facts it derives itself: what the change actually touches in the evaluated configuration, how sensitive and how disruptive that is, what else changed this session. Nothing in a proposal can grant authority. Runtime actions with no lasting effect just run. Configuration changes run as a transaction: journaled before anything happens, activated as a candidate generation, probed, and either committed or reverted, with a watchdog that finishes the job if Planarian itself is killed halfway. Security-relevant, irreversible, or user-data changes wait for approval, and the approval is re-checked against the exact change the moment before it runs. The repair machinery itself can never be changed by any proposal, under any approval.
The model is a convenience and a fallback. If it is absent, broken, or wrong, the system still boots, runs, repairs the easy faults, and reverts the bad changes.
Planarian is meant to be unnoticed under normal use. Its overhead, its notifications, and its effect on a game’s frame time are measured, and none of them may get worse between releases. It defers non-urgent work while the user is absorbed in something, and it never undoes a deliberate choice; it only needs the choice to have a name on it.
§2. Component diagram
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
┌────────────────────────────────────────────────────────────────────────┐
│ USER SURFACES console · `ask` CLI · launcher box │
└──────────────┬──────────────────────────────────────────┬──────────────┘
│ requests │ approvals (secure path)
▼ ▼
┌────────────────────────┐ ┌─────────────────────────┐
│ INTENT MATCHER │ unmatched / fuzzy │ APPROVAL BROKER │
│ phrases → transitions ├────────────┐ │ tokens bound to the │
│ or desired-state │ │ │ concrete candidate │
│ overrides │ ▼ └────────────┬────────────┘
└──────────────┬─────────┘ ┌──────────────────┐ │ token
│ proposal │ MODEL SERVICE │ │
│ │ unprivileged; │ │
│ │ emits proposals │ │
│ └────────┬─────────┘ │
▼ ▼ proposal ▼
┌────────────────────────────────────────────────────────────────────────┐
│ CORE │
│ ┌───────────┐ ┌───────────┐ ┌─────────┐ ┌──────────────┐ ┌─────────┐ │
│ │ INCIDENT │▶│ DIAGNOSER │▶│ POLICY │▶│ EXECUTOR │▶│ JOURNAL │ │
│ │ lifecycle │ │ search │ │ derives │ │ transaction │ │ durable │ │
│ │ budgets │ │ over graph│ │ facts; │ │ protocol │ │ states │ │
│ │ cooldowns │ │ │ │ decides │ │ │ │ │ │
│ └─────▲─────┘ └───────────┘ └─────────┘ └──────┬───────┘ └────▲────┘ │
│ │ divergences │ │ │
└────────┼────────────────────────────────────────┼───────────────┼──────┘
│ ▼ │
┌────────┴───────────────────┐ ┌──────────────────────────┐ ┌──┴──────────┐
│ STATE │ │ BACKENDS (swappable) │ │ RECOVERY │
│ desired (conditions, │◀──│ units: systemd │ │ WATCHDOG │
│ overrides) │ │ config: nixos-rebuild │ │ separate │
│ observed (unknown, stale) │ │ snapshots: btrfs/ZFS │ │ unit; reads │
│ health (probe contract) │ │ inventory: udev/sysfs │ │ the journal │
│ graph (typed edges) │ │ kernel: Linux │ └─────────────┘
└────────────────────────────┘ └──────────────────────────┘
│
┌──────────────┴───────────────┐
│ TRAINING GYM (offline) │
│ faults, lifecycle cases, │
│ disruption metrics │
└──────────────────────────────┘
Arrows into the core carry proposals. Only the executor calls backends. The model has exactly one write capability: submit a proposal. The recovery watchdog is a separate process that can finish or revert a transaction when the daemon is gone. Everything drawn above the CORE box is a principal (§7, §11); the core’s response to its own failure is enforced by systemd and the initrd, not by the core (§12d).
§3. Feature overview
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
| Feature | What the user sees | Status at v1 (NixOS service) |
|---|---|---|
| Fault detection | “audio: Failed (probe tone-playback)” within seconds; “Unknown” when evidence is stale | Yes |
| Deterministic repair | Restart-and-rebind faults fixed with no model, logged with reason | Yes |
| Incident discipline | No restart loops: cooldowns, backoff, flap detection, cooperation with systemd’s own restarts | Yes |
| Respecting intent | A service the user stopped stays stopped; conditional desired state (after login, when device present) | Yes |
| Durable transactions | A config change survives Planarian being killed mid-activation; the watchdog commits or reverts | Yes |
| Automatic revert | Failed probes after activation revert without the user | Yes |
| Model-assisted repair | Harder faults diagnosed by a local 4B model; proposals previewed | Phase B |
| Natural-language operation | “reconnect wifi”, “make text bigger” → matched action or cataloged change with preview | Yes |
| Risk tiers and approval | Security, external, user-data changes wait; approval re-validated at execution | Yes (CLI approval in A, root at a terminal; broker in C) |
| Disruption awareness | Actions that would drop your session or connection are classed and surfaced as such | Yes |
| Snapshots | Snapshot before every switch; restore on request; space-budgeted | If btrfs or ZFS present |
| Boot-confirmed generations | A kernel change is not “good” until it has rebooted | Yes |
| Visual console | Live graph of desired vs observed vs health, timeline, generations, incidents | Yes (read-only in A) |
| Degraded mode | Everything but model-assisted repair and fuzzy requests works with no model | Yes |
| Attributed actions | Every deliberate stop, disable, or disconnect is recorded with who did it, before it happens; nothing deliberate is undone | Yes |
| Security floor | Automatic revert never goes back below a generation that fixed a security problem | Yes |
| Failure posture | If Planarian itself fails or the system shows signs of tampering, the machine responds as declared: alarm, agents off, restricted access, or halt | Yes (Standard posture in A; all postures in C) |
| Quiet mode | Nothing non-urgent runs while a game or fullscreen app is in the foreground; updates wait for idle | Yes |
| Sandbox tier | Proposed changes tested first in a disposable Firecracker microVM | Phase B |
§4. Roadmap
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Each phase ships something usable on its own. Later phases replace a backend; the contract does not change.
| Phase | Deliverable | Gate to exit |
|---|---|---|
| A · Contract on NixOS 26.05 | planariad: schema, state model (desired/observed/health/graph), policy, executor with hash-chained transaction journal and recovery watchdog, systemd and nixos-rebuild backends, change catalog, deterministic diagnoser and incident manager, intent matcher, ask CLI, read-only visual console, fault-injection gym with lifecycle cases. Attributed overrides and session-aware actors (§8, §11); security floor (§12e); failure-posture mechanism with the Standard posture (§12d); activation scheduling and quiet mode (§12f, §10); boot report (§10); signed external desired-state source and signed health report as hooks (§9, §10). Gym also runs non-gating against an Asterinas NixOS VM and a Firecracker microVM target (allowed to stay red; no engineering time beyond keeping them running). schema, policy, and the transaction kernel in the Verus subset. No model. | GA: (1) the demonstration: inject a fault → repaired; inject a bad configuration → reverted; kill planariad during activation → watchdog recovers; restart the daemon → it reconciles the journal and explains what happened accurately. (2) ≥ 60% of the catalog cleared deterministically; every bad generation reverted; the manifest-weakening fault rejected; zero gamed successes; unnecessary-intervention rate on the intent cases = 0; every posture and security-floor gym case passes. (3) policy proofs pass in CI. (4) Baselines reported: the invisibility budget (§21) and the verification ratio d:a with the trust count (§17a). |
| B · The model | Model service with residency rules (§20); boot-model fine-tune via distillation; summoned only on diagnoser exhaustion; evaluation suite with held-out fault families. Sandbox tier: dry-run and Firecracker-from-snapshot (§12g); a passing sandbox run is required for Tested changes where KVM is available. Non-human principals may hold catalog and model-catalog authority (§11). | GB: diagnoser + model clear ≥ Y% including held-out families; a worse model is rejected by the suite; no model load while Foreground holds; invisibility budget and d:a not degraded; the trust count not increased. |
| C · PlanarianOS | PlanarianOS: a NixOS distribution in which Planarian governs the machine from first boot (§9a), with three profiles: agent host, server / fleet node, personal. Installer selects profile and failure posture; all four postures (§12d); approval broker; snapshot backend; hardware inventory and explain; Flatpak as the user-scope app valve. The personal profile ships the COSMIC reference desktop and the console (a libcosmic app with approvals) as an optional frontend. Planarian also remains a standalone NixOS module and flake. Real users feed the gym. | GC: per-profile gates (§9a); ten external users for a month; every reported fault reproduced as a catalog entry; invisibility budget and d:a not degraded; the trust count not increased. |
| D · Rust system layer | Snix-based evaluator and store; Rust init and supervisor; Rust CoW filesystem. Linux kernel. Backend contracts revised from what A–C taught. | GD: no systemd, no C Nix; full catalog passes; d:a not degraded and the trust count not increased (§17a). |
| E · Asterinas | Kernel swap; reference-laptop drivers; Smithay desktop; secure attention key; Verus on the core. | GE: reference laptop boots, suspends, connects, plays audio, runs the desktop, survives the catalog from cold boot; d:a not degraded and the trust count not increased. |
| F · Acceleration | GPU drivers, model on GPU, COSMIC migration, second machine, ARM64. | GF: accelerated desktop and model on both machines; planner correct under injected accelerator failure. |
Firmware: binary vendor firmware is loaded as data by Rust drivers and is allowed permanently.
Part II — Specification for implementation
Normative unless marked draft. MUST/MUST NOT are requirements; SHOULD is a default the agent may deviate from with a logged reason in docs/deviations.md.
§5. Scope and non-goals
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Phase A in scope: everything in the Phase A roadmap row, on NixOS 26.05 with systemd, D-Bus, udev; btrfs/ZFS optional; root via a dedicated systemd service.
Out of scope for A: the model service (B), the sandbox tier on the real change path (B; the Firecracker gym target runs non-gating in A), approval broker and console write paths beyond §16 (C), PlanarianOS profiles and the Relaxed, Guarded, and Sealed postures (C), snapshot restore (C), Rust evaluator or init (D), kernel work (E). Interfaces to them exist as traits with one implementation.
Not requirements in any phase of this spec: the directions in docs/future-work.md §3–§8b and §11 (remote principals and the endpoint catalog, app capabilities, fleet membership and attestation, resource governance beyond model residency, forecasting, Asterinas convergence, self-improvement). The schema hooks in §9 and §10 exist so these can be added later without reopening the proven crates.
Bypasses. Every known route around a guarantee, open or accepted by design, is a row in docs/bypasses.md (§17b) with what it breaks, its mitigation, and the phase that narrows it. A guarantee in this spec holds only as widely as its row in that file allows.
§6. Repository layout
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
planarian/
Cargo.toml workspace
crates/
schema/ types; no I/O (§8). Verus subset.
policy/ decision function (§11). Verus subset.
txn/ transaction kernel: journal states, recovery (§12). Verus subset.
state/ desired/observed/health/graph assembly; probe runner (§10)
exec/ executor protocol around txn (§12)
incident/ incident lifecycle, budgets, cooldowns (§13)
diagnose/ deterministic search (§13)
changes/ change catalog: typed handlers (§9)
intent/ phrase → transition or desired-state override (§14)
backends/
linux/ systemd, logind, sysfs/udev, nixos-rebuild, probes, processes behind the §8 traits (one crate in A; snapshot-btrfs in C)
fake/ the in-memory backends the kernel is tested against (§18 rule 12): a second real implementation
ipc/ request and response types shared by daemon, CLI, console and tray
watchdog/ planarian-recovery: separate binary (§12)
daemon/ planariad: wiring, IPC, systemd units
cli/ `planarian`, `ask`
console/ iced read-only console (§16a)
tray/ status indicator (StatusNotifierItem); ships with the console
gym/ catalog, VM harness (NixOS VM, Firecracker, Asterinas NixOS), scoring, metrics, verification accounting, the guarantees site generator (§15, §17a, §17b, §17c)
sandbox/ (B) dry-run and Firecracker-from-snapshot tier (§12g); not present in A
model/ (B) not present in A
nix/
module.nix services.planarian, including the unit manifest options (§9)
posture.nix planarian-ready.target and failure-posture targets (§12d)
reference.nix personal profile in A: COSMIC + greeter + console autostart; wires the shipped change catalog (§9)
package.nix, console.nix, verus.nix, firecracker.nix
profiles/ (C) agent-host.nix, server.nix, personal.nix (§9a)
template/ `nix flake init -t` starting point, including planarian/overrides.nix
vm/, live/, installer/, demo/ the demo VM, live image and installer (docs/planarian-os.md)
tests/ NixOS VM tests: the gym's VM harness, runtime/generations/posture/installer tests, the boundaries check, the Verus check
gym/ run-vm.sh (the by-hand VM catalog runner)
share/canonical.json canonical probes (§9)
docs/ SPEC.md (this), AGENTS.md (§18), deviations.md, pins.md, assumptions.md (§17a), bypasses.md (§17b), spec-history.md (changes through v4.4), verification.md and verification-ratio.toml (§17a), guarantees.md, ga-status.md, catalog-review.md, planarian-os.md, why.md (public design essay), future-work.md (non-normative)
The layout above is descriptive of the repository at v4.6; it replaces the aspirational v4.5 layout (separate backend crates, nix/manifest.nix, nix/changes/, stub sandbox/model crates), which docs/deviations.md recorded only in part.
Dependency rule, enforced in CI: schema, policy, txn, exec, diagnose, incident, state MUST NOT depend on backends/*, std::process, or read /proc, /sys, /nix. Backends are passed as generic type parameters; in Phase A a single enum Backends wraps the concrete set. No dyn Trait in schema, policy, txn, exec.
§7. Principles and invariants
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Principles
- The healer is not the patient. The repair core is outside the blast radius of anything it changes; no generation or proposal can modify it.
- The model proposes; machinery disposes. Model output is a typed proposal, never a command.
- Machinery before model. Deterministic code handles everything it can; the model is a fallback.
- Desired, observed, and healthy are three different things. Never collapse them.
- Every change is a transaction. Journaled before, probed after, reverted on failure, recoverable if the daemon dies.
- Authority is derived, never supplied. The executor computes what a change touches and how risky it is from the evaluated configuration; no field of a proposal or of any other request carries authority or identity.
- Sensor input is data, never instructions.
- Degrade to a working machine. Without the model, the console, or the network, the system boots, runs, repairs simple faults, and reverts.
- Don’t fight the user. A deliberate stop, disable, unmount, or disconnect is an override of desired state, not a fault, provided it reaches the journal with the actor’s identity before it takes effect (§11).
- Disruption is a cost. Prefer the least disruptive repair; surface disruption before acting.
- The oracle is protected. Nothing a proposal can do may weaken a canonical probe.
- Grow into the hardware.
- The executor is a reference monitor. It is always invoked, tamperproof, and small enough to verify. Everything above the verified core is a principal with an authenticated identity, a set of capabilities, and a reputation; authority is derived below that line and never held above it. The rule is species-blind: it applies the same way to users, root acting through Planarian, apps, the on-device model, and remote agents.
- Invisible under normal use. Planarian’s overhead, notifications, and effect on foreground work are measured and may not get worse; non-urgent work waits while the user is absorbed in something.
- The response to the monitor failing cannot depend on the monitor. Failure posture is declared in the generation and enforced by systemd and the initrd.
- Name every way around. A guarantee is stated only as widely as it holds; each route around it is a row in
docs/bypasses.mdwith its phase, and no gate passes with an open High row.
Invariants (checked by txn and policy; each is a test in gym and, where marked, a proof)
| ID | Invariant | Proof |
|---|---|---|
| I1 | A boot-confirmed known-good generation always exists, is bootable, and is in the boot menu; an accepted_baseline never replaces it there. It may lag the security floor until the floor generation is boot-confirmed (§12e); it is then a manual fallback, never an automatic one. | D |
| I2 | The installed core package’s hash equals the hash recorded in its signature. In A–D the reference hash is recorded by the build and covers every Planarian binary and script (§12d); it detects a swapped file, not a rebuilt generation. | E |
| I3 | Garbage collection never removes known-good, the previous generation, the current generation, an in-flight candidate, or the boot model. | A (test), D (proof) |
| I4 | Running means desired-Running under the current conditions, observed-active, and every applicable Readiness probe passed with fresh evidence. Diagnostic probes never determine Running. | A |
| I5 | Forbidden actions are unrepresentable as transitions; Decision::Reject is never converted to any tier by any setting. | A (proof) |
| I6 | At most one generation-changing transaction is active (a generation transaction waiting in Built for an activation window, or in AwaitingBoot, counts as active); no transition executes while a transaction is in any mutating or checking state: Activating, Checking, Publishing, Published, Promoted, or Reverting. Unit and device repairs may execute during Built and AwaitingBoot, and during a boot-only Promoted (admitted at boot, awaiting its confirmation; owner decision, 31 Oct): that change is already fully in place. (This list is the one the ledger enforces; v4.5’s omitted Checking.) | A (proof) |
| I7 | Every executed transition is logged with proposal, source, pre- and post-state, derived facts, policy version, probe evidence (per probe: id, result, time), decision, and outcome; an approved transition’s log and journal records name the approver’s principal and the token’s bound_to. | A |
| I8 | A tier-3 transition executes only with an approval token bound to the concrete candidate (§12), re-validated immediately before execution. “Immediately before execution” includes the moment a transaction leaves Built after waiting for an activation window: facts are re-derived, decide re-run and the stale check repeated there too, not only at approval. | A, C (proof) |
| I9 | The model process holds exactly one write capability: submit a proposal. | B (by the sandbox, §20, and a gym case), E (proof) |
| I10 | No change that removes or alters a canonical probe executes below AskFirst, and canonical probes are evaluated from the installed core package regardless of the active generation. | A |
| I11 | A session’s cumulative effect is classified at least as high as any single transition producing it. | A (proof) |
| I12 | Under the progress assumptions of §17a, every transaction in the journal reaches a terminal state (Committed, Reverted, Rejected, Cancelled, or RecoveryFailed); RecoveryFailed always alerts and sets the recovery interlock. Two states wait on events outside the system’s control and are bounded separately (§12f): a transaction in Built waiting for an activation window is Cancelled(Daemon) after the parking limit; one in AwaitingBoot waits for the reboot it was published for and is reported as waiting, never as a recovery failure. | A (proof, conditional) |
| I13 | A unit under an override is never a repair target while the override holds. | A, C (proof) |
| I14 | Policy facts (sensitivity, affected units, manifest-touching, caller identity) are derived by the executor from the evaluated generation and the authenticated peer, never read from a proposal or from any other request field. | A (proof of the decide half; the request half by test) |
| I15 | Exactly one owner (lease) per transaction; a mutating call with a stale epoch is refused; only the lease holder advances the journal. | A (proof) |
| I16 | No automatic revert, rollback, or boot fallback selects a generation below the security floor; going below it requires administrator approval (§12e). The floor is derived from the whole system closure (§12e); a fix outside the inspected set does not move it. | A (proof of the decide and target-selection parts; the bootloader part by test) |
| I17 | The set of principals holding administrator capability is never empty. | A (proof, given root exists) |
| I18 | Every journal record commits to the hash of its predecessor; a broken chain is detected on read and treated as an integrity failure (§12c, §12d). This holds against damage and against anyone who cannot rewrite the whole journal and checkpoint consistently; root can, until the head is anchored off the disk (§12c). | A (proof, trusting SHA-256) |
| I19 | No principal approves its own proposal, and no principal grants itself, or a principal it controls, a capability it does not hold. | A (proof), C |
| I20 | No agent (the on-device model, a remote relay, or any agent started by planarian agent run) starts while planarian-ready.target is not reached, and every running agent is stopped when a failure posture engages, in every posture (§12d). Agents the launcher creates are inside this rule exactly as the units declared in services.planarian.agentUnits (the model service and any relay) are. | A |
| I21 | No transition that cannot be reversed, or that may lose a session, user data or connectivity, executes below AskFirst, unless the loss falls only on the caller’s own session (and, for connectivity, the caller has a seat); a loss reaching another user’s session requires administrator approval (§11 floors). Garbage collection of generations outside the protected set is Runtime by §12b, not irreversible loss in this sense (owner decision, 2026-10-30); gc.retainAllGenerations keeps every generation. | A (proof) |
| I22 | Changing the generation, collecting garbage, binding drivers, resolving the recovery interlock and overriding a Security-sensitive unit are administrator operations; a Security-sensitive unit is changed without approval only to restore its declared state (§11); the unattributed actor holds no authority, and an agent holds exactly its user’s (§11). | A (proof) |
| I23 | A generation transaction commits only after its boot entry is written and promoted; a runtime transaction never publishes, promotes or reverts; the override file is replaced only under the daemon’s exclusive lock on its directory and only when its content hash is the one recorded at Prepared (§9 step 4). A writer that does not take the lock is outside this guarantee (docs/bypasses.md, X-12). | A (proof) |
| I24 | An agent’s authority is bound to the one AgentStarted record for the unit instance it runs in, identified by that instance (not by name); a second start under the same name neither rebinds a running agent nor is journaled before it has succeeded (§11, §16). | A |
| I25 | Post-commit bookkeeping (security-floor derivation, the committed generation’s override content, session accounting) runs on every path that writes Committed for a generation transaction: the daemon’s, the watchdog’s, the boot-only confirmation’s. A commit whose floor record cannot be appended does not commit (§12, §12e). | A |
§8. Schema (crate schema)
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Draft; §18 lists what must be preserved. BTreeMap below is the readable form; the implementation uses vstd-compatible structures.
#![allow(unused)]
fn main() {
pub struct Hash([u8; 32]);
pub struct GenerationId(u64);
pub struct StorePath(String);
pub struct ProbeId(String);
pub struct ChangeId(String);
pub struct IncidentId(u64);
pub struct TxnId(u64);
pub struct PolicyVersion(Hash);
pub struct UnitId { pub kind: UnitKind, pub name: String, pub scope: Scope }
pub enum UnitKind { Service, Device }
pub enum Scope { System, User { uid: u32, session: Option<SessionId> } }
pub struct SessionId(String); // logind session id
pub struct SeatId(String); // logind seat; None for SSH and other remote sessions
// ---------- Desired ----------
pub enum Condition {
Always,
SessionEstablished { uid: Option<u32>, seated: bool },
// logind reports a new session of class `user` (authentication succeeded);
// greeter and background/`manager` sessions are excluded; independent of
// whether the desktop then comes up. `seated: false` matches any such
// session, local or remote (user-scoped units); `seated: true` matches
// only sessions with a seat (the graphical expectation, §9, §10)
DeviceConnected(UnitId),
OnAcPower, OnBattery,
Foreground { session: SessionId }, // quiet mode (§12f; signals in §10): a GameMode client, a gamescope session, a fullscreen
// surface holding a Wayland idle-inhibitor, or the user's Do Not Disturb
All(Vec<Condition>), Any(Vec<Condition>),
}
pub enum DesiredStatus { Running, Stopped, Bound, Absent }
pub struct Desired { pub status: DesiredStatus, pub when: Condition }
/// A deliberate, attributed change to a unit's desired state (§11). Generalizes v4.3's `Suppression`;
/// "suppression" in older prose means an `Override` with `desired: LeaveAlone`. The `StopAndSuppress`
/// transition keeps its v4.3 name: it journals `Override { desired: Stopped, attribution: Attested }`, then stops.
pub struct Override {
pub unit: UnitId,
pub desired: OverrideDesired,
pub until: Until, // time, next boot, or explicit lift
pub reason: String,
pub by: Actor, // derived from the socket peer (§11); never from the proposal;
// `Actor::Unattributed` exactly when `attribution == Inferred`
pub attribution: Attribution,
}
pub enum OverrideDesired {
LeaveAlone, // do not repair; take no action
Stopped, // stop / kill a service
Absent, // disable / mask
Detected, // unmount, eject, or unbind a device
}
pub enum Attribution {
Attested, // the action reached Planarian with the actor's identity before it took effect
Inferred, // the unattributed residue (§11): recorded as an inference, never as fact
}
pub enum Until { At(SystemTime), NextBoot, Explicit }
// ---------- Graph ----------
pub enum EdgeKind {
Ordering, // After=/Before=: never followed in diagnosis
Wants, // Wants=: weak; the wanting unit runs without it; never a root cause for it
Requires, // Requires=/BindsTo=/Requisite= or manifest: functional prerequisite
Diagnostic { confidence: f32 }, // manifest or learned; may form cycles (§13)
}
pub struct Edge { pub from: UnitId, pub to: UnitId, pub kind: EdgeKind, pub origin: EdgeOrigin }
pub enum EdgeOrigin { SystemdRequires, SystemdWants, SystemdAfter, Manifest, Learned }
// ---------- Risk metadata (static, from the evaluated generation) ----------
pub enum Sensitivity { Routine, Security, UserData, External, Core }
pub struct Sensitivities(BTreeSet<Sensitivity>); // composable
pub enum Disruption { None, Brief, SessionLoss, DataInterruption, ConnectivityLoss }
pub struct Disruptions(BTreeSet<Disruption>); // composable: every kind a change carries (owner decision, Nov 2; C10)
pub enum Recovery { Idempotent, Revertible, NonReversible }
pub struct UnitDef {
pub id: UnitId,
pub desired: Desired,
pub edges: Vec<Edge>,
pub sensitivity: Sensitivities,
pub restart_disruption: Disruption,
pub probes: Vec<ProbeDef>,
pub synonyms: Vec<String>,
pub systemd_restart: Option<SystemdRestartPolicy>, // so incident mgmt can defer to it
}
pub type UnitDefs = BTreeMap<UnitId, UnitDef>;
// ---------- Health ----------
pub struct ProbeDef {
pub id: ProbeId,
pub kind: ProbeKind,
pub timeout: Duration,
pub origin: ProbeOrigin, // Canonical | User
pub role: ProbeRole, // Readiness feeds Running/Unknown; Diagnostic never does
pub cadence: Cadence,
pub cost: ProbeCost,
pub side_effects: SideEffects, // None | Audible | Visible | Network | Disk
pub establishes: String, // what a pass actually shows, in plain words
}
pub enum ProbeKind { SystemdActive, Tcp { addr: String }, Http { url: String, status: u16 }, DBus { service: String, path: String, method: String }, FilePresent { path: String },
Exec { argv: Vec<String>, sandbox: ProbeSandbox } } // Exec: administrator-authored manifest entries only;
// flagged in the console and journal (§9)
pub struct ProbeSandbox { pub uid: u32, pub caps: Vec<String>, pub max_output: usize, pub cpu_ms: u32, pub network: bool }
pub enum ProbeRole { Readiness, Diagnostic } // Readiness permits side_effects None or Measurement only; everything else MUST be Diagnostic
pub enum Cadence { EveryTick, Every(Duration), OnEvent, OnDemand }
pub enum ProbeCost { Trivial, Cheap, WakesHardware }
pub enum SideEffects {
None,
Measurement { bytes_max: u32, per: Duration }, // bounded read-only I/O (a TCP connect, a GET to a health endpoint);
// permitted for Readiness probes at a declared rate
Audible, Visible, RemoteStateChange, Disk, // Diagnostic only, OnDemand only
}
pub enum ProbeResult { Pass, Fail(String), Timeout, Error(String) }
pub struct Evidence { pub result: ProbeResult, pub at: SystemTime, pub stale_after: Duration }
// ---------- Observed ----------
pub enum Observed {
Unknown { since: SystemTime, reason: String }, // stale evidence, probe timeout, backend error
Transitioning { from: Box<Observed>, since: SystemTime },
Absent, Detected, Bound, Active, Failed(Reason),
}
pub enum Reason { ExitCode(i32), DriverError(String), Backend(String) }
/// Derived, never stored as truth on its own.
pub enum UnitStatus { Running, Degraded(Vec<ProbeId>), Stopped, Overridden(OverrideDesired), Unknown, Failed(Reason), Absent, Detected, Bound, Transitioning }
pub struct SystemState {
pub generation: GenerationId,
pub known_good: GenerationId, // boot-confirmed
pub live_tested: Option<GenerationId>, // passed probes live, not yet rebooted
pub security_floor: Option<GenerationId>, // §12e; derived, never set by a proposal
pub units: BTreeMap<UnitId, (Observed, Vec<Evidence>)>,
pub overrides: Vec<Override>,
pub conditions: BTreeSet<Condition>, // which conditions currently hold
pub resources: Resources, // excluded from digest
pub core_hash: Hash,
pub observed_at: SystemTime, // excluded from digest
}
impl SystemState { pub fn digest(&self) -> Hash; pub fn status(&self, id: &UnitId, defs: &UnitDefs) -> UnitStatus; }
// ---------- Transitions ----------
pub enum Transition {
RestartUnit(UnitId),
StopUnit(UnitId),
BindDriver { device: UnitId, driver: StorePath },
ApplyChange { change: ChangeId, args: BTreeMap<String, Value> }, // cataloged (§9); executor computes the diff
SwitchGeneration(GenerationId),
Revert { to: GenerationId },
Rollback, // to known_good
LoadModel(ModelId), // B
RestoreSnapshot(SnapshotId), // C
CollectGarbage { emergency: bool },
SetRuntimeOption { path: String, value: Value },
SetOverride(Override), // journals the override; for LeaveAlone, does nothing else
LiftOverride(UnitId),
StopAndSuppress { unit: UnitId, until: Until, reason: String }, // the user's "stop X": Override { desired: Stopped } journaled, then stop
}
/// Per-transition recovery strategy, fixed by the spec, not the proposer (§12).
pub enum RecoveryStrategy {
ReobserveOnly, // runtime unit actions: never a generation operation
RevertGeneration, // generation operations: fixed `revert_to`, full journal path
UnbindDevice, // BindDriver: on failure, unbind and rebind the previously bound driver
RestoreSnapshotThenRevert,
}
/// Every transition has exactly one handler and one recovery strategy, fixed by the spec and
/// independent of its tier (§12). The crash-recovery table branches on `recovery`, never on tier.
pub enum Handler { UnitOp, GenerationOp, DeviceOp, SnapshotOp, GcOp, IntentOp }
pub enum Tier { Observe, Runtime, Tested, AskFirst }
/// Who may approve an AskFirst decision. The class is the maximum over every floor and table row that
/// raised the decision; it is ignored below AskFirst. `Administrator` requires the approver to hold
/// `Capability::Administrator` as well as `Capability::Approve`.
pub enum ApproverClass { AnyApprover, Administrator }
pub enum Decision { Allow { tier: Tier, approver: ApproverClass }, Reject(RejectReason) }
pub enum RejectReason { Forbidden, CoreTarget, ProbeTamper, NotInCatalog, LockedOption, Stale, Unauthorized, Interrupted, NoSpace, Precondition, ActivationUnknown, Other(String) }
// ActivationUnknown: the dry activation failed, timed out or was truncated, so the change's consequences cannot be derived (§9 step 3)
/// Derived by the executor from the evaluated generation — never from the proposal (I14).
pub struct DerivedFacts {
pub affected_units: BTreeSet<UnitId>,
pub sensitivity: Sensitivities,
pub disruption: Disruptions,
pub recovery: Recovery,
pub touches_manifest: bool,
pub touches_canonical_probe: bool,
pub touches_core: bool,
pub below_security_floor: bool, // target generation (revert, rollback, switch) is below `security_floor`
pub other_sessions_affected: bool, // a system-scope effect reaches another uid's online or active session (§11)
pub restarts_failed_unit: bool, // a RestartUnit whose unit has already failed when the facts are derived (§11; owner decision, Nov 2)
pub removes_sensitivity: Sensitivities, // a generation transition: the sensitivity of the units it would remove or stop (§11 Rollback row; owner decision, Nov 2)
pub removes: BTreeSet<UnitId>, // those units, as an approval shows them (§16)
pub restarts_held_unit: bool, // a RestartUnit of a network-security unit (§12d) that has failed, whose traffic is held (§11; owner decision, Nov 2)
pub caller: Actor,
pub config_diff: Option<ConfigDiff>,
}
pub trait TransitionSpec {
fn precondition(&self, s: &SystemState, defs: &UnitDefs) -> bool;
fn predict(&self, s: &SystemState, defs: &UnitDefs) -> Expectation;
fn derive(&self, s: &SystemState, defs: &UnitDefs, gen: &EvaluatedGeneration, caller: Actor) -> DerivedFacts;
}
pub struct Expectation {
pub units: BTreeMap<UnitId, UnitStatus>,
pub generation: Option<GenerationId>,
pub may_change: BTreeSet<UnitId>,
pub others_unchanged: bool,
pub settle: Settle,
}
pub enum Settle { Duration(Duration), Ready { unit: UnitId, timeout: Duration } }
pub struct Proposal {
pub observed: Hash,
pub transition: Transition,
pub expected: Expectation, // MUST refine spec.predict(); checked in §12 step 3
pub reason: String,
pub source: Source,
}
pub enum Source { Diagnoser { incident: IncidentId }, Model(ModelId), User, Intent { phrase: String, confidence: f32 } }
pub enum Actor {
Daemon, Watchdog,
User { uid: u32, session: Option<SessionId>, seat: Option<SeatId>, active: bool }, // filled from SO_PEERCRED → pid → logind
Model(ModelId),
Agent(PrincipalId), // an agent peer the journal has no live `AgentStarted` for (holds nothing), and
// from later phases remote principals via a relay (future-work §3). A *started*
// agent is authorized as `Actor::User { uid: by, session: None, seat: None, .. }`
// (§11); its agent name and instance travel in `LogRecord.agent`, not in `Actor`,
// so the proved `Actor` shape is unchanged
Unattributed, // the actor of an `Inferred` override (§11); never a caller, never authorized
}
// `session` is None only for callers outside any logind session (e.g. a root timer); such callers never
// satisfy an "own session" floor.
// ---------- Principals and authorization (§11) ----------
pub struct PrincipalId(pub u64); // stable; a human principal maps to a uid, an agent to a registered key
pub enum Capability {
OwnScope, // act on units in one's own Scope::User
UserControllable, // act on units in services.planarian.userControllable
Administrator,
Approve, // approve others' AskFirst proposals within one's other capabilities (I19)
Catalog, ModelCatalog, // propose catalog / model-catalog entries (the only non-human grants in B)
Manifest, Merge, // human-only through B; Merge needs two distinct approvers
}
pub struct Grant { pub to: PrincipalId, pub capability: Capability, pub approved_by: PrincipalId, pub txn: TxnId }
/// Computed by the daemon for each call from SO_PEERCRED, logind, group membership, and journaled grants.
/// Never read from a proposal (I14).
pub struct Authorization { pub principal: PrincipalId, pub actor: Actor, pub capabilities: BTreeSet<Capability> }
/// Journaled by `planarian agent run` (§11, §16) once the agent's unit has started. `instance` is the
/// unit's InvocationID (or an equivalent per-start identity the backend can read back from the peer's
/// cgroup); a request from an agent peer is authorized as `by` only if the peer's instance equals this
/// record's and no later record names the same instance (I24). The name alone never identifies an agent.
pub struct AgentStarted { pub name: String, pub instance: InstanceId, pub by: u32 /* uid */, pub at: SystemTime }
pub struct InstanceId([u8; 16]);
// ---------- Failure posture (§12d) ----------
pub enum Posture { Relaxed, Standard, Guarded, Sealed }
pub enum FailureClass { Absent, Crashed, Integrity }
// ---------- Incidents ----------
pub enum IncidentState { Open, Cooldown { until: SystemTime }, Escalated(EscalationReason), Closed, Regressed { count: u8 } }
pub enum EscalationReason { Regressed, BudgetExhausted, DiagnoserExhausted, RevertBlockedByFloor, RecoveryFailed }
pub struct Incident {
pub id: IncidentId,
pub target: UnitId,
pub divergence: (DesiredStatus, UnitStatus),
pub opened: SystemTime,
pub attempts: Vec<(TxnId, Outcome)>,
pub budget_left: u8, // persisted; survives daemon restart
pub state: IncidentState,
pub backoff: Duration,
}
// ---------- Transactions ----------
pub enum TxnState {
Prepared, Staged, Built, Activating, Checking,
Publishing, // boot-only path (§12 step 7): journaled before the first boot mutation
// (`publish_boot_once`); recovery from here reads the op's OpBegin/OpEnd
Published, // boot entry written
AwaitingBoot, // boot-only candidate published as a one-shot next-boot entry (§12f); active for I6
Promoted, // live path: override source promoted; boot-only path: candidate admitted as the
// boot default at the boot it was published for, confirmation pending (§10)
Committed, Reverting, Reverted, RecoveryFailed,
Rejected(RejectReason), // terminal: refused after Prepared (precondition, expired/invalidated approval, policy)
Cancelled(Actor), // terminal: withdrawn before any mutation
}
impl TxnState {
/// The one list I6 and §12 "Concurrency" both mean: no transition executes while any transaction is in one of these.
pub fn exclusive(&self) -> bool { matches!(self, Activating | Checking | Publishing | Published | Promoted | Reverting) }
}
/// Exclusive ownership of a transaction (§12). Stored in the journal; every
/// mutating backend call carries the epoch and is refused if it is not current.
pub struct Lease { pub owner: OwnerId, pub epoch: u64, pub worker_pid: Option<u32>, pub renewed: SystemTime, pub jobs: Vec<JobId> } // jobs: supervisor jobs this owner submitted
pub enum OwnerId { Daemon { pid: u32, boot_id: Hash }, Watchdog { pid: u32, boot_id: Hash } }
pub struct Transaction {
pub id: TxnId,
pub state: TxnState,
pub lease: Lease,
pub base_source: Option<Hash>, // NAR hash of the owner's Nix-visible source with the BASE override, at Prepared (§9)
pub staged_source: Option<Hash>, // NAR hash of the candidate tree actually evaluated and built (§9)
pub base_override: Option<Hash>, // content hash of the override file as it was at Prepared (§9)
pub candidate_override: Option<Hash>, // content hash of the staged candidate override
pub gc_root: Option<PathBuf>, // /nix/var/nix/gcroots/planarian/<txn>, created at Built (I3)
pub recovery: RecoveryStrategy,
pub transition: Transition,
pub facts: DerivedFacts,
pub base: GenerationId,
pub candidate: Option<(GenerationId, StorePath)>,
pub policy: PolicyVersion,
pub approval: Option<ApprovalToken>,
pub snapshot: Option<SnapshotId>,
pub expected: Expectation,
pub started: SystemTime,
pub deadline: SystemTime, // watchdog acts after this
pub revert_to: GenerationId, // fixed at Prepared; authorized with the txn
}
pub enum Outcome { Succeeded, Failed(String), Partial(String), Unknown(String) }
pub struct ApprovalToken {
pub bound_to: Hash, // hash(transition, staged_source, candidate store path, base generation, policy version,
// cumulative-diff hash, derived facts, expected, revert_to, recovery strategy)
pub approver: PrincipalId, // must differ from the proposer and hold the decision's ApproverClass (I19)
pub class: ApproverClass,
pub issued: SystemTime, pub expires: SystemTime, pub nonce: [u8; 16], pub used: bool,
}
pub struct Session { pub applied: Vec<TxnId>, pub cumulative: ConfigDiff, pub policy: PolicyVersion }
// ---------- Backends (generic, one impl each in A) ----------
pub trait UnitBackend {
fn observe(&self, id: &UnitId) -> Result<Observed, Outcome>;
fn restart(&self, id: &UnitId) -> Outcome;
fn stop(&self, id: &UnitId) -> Outcome;
fn list(&self) -> Result<Vec<UnitId>, Outcome>;
}
pub trait GenerationBackend {
// read-only (allowed before Prepared)
fn current(&self) -> Result<GenerationId, Outcome>;
fn boot_entries(&self) -> Result<Vec<GenerationId>, Outcome>;
fn last_booted(&self) -> Result<GenerationId, Outcome>;
fn stage(&self, owner_cfg: &PinnedSource, overrides: &OverrideSet) -> Result<StagedSource, Outcome>; // immutable copy; returns its NAR hash
fn evaluate(&self, src: &StagedSource) -> Result<EvaluatedGeneration, Outcome>; // pure-eval; option set, UnitDefs, activation plan
fn build(&self, eval: &EvaluatedGeneration) -> Result<(GenerationId, StorePath, PathBuf /* build root */), Outcome>; // store-only; roots its output under gcroots/planarian/builds/
// mutating (require durable Prepared and a current lease epoch)
fn activate_live(&self, g: GenerationId, lease: &Lease) -> Outcome; // switch-to-configuration test
fn publish_boot(&self, g: GenerationId, lease: &Lease) -> Outcome; // boot entry only; no re-activation
fn protect(&self, g: GenerationId, lease: &Lease) -> Outcome;
fn pending_jobs(&self) -> Result<Vec<JobId>, Outcome>; // supervisor jobs still running
fn jobs_tagged(&self, op: &OpId) -> Result<Option<Vec<JobId>>, Outcome>; // discover jobs by durable operation id; None = backend cannot say
fn cancel(&self, job: &JobId, lease: &Lease) -> Outcome;
fn verify_candidate(&self, path: &StorePath, expected: &Hash) -> Result<bool, Outcome>;
}
pub trait SnapshotBackend { fn supported(&self) -> bool; fn snapshot(&self, label: &str) -> Result<SnapshotId, Outcome>; fn restore(&self, id: &SnapshotId) -> Outcome; fn usage_bytes(&self) -> u64; }
pub trait InventoryBackend {
fn devices(&self) -> Result<Vec<DeviceInfo>, Outcome>;
fn resources(&self) -> Result<Resources, Outcome>;
fn bound_driver(&self, device: &UnitId) -> Result<Option<StorePath>, Outcome>;
fn bind(&self, device: &UnitId, driver: &StorePath, lease: &Lease) -> Outcome; // mutating; sysfs driver bind
fn unbind(&self, device: &UnitId, lease: &Lease) -> Outcome; // mutating
}
}
Status derivation (SystemState::status), in order: Overridden if an override holds (reported with its OverrideDesired); else if the desired condition does not hold, not expected (observed status reported, no incident); else Failed if Observed::Failed (a definite supervisor failure is never masked by stale probes); else Unknown if any Readiness probe’s evidence is stale or timed out; else Running if observed Active and all Readiness probes passed fresh; Degraded if Active with a failed Readiness probe; else from Observed. Diagnostic probes never affect status; their evidence is shown in the console and used by the diagnoser only.
systemd mapping: active → Active; failed → Failed(ExitCode); inactive/dead → Bound (Stopped once desired is considered); activating/deactivating/reloading → Transitioning; not found → Absent; D-Bus error → Unknown. Devices via udev: present without driver → Detected; bound → Bound/Active.
§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.
§9a. PlanarianOS (Phase C)
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
PlanarianOS is a NixOS distribution in which Planarian governs the machine from first boot. It exists for the guarantees a module installed on an existing machine cannot make: no older, unmonitored generations in the boot menu; a boot chain locked from install (Secure Boot with a signed unified kernel image via Lanzaboote, bootloader editing disabled, TPM measured boot where available); failure posture and the first administrator set before anyone logs in; a known starting configuration against which catalog coverage is measured; and Planarian-native defaults (hash-chained journal from the first boot, sandbox tier where KVM exists, no out-of-band rebuild path in the default configuration). Planarian also remains installable as a module on any NixOS.
| Profile | For | Default posture | Includes | Leaves out by default |
|---|---|---|---|---|
| Agent host | Machines that agents operate | Guarded | Sandbox tier, journal export, capability-scoped agent principals | Desktop, local model |
| Server / fleet node | Unattended servers and edge devices | Sealed | Signed external desired state, signed health reports | Desktop, local model |
| Personal | Desktops and laptops, including remotely administered ones | Standard | COSMIC reference desktop, optional console, quiet mode, model residency, Flatpak, remote administration | — |
Gates (GC), per profile. Agent host: an agent holding a user account completes a scripted set of real administration tasks; zero unsafe changes reach the real machine; every change attributed; every Tested change carries a passing sandbox run. Server: unattended under the full fault catalog for a set period; every fault repaired or escalated; every posture response verified by injection. Personal: a person new to Linux installs it and completes common tasks (connect to Wi-Fi, change display scaling, install an app) through whichever frontend the profile ships; catalog coverage above the owner-set threshold (set after the first catalog survey); invisibility budget met.
Accounts. The everyday login is a user account with no administrator authority, never shown an AskFirst prompt it cannot answer. Administrator authority is a capability, grantable to a local account, to a remote principal, or both (I17: the set is never empty). The installer creates the first administrator, then an everyday account, and says plainly that the everyday account is the one to use daily. An administrator may drop the capability only while another remains. Lost-administrator recovery requires physical presence: a local session (in E, the secure attention key).
Live mode. A live image has no generations: planariad runs with max_tier = Runtime and reports “no baseline,” and every frontend says so. The live image is also the gym’s VM image.
§10. State (crate state)
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
- Tick: 5 s and on systemd/udev/login events. Warn if a tick exceeds 500 ms.
- Conditions evaluated each tick from logind (sessions), udev (devices), upower (power), and the foreground signals (a registered Feral GameMode client, an active gamescope session, a fullscreen surface holding a Wayland idle-inhibitor, the user’s Do Not Disturb) for
Foreground { session }. Two session conditions are distinguished (owner decision, 30 Oct):SessionEstablishedcounts every session of classuser, seated or remote (greeter,manager, and background sessions are ignored), and is what user-scoped units are desired under; the graphical expectation — thegraphical-sessionunit’s desired condition — holds only for a seatedusersession (seatset), so an SSH-only login never expects a compositor and never opens a desktop incident. The readiness deadline is bound to that specific session id, so a second user logging in does not reset or satisfy the first session’s deadline. Gym cases: SSH-only session (no graphical incident); two graphical sessions for one uid; greeter only; a locked session (no divergence). - Probe contract. Every probe runs in a sandbox (
ProbeSandbox: dedicated uid, dropped capabilities, bounded CPU and output, network only if declared), is killed at timeout with its process group, and its output is logged as data. A probe’stimeoutis a deadline for the whole probe: name resolution, connection, writing, and every read count against the one budget, and a probe past its deadline yieldsTimeoutwhatever it was doing. The probe runner bounds concurrency and never waits past the batch deadline for a straggler: a probe still running when the tick’s deadline passes is recordedUnknownand abandoned, so one wedged service cannot stall observation, repair, approvals or the socket. Typed probes (Tcp,Http,DBus,FilePresent) run in the daemon’s process in A, withbytes_maxenforced for every kind that reads andperenforced by the cadence scheduler; running them in the probe sandbox is C. Probes withside_effectsofAudible,Visible,RemoteStateChange, orDiskareDiagnostic, runOnDemandonly, andAudible/Visiblerequire a user-initiated diagnostic.Measurementside effects (a bounded, read-only network or local I/O such as a TCP connect or an HTTP GET to a declared health endpoint) are permitted for Readiness probes at the declared rate. A Readiness probe whoseestablishesnames an external dependency (an upstream endpoint, a gateway) MUST declare a traversable edge to a unit representing that dependency (service:external:<name>):Requires, orDiagnosticwith confidence ≥ 0.7, the diagnoser’s threshold (§13). The module rejects a manifest that declares a weaker edge for an external dependency, so an unavailable dependency is diagnosed as such and never produces a local restart (owner decision, 30 Oct). Gym cases: a process stays active while its HTTP health endpoint returns 503 (Degraded, incident opens); the external dependency is down (incident targets the external unit; no local restart); a manifest declares a 0.5-confidence edge to an external dependency (rejected at evaluation); a stalled resolver and a server that sends one byte just before each read would time out (the batch returns within the deadline plus a small allowance and the repair loop ticks meanwhile).WakesHardwareprobes defaultEvery(60 s),Every(300 s)on battery. ATimeoutorErroryieldsUnknown, neverFailed. A tick that exceeds 500 ms is logged as a warning with the probes that ran in it. - Evidence carries
stale_after; the status derivation treats stale Readiness evidence as Unknown; stale Diagnostic evidence is displayed, never counted. An incident is not opened on Unknown; the probe is re-run with backoff and, after three consecutive Unknowns, a diagnostic incident is opened whose first candidate is “re-observe via a different Readiness probe,” not a restart. Gym cases: Diagnostic evidence expires on a healthy machine (status stays Running); a definiteObserved::Failedwhile another probe is stale (status is Failed, not Unknown). - Storage is modeled per filesystem:
device:fs-root,device:fs-nix(if separate),device:fs-boot,device:fs-journal(the filesystem holding the journal), each with a canonical Readiness probe (free ≥ max(2 GB, 5%) for root and nix; ≥ 64 MB for boot; journal headroom per §12c). Divergence on root/nix →CollectGarbagefirst. - User-scoped units are keyed by uid and session; a user service is desired while any session of that uid exists, so logging out of one of two sessions does not stop the user’s services.
- Actors and sessions. For every request on the daemon socket, the daemon obtains a pidfd for the peer (
SO_PEERPIDFD, Linux 6.5+), resolves the logind session from that pidfd, never from a bare pid, and fillsActor::User { uid, session, seat, active }. A peer whose pidfd cannot be obtained, or whose process has exited, is treated as having no session. A request with no seat (SSH, other remote logins) is never treated as local. Greeter and lock-screen sessions remain excluded fromSessionEstablished; locking a session is not a divergence. - Boot report. On its first tick after boot,
planariadwrites a boot report: every unit that started beforeplanarian-ready.targetand is outside the generation’s declared closure (from C also every such process whose executable is outside the closure, including udevRUN+=helpers and generators’ children; user binaries started later are never early-boot findings); whether the installed Planarian package is a test build (§15); core hash against its signature (I2); journal hash-chain integrity (I18). Any finding is anIntegrityfailure (§12d) and opens an incident per undeclared item. - Signed health report (hook). The core emits a signed outbound health report (status per unit, incident summary, generation identities) on a declared cadence, with contents selected by a disclosure profile that the core computes, never the requester. Phase A emits it locally only; the default profile is minimal (unit statuses and incident kinds; no versions, paths, or log content). Expanding the profile is Security-sensitive.
- Boot confirmation has two milestones (owner decision, 30 Oct; v4.5 conflated them). Admission, before
planarian-ready.target, within the boot-ready timeout (§12d): the daemon reconciles the journal (§12: activation recoveries run just after ready) and, for a boot-only candidate inAwaitingBoot, runs the candidate’s applicable Readiness probes once; pass → the candidate is made the default and the transaction advances toPromoted(the boot proceeds; principals are admitted); fail, or the target not reached → the next boot falls back (§12 step 7) and the transaction isReverted. Confirmation, at the 10-minute checkpoint on the same boot:GenerationBackend::last_booted()is compared to the generation that waslive_testedor admitted; if they match, the canonical probes pass, and, for an admitted boot-only transaction, no canonical unit needed a repair since admission (repairs run in that window, I6; a repair is evidence against the candidate even when it worked), that generation becomesknown_goodand the admitted transaction isCommitted. A generation whose diff touches kernel, initrd, bootloader, or filesystem options can becomelive_testedbut notknown_gooduntil confirmed by a reboot; the console says so. A boot-only candidate (§12f) is neverlive_tested. Between admission and confirmation the candidate is the default and the booted generation; a crash of the daemon in that window leavesPromoted, which the crash table completes.
§11. Policy (crate policy)
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
decide(transition, facts: &DerivedFacts, state, defs, session, authz: &Authorization) -> Decision. Pure. Reads nothing fromProposal.- Principals. Every caller is a principal: an authenticated identity, a capability set, and a reputation (how much its claims about the world are credited). Defaults: a user has wide capabilities and high reputation; the diagnoser moderate and high; the model narrow and zero (I14); an app tiny and zero. These are defaults, not ceilings. Capabilities come from grants, never from what kind of thing the principal is: from B, an agent granted a capability holds it exactly as a human would, within the same floors. In A an agent acts with its user’s permissions, no more and no fewer (owner decision, 2026-10-30): there are no grants to agents in A, so an agent’s capability set is its user’s. An agent (on-device model, coding agent, or any automated actor on the user’s behalf) is started through
planarian agent run(§16), which journals who started it and creates a transient unit withDynamicUser=for it. Its requests are authorized exactly as that user’s would be, as the user with no login session, no seat and no terminal — so the own-session and seat exemptions never clear for it and it cannot approve, which is also how that user is treated over SSH — and the executor log attributes each one to the agent (LogRecord.agent), acting for the user. An agent is not treated differently from its user in what it may do; it is told apart so that what it did can be seen. An agent the journal has no start for holds nothing. Identity is per instance, not per name (I24; B-20):agent runrefuses a name whose unit is loaded or whose lastAgentStartedrecord names a live instance started by a different uid; the record is journaled only after the unit has started, carrying the instance identity the daemon reads back from the peer’s cgroup, and authorization matches the peer’s instance to that record. Re-runningagent run <name>can therefore never change what an already-running agent may do. An agent run by hand under a human’s uid, outsideagent run, is that human, unattributed (docs/bypasses.md, X-10). The model service (B) is an agent in this sense too:Actor::Modelholds nothing until B defines its grants, andauthorized_spectreats it as it treatsActor::Agent, never as the daemon. In Phase B, non-human principals may hold catalog and model-catalog authority only; manifest and merge authority stay with humans. Capability grants are AskFirst changes approved by a different principal with administrator authority; the root of every grant chain is a local human session (I19). - Caller identity and authorization are established by the daemon, not the proposal: the Unix socket peer is authenticated with
SO_PEERCREDand a pidfd (§10);Actor::User { uid, session, seat, active }is set from it (§10), and any actor field inside a proposal (e.g.Override.by) is overwritten. Authorization is a separate rule evaluated before risk: a user may act on units in their ownScope::Userand on system units listed inservices.planarian.userControllable(default: audio, display scaling, their own Wi-Fi selection); every other system action requires administrator authority (in A, root or membership in theplanarian-admingroup; I17 holds over that group plus root). Authorized to start a unit means authorized to override it, with one exception: an override on a unit whose derived sensitivity includesSecurity(firewall, audit, sshd hardening, Planarian’s own units, the sandbox) needs administrator authority. A non-administrator’s override on a Security-sensitive unit is not rejected: it is held as AskFirst withApproverClass::Administrator, the unit stays up meanwhile, and an administrator may approve it. Any other action outside the caller’s authority →Reject(Unauthorized), regardless of tier. The model and the daemon are never “authorized users”; their proposals follow the daemon rules. No request field decides authority: a property of the caller that matters (a controlling terminal, a session, a seat) is established by the daemon from the peer’s pidfd, never from the request. - Equivalent effects, equivalent facts. Derived facts describe what a transition does, not how it is spelled.
SetOverride { desired: Stopped }stops the unit and carries the unit’srestart_disruption, its dependents andother_sessions_affectedexactly asStopAndSuppresson the same unit does;desired: AbsentandDetectedlikewise carry the effects of the disable or unbind they cause. OnlyLeaveAlonehas no effect and no disruption.SwitchGeneration,RevertandRollbackderive their facts from a dry activation of the target generation and the override content recorded for it, likeApplyChangedoes from its build: affected units, disruption, the option diff (for the Rollback row and I11) andbelow_security_floorall come from the target, never from the assumption that a vetted generation is harmless. A unit that has already failed (observedFailed, or not running while it is desired running, when the facts are derived) contributes no disruption of its own: what it provides is already lost, and restarting it restores rather than costs (owner decision, Nov 2). Every other unit the transition touches still contributes its own. The same holds for a runtime option whose effect is a unit’s: Wi-Fi selection’sConnectivityLossis NetworkManager’s, and it is none while NetworkManager has already failed. Restarting an already-failed unit is not escalated forExternalsensitivity either, since what that escalation protects is already lost;Securitysensitivity still escalates, failed or not, and stopping is unchanged (owner decision, Nov 2). “Already failed” is read from the observation the facts are derived against, which is current at approval and again beforeActivating(§12 step 6). A unit whose status isUnknownis not failed (§10), so a probe that cannot answer never waives a floor. A network-security unit (§12d) that Planarian manages and that has failed, or been stopped outside Planarian and not attributed, is restarted at once, without asking, whatever its sensitivity, and alarmed (restarts_held_unit); while it is down its traffic is held (§12d Network security fails closed), so the restart ends an outage and exposes nothing (owner decision, Nov 2). One it does not manage is held and alarmed, not restarted (§12d A network tool Planarian does not manage). Gym cases:rollback-repair-foreground-dependency; both stop spellings ondisplay-managerfrom a remote administrator session get SessionLoss and ask; a rollback between two vetted generations whose manifests agree but whoseNetworkManagerunit differs is scheduled as ConnectivityLoss. - Preconditions are the executor’s.
TransitionSpec::preconditionis checked by the executor at §12 step 5 for every proposal, whatever its source; a failed precondition isRejected(Precondition). The diagnoser’s use of it to filter candidates is in addition, not instead. The preconditions the spec fixes:SetOverrideon a unit that already holds an Attested override by another principal fails (only an Inferred override may be replaced, and only by an authorized principal; the holder lifts their own);LiftOverrideon a unit with no override fails;Revert { to }withtothe current generation andRollbackwithknown_goodthe current generation fail;RestartUnit/StopUniton anexternal:unit or on a unit under an override fail (I13). - Global floors, applied before the per-transition table, to every transition kind:
recovery == NonReversible→ at least AskFirst;disruption ∋ SessionLossorDataInterruption→ at least AskFirst unless the caller is an authorized user whose own session it is;disruption ∋ ConnectivityLoss→ at least AskFirst unless the caller is a user with a local (non-remote) session, i.e.seatis set. “Own session” is per session, not per uid: a user dropping another of their own sessions gets the floor.other_sessions_affectedwith disruption ∋SessionLossorConnectivityLoss, on a system-scope transition → AskFirst unless the caller holds administrator authority, and the affected sessions are notified either way.below_security_floor→ AskFirst withApproverClass::Administrator, for every transition kind. The disruption is a set and each floor applies when any member triggers it, so a change carrying several kinds meets every floor they trigger (owner decision, Nov 2; C10). So a daemon-proposedBindDriverthat would drop a session, aSetRuntimeOptionWi-Fi change proposed by the diagnoser, and a non-administrator stopping the network while another user is logged in all ask. - Reject when:
touches_core, forbidden transition,touches_canonical_probe(definition) outside AskFirst, change not in catalog, locked option, unauthorized caller. - Tier otherwise, by table; escalations compose with the floors:
| Transition | Base | → AskFirst when |
|---|---|---|
| RestartUnit | Runtime | sensitivity ∋ Security, unless it restores an unattributed stop (residue) or restarts a failed network-security unit whose traffic is held (§12d); sensitivity ∋ External, unless the unit has already failed (above) (floors cover disruption) |
| StopUnit | Runtime | sensitivity ∋ Security or External (floors cover disruption) |
| SetOverride / LiftOverride / StopAndSuppress | Runtime | caller is not an authorized user; or the unit’s sensitivity ∋ External; sensitivity ∋ Security and the caller is not an administrator (approver: Administrator) |
| BindDriver | Tested | sensitivity ∋ Security |
| ApplyChange | Tested | sensitivity ∌ only Routine; touches_manifest; touches_canonical_probe; cumulative diff non-Routine (I11) |
| SwitchGeneration / Revert | Tested | target not live-tested or known-good; revert target differs from the transaction’s recorded revert_to; target below the security floor (administrator) |
| Rollback | Runtime | known_good would restore Security- or External-sensitive options that changed since (policy computes the diff), or would remove or stop a Security- or External-sensitive unit (removes_sensitivity); known_good below the security floor (administrator) |
| LoadModel | Tested | not in hash-pinned catalog → Reject |
| RestoreSnapshot | AskFirst | always |
| CollectGarbage | Runtime | never (I3) |
| SetRuntimeOption | Runtime | not in the core allowlist → Reject |
- A Rollback asks for what it would take away, not for what it restarts. Restarting a
Security- orExternal-sensitive unit whose options are unchanged does not make it ask; restoring such an option (I11 and the row) or removing or stopping such a unit (a VPN the target lacks) does. The global floors still cover disruption (owner decision, Nov 2). - Autonomy setting:
max_tier ∈ {Observe, Runtime, Tested}. AnyAllow { tier: t, .. }witht > max_tierbecomes AskFirst withApproverClass::AnyApprover(or the class already set, if higher).Rejectis never affected (I5). - Approval. Approval is a capability held by principals, granted only from a local human session. Whoever holds it, human or agent, may approve within its scope, but never a proposal it made itself (I19). Remote principals hold no approval capability by default. In Phase A approval requires root at an interactive terminal, and the daemon establishes the terminal itself (the controlling terminal of the peer’s process, from its pidfd). A change to merge authority, and any proposal to grant
Merge, requires two distinct approvers, neither of them the proposer. - Deliberate actions carry identity. Every user-facing path by which an authorized principal can put a unit into a state that diverges from desired state (stop, disable, unmount, disconnect, unbind) is routed so the action is journaled as an attested
Overridebefore it takes effect: the CLI and console in A; in C, PlanarianOS also wraps the commands people type (systemctl,nmcli,udisksctl) and routes the desktop’s own switches through the same path. Logging out and suspending are not divergences (conditions stop holding) and need no override. A rootnixos-rebuildoutside Planarian is observed as a new, unconfirmed base: not fought, not trusted. - The unattributed residue. A raw signal or direct sysfs write has no principal. OOM kills (
Result=oom-kill) are always faults. A stop of a Security-sensitive unit is always a fault: it is restarted and alarmed, never inferred as intended. So is a stop of a unit whose manifest setsinferStops = false(default true; the reference configuration sets it false for backup, sync, and the desktop’s own units).inferStopsis provisional (§19): if unit importance comes to matter beyond residue inference, replace it with a declaredimportanceproperty separate from sensitivity. Otherwise, by posture: Relaxed and Standard accept the stop until next boot as anOverride { by: Actor::Unattributed, attribution: Inferred, until: NextBoot }, with a notice and an undo; Guarded and Sealed open the 10 s ask window and restart if nobody answers. An inferred override is an observation the daemon records, not a proposal: it does not pass throughdecide, holds no authority, and cannot be extended or converted toAttestedexcept by an authorized principal setting a new override. The journal records the inference as an inference, and the console lists inferred overrides until a user acknowledges or undoes each one. - Sensitivity completeness. Overrides, the residue rule, and I22 key on
Security, so an untagged security unit is unprotected. Units declared through thesecurity.*,networking.firewall.*,services.openssh.*,services.fail2ban.*,services.usbguard.*, and VPN option trees areSecurityby default; a unit with no sensitivity entry is treated as non-Routine (fail closed). A gym entry checks a named list of security units against the shipped tables. - Disruption surfacing: any Allow with disruption ≥ SessionLoss is logged and, if a console is attached, shown before execution with a 10 s cancel window for daemon-initiated actions.
PolicyVersionis the hash of the policy crate build plus the shipped sensitivity tables; it is recorded in every transaction and token.
§12. Executor and transaction kernel (crates exec, txn, watchdog)
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Journal: append-only, write-ahead, fsync’d, in a preallocated file at /var/lib/planarian/journal/ (§12c). Read-only backend calls (current, boot_entries, last_booted, observe, stage, evaluate, build — build writes only to the Nix store and activates nothing, so it is treated as read-only with respect to system state, but its output is recorded) may precede Prepared. Every mutating call (activate_live, publish_boot, protect, restart, stop, snapshot, restore, file promotion) requires a durably written Prepared record and a current lease epoch. Prepared is written on the Allow path as well as the AskFirst path.
Ownership and fencing. Each transaction has exactly one owner at a time (Lease). Mutating backend calls carry the lease epoch; the backend layer reads the journal’s current epoch for that transaction and refuses the call if it differs (Outcome::Failed("fenced")). Mutations that take time (activation, revert) run in a worker (a thread in A, a subprocess recorded in the lease from D; see “Worker processes” below) that inherits the epoch. Taking over a transaction means: write a new lease with epoch + 1 (durably), stop the previous worker where one can be addressed (SIGTERM then SIGKILL to its process group via pidfd, from D), then establish confirmed quiescence. Every mutation attempt first journals a durable operation id (OpId, an OpBegin { txn, epoch, seq } record written and fsynced before the backend call, matched by an OpEnd with the outcome after it; “acknowledged” below means the OpEnd exists), and the backend call is tagged with it (for systemd: the job’s unit is started with a transient property X-Planarian-Op=<OpId> via SetUnitProperties in the same D-Bus transaction, or, where the call cannot carry a tag, the daemon records the pre-call snapshot of pending_jobs() as the op’s baseline). Quiescence then covers both the jobs in the old lease’s jobs list and every job discovered by jobs_tagged(op) for each journaled OpId whose acknowledgment was never recorded. Every such job must be cancelled (systemctl cancel <job> where the supervisor supports it) and confirmed gone from pending_jobs(), and any activation worker’s own child processes reaped; the new owner waits up to the job-drain limit (60 s) for that confirmation, and if quiescence cannot be confirmed, or jobs_tagged returns None for an unacknowledged OpId (the backend cannot say whether undiscovered work remains), it writes RecoveryFailed and blocks all further mutation rather than proceeding. A drain timeout is never permission to proceed, and neither is an empty list of known jobs. Gym case: accept an activation job, withhold its reply, kill the owner before the job id is journaled, start recovery; recovery must discover or fence the accepted work before any conflicting mutation. Only after confirmed quiescence may the new owner advance the journal. Lease transfer is atomic: the lease is a file /var/lib/planarian/txn/<txn>/lease replaced by atomic rename of a temp file whose name embeds the candidate epoch, after flock on the transaction directory; two processes attempting takeover serialize on the lock and the second sees the first’s epoch and backs off. An old owner that wakes up finds its epoch stale on its next call and exits. Only the current lease holder may advance the journal or commit. Gym case: kill and immediately restart the daemon while a deliberately slow activation worker survives; assert activation and revert never overlap and the old owner cannot commit afterward.
Protocol for a Proposal:
-
Observe S0. If
proposal.observed != S0.digest()→ RejectStale, return the current digest. -
Admission before work. Before any staging, evaluation or build, check what can be checked cheaply: the caller’s identity and capabilities against the transition’s authorization rule (§11), the catalog membership of an
ApplyChange, and the recovery interlock (§12c). A caller who cannot be authorized for the transition causes zerostage/evaluate/buildcalls (Reject(Unauthorized)here). Then, forApplyChange: run the handler,stage,evaluate, andbuild(read-only path); forSwitchGeneration/Revert/Rollback: dry-activate the target (§11). Derive facts from the evaluated candidate. Work in this step is charged to the caller and bounded (one build slot per caller class; §16a). -
Refinement check:
proposal.expectedMUST agree withspec.predict()on every unit the spec predicts, MUST NOT broadenmay_change, MUST NOT setothers_unchanged = falsewhere the spec sets true. Violation → Reject. Executor-owned requirements (§9) are appended. The transition’sRecoveryStrategyandrevert_toare fixed here by the spec, not the proposer. -
policy.decide(with authorization).Reject→ log, alert if the source was the model. Otherwise create the transaction atomically:mkdir /var/lib/planarian/txn/<txn>/(fails if it exists), writebase.nixand the initial lease (epoch 1, this daemon) into it, then journalPrepared(durable) with facts, expected, revert_to, recovery strategy, handler, staged_source, base/candidate override hashes, gc_root, candidate path, policy version, and the lease. The transaction does not exist until thePreparedrecord is durable; the directory alone is garbage the next startup removes.AskFirst→ request a token bound as in §8, park. On token: re-derive facts, re-rundecide, recompute cumulative hash and candidate verification; any difference inbound_toinvalidates the token and re-requests. Tokens expire in 24 h (CLOCK_BOOTTIME, §12h), are single-use, and do not survive a daemon restart (owner decision, 30 Oct): a restart invalidates every unused token, the parked change re-requests approval, and the checkpoint carries consumed nonces only, for replay protection. The approval is recorded: the token-use journal record and the transaction carry the approver’sPrincipalId,bound_toand the transaction id, and the approved execution writes its own log record (I7). -
Check precondition against the current state (
TransitionSpec::precondition; §11). A failed precondition writesRejected(Precondition); an expired or invalidated approval that is not re-granted within its window, or a policy decision that changes toRejecton re-evaluation, writes the terminalRejected(reason); all release the GC root. A user withdrawing a parked proposal writesCancelled. All are terminal and reopen the incident if one was waiting. -
(The lease was created with the transaction in step 4.) Snapshot if facts.sensitivity ∋ UserData and supported; record. If unsupported in Phase A, log
NoSnapshotand continue; from C, AskFirst. -
Execute, by recovery strategy, which is fixed per transition and independent of tier:
Transition Handler Recovery Notes RestartUnit, StopUnit, StopAndSuppress, SetOverride, LiftOverride UnitOp / IntentOp ReobserveOnly candidate = None; never a generation operationSetRuntimeOption, CollectGarbage UnitOp / GcOp ReobserveOnly same ApplyChange, SwitchGeneration, Revert, LoadModel GenerationOp RevertGeneration full journal path below Rollback GenerationOp RevertGeneration tier may be Runtime, but it is a generation operation: serialized (I6), owned, with candidate = known_good(oraccepted_baseline),publish_boot, and override restoration to the override content recorded for that generation; its ownrevert_tois the generation it leftBindDriver DeviceOp UnbindDevice a device operation via the inventory backend ( bind/unbindon the sysfs driver interface); no generation is built or activated; on failure or mismatch, unbind and rebind the previously bound driver (recorded at Prepared)RestoreSnapshot SnapshotOp RestoreSnapshotThenRevert C - UnitOp / IntentOp / GcOp (
ReobserveOnly): journalActivating→ backend call in-process →Checking→ settle → observe → compare → journalCommittedwith the outcome recorded, whether or not the expectation held; a mismatch informs the incident and never triggers generation reversion. APartial/Unknownoutcome means re-observe and record, nothing more.StopAndSuppressjournals the suppression before callingstop. - GenerationOp (
RevertGeneration), boot-only candidates (§12f: the change takes effect only at boot):verify_candidate→ journalPublishing(durable, before any boot mutation) →publish_boot_once(candidate)as a one-shot next-boot entry, with the running generation (the one publishing the candidate; always at or above the security floor) kept as the default and fallback (owner decision, 30 Oct;known_goodis a manual fallback per I1, never the automatic one) → journalAwaitingBoot. At the next boot, the candidate boots once; the admission check and the confirmation checkpoint of §10 apply: admitted → the candidate is made the default and the override promoted (§9 step 4) →Promoted; confirmed at 10 min → post-commit bookkeeping →Committed(boot-confirmed,known_good); not admitted, or the boot does not reach ready: the following boot falls back to the publishing generation →Reverted; admitted but not confirmed (canonical probes fail at the checkpoint) →Reverting:publish_boot(publishing generation)as default, restore the base override, the notice says a restart will complete it →Reverted(the machine runs the candidate until that restart; the daemon treats the window as a live-tested-but-unconfirmed generation). A daemon restart without a reboot leavesAwaitingBootuntouched and never re-prepares it. - GenerationOp (
RevertGeneration), live: wait for an activation window if §12f requires one (the transaction stays inBuilt, bounded per §12f) → when the window opens: stale check, re-derive, re-decide(I8) → from B, confirm a passing sandbox run for thisstaged_sourcewhere §12g requires one →verify_candidate→ journalActivating→ worker:activate_live(candidate)→ journalChecking→ run applicable Readiness probes from both the candidate’sUnitDefsand the installed core, plus the expectation (per probe, §9) → pass:publish_boot(candidate)→ journalPublished→ promote (§9 step 4) → journalPromoted→ post-commit bookkeeping (§12e floor derivation, the committed generation’s override content, session accounting; part of the commit, I25) → journalCommitted(same record markslive_tested). Fail: journalReverting→ worker:activate_live(revert_to)→publish_boot(revert_to)→ restore base override (§9 step 4) → re-probe →Reverted, orRecoveryFailedif revert probes fail (alerts; stops all autonomous action until a human acts). - DeviceOp (
UnbindDevice): journalActivatingwith the previously bound driver recorded →bind→Checking→ probes → pass:Committed; fail:Reverting→unbind, rebind previous →Reverted/RecoveryFailed. Gym case: a failed driver bind restores the previous driver and attempts no generation rollback. RestoreSnapshotThenRevert(RestoreSnapshot): restore, then as above.
- UnitOp / IntentOp / GcOp (
-
Outcomes.
Partial/Unknownare never success. InActivatingwithRevertGeneration: re-observe; candidate active →Checking; else →Reverting. InChecking: probes decide. InPublished/Promotedon the live path: the candidate is already live and tested; recovery completes the commit (re-runpublish_boot, re-run the promotion rename — both idempotent) rather than reverting, unless probes now fail, in which case revert. InPromotedon the boot-only path the candidate is admitted, not confirmed: recovery waits for the confirmation checkpoint of this boot and then commits or reverts as above; it never writesCommittedbefore it. The watchdog may complete these steps because they are idempotent and were authorized by the transaction; it still cannot writeCommittedfrom any state beforeChecking. -
Log (I7) with
DerivedFacts,PolicyVersion, probe evidence, decision, outcome, lease epoch.
Crash-recovery table (what the lease holder does on finding a transaction in each state):
| Found in | Action |
|---|---|
| Prepared, Staged, Built (any strategy) | Nothing was mutated; mark Rejected(Interrupted), release the GC root, reopen the incident if any. A transaction that was waiting for an activation window is re-prepared from the same proposal, with its original caller and source (not as the daemon’s maintenance), once the daemon is ready; any unused approval token is invalidated by the restart and re-requested (§12 step 4) |
| Publishing (boot-only) | Read the publish_boot_once op’s records: OpEnd succeeded → journal AwaitingBoot; no OpEnd → ask the backend whether the one-shot is armed (boot_entries); armed → AwaitingBoot, not armed → Rejected(Interrupted). Never re-prepare a transaction that reached Publishing |
| AwaitingBoot | No reboot since publish: leave as is. After a reboot: last_booted() is the candidate → the admission check of §10; any other generation has booted since the publish → Reverted |
Activating, ReobserveOnly | Fence; confirm quiescence; re-observe; write Committed with the observed outcome; inform the incident. Never a generation operation. |
Activating, RevertGeneration | Fence; confirm quiescence; re-observe; candidate active → Checking, else Reverting |
Activating, UnbindDevice | Fence; confirm quiescence; re-observe; bound to candidate driver → Checking, else restore previous driver → Reverted |
| Checking (generation / device) | Fence; confirm quiescence; re-run probes; pass → the complete commit path, fail → Reverting |
| Published | Promote by hash comparison (§9) → Promoted → post-commit bookkeeping → Committed, unless probes fail → Reverting |
| Promoted (live) | Post-commit bookkeeping (I25) → write Committed. Whoever holds the lease — daemon or watchdog — runs the bookkeeping; a Committed without it is not written |
| Promoted (boot-only, this boot) | Admitted, not confirmed: wait for the confirmation checkpoint (§10); pass → bookkeeping → Committed; fail → Reverting as in step 7. The watchdog does not advance this row; only the daemon’s checkpoint does, and if the daemon is absent at the checkpoint the next boot runs the AwaitingBoot row |
| Reverting | Fence; confirm quiescence; re-run the strategy’s revert steps (all idempotent) → Reverted or RecoveryFailed |
Checking, ReobserveOnly | Fence; confirm quiescence; re-observe; write Committed with the observed outcome. Never the generation commit path. |
| RecoveryFailed | Alert; set the recovery interlock (§12c); refuse all autonomous transactions until an administrator records planarian resolve <txn> |
Gym cases: kill the daemon during a Runtime-tier Rollback (recovers as a generation operation) and during StopAndSuppress (recovers as a unit operation; no generation rollback attempted); an old backend job that outlives the 60 s drain (takeover ends in RecoveryFailed, never overlapping mutation); daemon and watchdog attempt takeover simultaneously (exactly one wins); daemon stays down after Published and after Promoted (watchdog completes the commit).
Recovery watchdog. planarian-recovery.service: separate unit, Restart=always, started before planariad, no dependency on it. Every 10 s it reads the journal; for a transaction in Activating/Checking/Publishing/Published/Promoted (live)/Reverting whose lease is unrenewed past deadline or whose owner pid is dead, it takes over the lease (fencing and confirmed quiescence as above) and runs the crash-recovery table. From Checking it does one of two complete things: if the authorized Readiness probes pass, it runs the entire authorized commit path (publish_boot → promote → Committed), because that path was authorized by the transaction and every step is idempotent; otherwise it reverts. It never stops partway. The earlier restriction is restated precisely: the watchdog may not write Committed from any state before Checking; from Checking, Published or Promoted (live) it completes the authorized path, bookkeeping included (I25). For a daemon that is alive and renewing its lease, the watchdog does nothing. Its binary and config are Sensitivity::Core.
Daemon restart reconciliation. On start, planariad reads the journal. If the watchdog holds a lease on a non-terminal transaction, the daemon waits until that transaction is terminal (it does not compete). Otherwise it takes over the lease (fencing) and runs the crash-recovery table. Either way it writes a reconciliation record (what it found, what it did, why) shown in the console on next open. Recovery steps that may activate a generation (a transaction found in Activating, Checking, Publishing, Published or Reverting) run just after planarian-ready.target is reached: the takeover and the reconciliation record come before it (owner decision, Nov 1). Activation reloads and restarts units ordered after the target, which would otherwise wait on planariad’s own start (#9).
Self-protection during activation. The NixOS units for planariad and planarian-recovery are declared with restartIfChanged = false and stopIfChanged = false, which NixOS’s switch honors by emitting X-RestartIfChanged=false/X-StopIfChanged=false, so no activation restarts or stops them. Independently, a candidate whose evaluated unit files for these two services differ from the running generation’s is touches_core → Reject; upgrading Planarian itself is the core update path (Phase E), and in A–D a manual nixos-rebuild by root outside Planarian. Networking changes set deadline to 90 s because loss of the daemon’s control path is likely. Boot ordering, readiness, and the response to the daemon failing are in §12d.
Concurrency: I6. Runtime transitions may run during build but not during Activating/Checking/Publishing/Published/Promoted/Reverting, except a boot-only Promoted, during which unit and device repairs run (owner decision, 31 Oct). This list and I6’s are the same list; schema carries it once.
Worker processes. v4.1–v4.5 described a worker subprocess whose pid the lease records. In A the activation runs in a worker thread and quiescence is established through jobs_tagged and the transient-unit tag alone, which is sufficient; the Lease.worker_pid and Lease.jobs fields are reserved for D’s supervisor and are empty in A (recorded in docs/deviations.md).
§12a. Runtime-settable options
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A core-owned allowlist (Sensitivity::Core; not extensible by manifest or model) of options whose effect can be applied immediately: font scale, volume, brightness, keyboard layout, Wi-Fi network selection. SetRuntimeOption applies the effect as a Runtime transition (subject to the global floors: Wi-Fi selection has ConnectivityLoss, none while NetworkManager has already failed, §11), then enqueues an ApplyChange write-back whose expectation is “no unit changes.” If the write-back’s build or probes fail, the runtime change stays in effect, the write-back is Reverted (override source untouched), the console shows “this setting will not survive reboot,” and the incident manager does not retry it; the user may re-issue it. The write-back is never attempted while a generation transaction is active: it is queued behind the active transaction and submitted when that transaction is terminal, with the same caller. A write-back refused as busy is not a failure and produces no notice.
§12b. Garbage collection and space budgets
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Protected set per I3, including in-flight candidates (via their GC-root symlinks) and every txn/<txn>/base.nix of a non-terminal transaction. services.planarian.gc.retainAllGenerations (default false) protects every generation: a full history kept for export or to go back to any of them; other store garbage is still collected, and the budgets below are reported, not enforced. Policy: keep last 10 generations and 10 snapshots and space budgets (default: generations ≤ 20% of the Nix store’s filesystem, snapshots ≤ 10% of their volume); whichever bound is hit first collects oldest-first down to the other. If the protected set alone exceeds a budget, CollectGarbage collects everything unprotected, reports BudgetUnsatisfiable with the shortfall, and never touches the protected set; the budget is then a target, not a guarantee, and the console says so. CollectGarbage is Runtime. Budgets are user settings, not manifest options.
§12c. Journal headroom and ENOSPC
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
- Format. Records are length-prefixed and CRC-32C-checked; a torn or mismatched tail record is ignored and truncated on open, never interpreted as a transition. Anything worse — a checkpoint that fails its CRC, does not parse, or violates an invariant on load, or a retained record that does — is an
Integrityfailure (§12d), reported as such by both processes; it is never reported as the journal being merely absent. Every record also carries the hash of its predecessor (I18), and the checkpoint carries the chain head, so an edit to any retained record is detected on the next read and raised as anIntegrityfailure (§12d). The chain is tamper-evident, not tamper-proof, until E signs the chain head with a key outside the running system. The journal is a preallocated file (default 64 MB) in/var/lib/planarian/journal/. - Journal versions (owner decision, Nov 3). Two classes of record kind. Every journal record kind is one of: informational: no decision reads it; decisive: the fold uses it to decide what runs, what is held, or what is approved. Hold events and withdrawal links are decisive. The class is in the frame. Each record’s frame carries its kind’s class, so a build can classify a kind it doesn’t know. Opening a journal with an unknown kind. An unknown informational record is skipped, and the skip is journaled (
Skipped { kind }). An unknown decisive record is anIntegrityfailure. Rollbacks across a format change. A generation transition whose targetplanariadcannot read a decisive record kind present in the journal, or a decisive field of one (a field added to an existing kind that the fold decides from), asks first (AskFirst,ApproverClass::Administrator). The approval names what the target would not understand, so a rollback never silently lands a machine inIntegrityor drops a decision. Each build exports the kinds and decisive fields it reads (journalKinds). A generation from before the export is taken to read only the journal format’s original set. - Chain head. The chain is unkeyed in A: it detects damage and partial edits, not a consistent rewrite by root (I18). The current head is included in every signed health report (§10), so an off-machine receiver that keeps the heads it has seen can detect a rewrite of anything it has already witnessed (C, with the mesh); measured boot anchors it on the machine in E.
- Checkpoint. Compaction is not deletion of history; it is checkpoint-then-truncate. A checkpoint captures all live state the journal establishes: the external desired-state source’s last accepted sequence number (§9), active overrides (including
Until::Explicitones from long-committedStopAndSuppress), the security floor, open and cooling incidents with their budgets and regression counts, the nonces of consumed approval tokens within their validity window (outstanding tokens are not carried: a restart invalidates them, §12 step 4), theAgentStartedrecords of live agent instances (I24),known_good/accepted_baseline/live_tested, every non-terminal transaction with its lease, the lease epochs of recently terminal ones, and the recovery interlock: the set of unresolvedRecoveryFailedtransactions with the facts needed to resolve them (what was being changed, which step failed, the observed state, the base and candidate override hashes, the revert target). The interlock is live control state, not history: while it is non-empty, autonomous mutation is blocked (onlyObserve-tier reads, user-authorizedSetOverride/LiftOverride, and administrator operations run), and it survives compaction and restart of both processes. It is cleared only byplanarian resolve <txn> --as-committed | --as-reverted | --manual-repaired, an administrator-authorized operation journaled as a transition of its own; no other request clears it. Gym case: inject a failed revert, force compaction, restart daemon and watchdog; autonomous mutation stays blocked until the resolution is durably recorded. It is written to a temp file, fsynced, renamed overcheckpoint, directory fsynced; only then is the journal truncated to records after the checkpoint’s sequence number. Recovery from (checkpoint + retained journal) MUST reconstruct the same state as recovery from full history; the gym asserts this by comparing both reconstructions after forced compaction. Compaction triggers at 75% full. - Filesystem contract. The preallocation guarantee holds only where in-place rewrite does not allocate: on ext4 and XFS a
fallocated file; on btrfs only with the journal directory markednodatacow(chattr +Cbefore creation) and the file preallocated; on ZFS it does not hold, and the daemon instead reserves space with a dedicated datasetreservation. The installer applies the layout-specific setup and the gym validates it per supported layout (ext4, btrfs, ZFS). If a recovery-path write to the preallocation fails with ENOSPC despite the contract, the daemon writes a fixed-sizeRecoveryFailedsentinel into a separate 4 KB file reserved at install and blocks all mutation. The reservation is counted indevice:fs-journal’s Readiness probe. CollectGarbage { emergency: true }is the only transition allowed to start whiledevice:fs-journalordevice:fs-nixis below threshold; it is Runtime and its record fits the preallocation.- ENOSPC behavior by state: in
Prepared/Staged/Built(write to stage or store fails) → reject withNoSpace, open the storage incident, do not retry the original until it closes. InActivating/Checking→ treat as probe failure →Reverting; the revert record is in the preallocation. InReverting→ if a revert step fails for space, markRecoveryFailedvia a fixed-size sentinel record and alert. Promotion (temp-write of the override file) failing for space → treat as Checking failure → revert; the live override file is untouched because the rename never happened. /bootis tracked separately:publish_bootfailing for space →Checkingfailure → revert; the candidate is never left as the only boot entry.
Gym cases: fill the journal filesystem to the reserved-block boundary as the daemon’s uid; verify that emergency collection is recorded and executed, or that mutation stops with an explicit RecoveryFailed/NoSpace status; repeat after a snapshot and after repeated journal reuse on btrfs; force compaction with a live Explicit suppression and an exhausted incident budget, then crash and restart (both survive); interrupt a record write and a checkpoint write (neither is misread).
§12d. Boot ordering and failure posture
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Admit no principal before the monitor. planariad is ordered before every unit that admits a principal: the display manager (whichever unit NixOS uses for it — display-manager.service, or greetd.service where services.greetd is enabled without the display-manager alias), getty, sshd, the model service, any remote relay, user@.service, and every agent unit, declared or launched. Agents started by planarian agent run are created in a planarian-agents.slice with After=/Requires=planarian-ready.target and a start condition that fails while any planarian-failed@*.target is active; the posture’s disable agents response stops that slice as a whole, not a configured list, so an agent the launcher created cannot outlive a failure posture (I20). Each launch carries TasksMax=, MemoryMax= and a per-uid count from services.planarian.agents.limits. When it is ready it reaches planarian-ready.target. Whether principal-admitting units merely follow that target or require it is the failure posture. With this ordering, the window before the monitor is ready contains only firmware, bootloader, kernel, initrd, and the generation’s own early units, all already in the trusted base; and the change path fails closed while planariad is down, since Proposal is the only write path.
Enforced outside the daemon. The NixOS module generates planarian-ready.target and one planarian-failed@<class>.target per failure class, wired with ordering and OnFailure= in systemd and the initrd. planariad never implements its own failure response. The posture is declared in the generation, so it is immutable at boot.
| Class | Meaning | Detected by |
|---|---|---|
Absent | planariad did not reach ready within the boot-ready timeout, or was masked or stopped | ordering timeout on planarian-ready.target |
Crashed | reached ready, then died and exhausted its restart budget; or planarian-recovery exhausted its own start limit | OnFailure= on both units, each with a StartLimitBurst so a crash loop ends in the class rather than running forever |
Integrity | core hash ≠ signature (I2); journal chain broken (I18); boot report found undeclared early units or processes; invalid signature on the external desired-state source (§9); in E, a measured-boot mismatch | boot report, planarian-recovery’s independent check, initrd measurement |
Integrity is always treated at least as seriously as Absent.
Responses (composable): alarm (a non-dismissable banner on the login screen and in every session until an administrator acknowledges it: for Absent/Crashed, “Planarian, which protects this computer from unsafe changes, is not running. Assistants and system changes are turned off until it is fixed. [Administrator] has been notified.”; for Integrity, “This computer may have been tampered with. Do not enter passwords or open sensitive files. [Administrator] has been notified.”); notify administrators, local and remote, with the boot report; disable agents (the model service, any relay, every agent principal are not started); restrict remote access to administrators; restrict login to local administrators in a recovery session; restrict network (none, mesh-to-administrators, or full); halt to recovery (boot stops at a recovery target); in E, seal (the disk-encryption key is sealed to measurements that include the core, so an Integrity failure leaves the disk locked).
| Posture | For | Absent / Crashed | Integrity |
|---|---|---|---|
| Relaxed | Developing Planarian; throwaway VMs | Alarm (dismissable), disable agents | Alarm, notify, disable agents |
| Standard (default) | Personal machines | Alarm, notify, disable agents, restrict remote access; local login proceeds | Alarm, notify, disable agents, restrict remote access, network to mesh-to-administrators (in A, before any mesh exists: remote access off, network otherwise unrestricted); local login proceeds with the warning |
| Guarded | Work machines; agent hosts | Alarm, notify, disable agents, restrict remote access, restrict login | Halt to recovery, network to mesh-to-administrators |
| Sealed | Servers, fleet nodes | Halt to recovery, network to mesh-to-administrators | Halt to recovery, no network, seal (E) |
Every posture disables agents on any failure (I20). Restrict remote access to administrators means Planarian’s administrators (root and planarian-admin), not wheel. In A, alarms and notification are local (the login screen, every session, wall, the system journal): root can clear them, and a machine whose planariad is stopped can go unnoticed by anyone not at it. From C, the signed health report doubles as a dead-man signal: an off-machine receiver alerts administrators when reports stop arriving within twice the declared cadence. Custom postures combine the options per class, with two constraints: agents are always disabled, and the Integrity response is never weaker than the Absent response. The installer selects the posture (C; A ships Standard). The boot-ready timeout is configurable per machine, default 60 s, bounded 15–600 s. Raising the posture or shortening the timeout is a Tested change; lowering the posture or lengthening the timeout is Security-sensitive (AskFirst with administrator authority) and takes effect at the next boot, never in the current one: the posture units read the declared posture from the booted generation’s files, and a switch that would weaken either leaves the booted values in force until reboot (the module implements this by having planarian-ready.target’s configuration come from /run/booted-system, not /run/current-system). Recovery exits: an administrator acknowledges the alarm (journaled with identity), or planariad reaches ready and the boot report is clean, after which restrictions lift. Only an administrator clears an Integrity failure.
Network security fails closed (owner decision, Nov 2). Which units. A network tool is a unit able to change how traffic leaves the machine: one that holds CAP_NET_ADMIN or CAP_NET_RAW, or could gain either (its bounding set allows it and NoNewPrivileges= is unset, so a setuid or file-capability binary such as ping can grant it), can open /dev/net/tun or /dev/ppp, declares network interfaces, or runs as root without a capability bounding set (computed at build time from the unit’s systemd configuration, never from its name; fail closed). A network tool is protecting traffic while the kernel has attributed to it a change that currently steers traffic: an interface it created that carries a default or all-covering route, a default or all-covering route it added over a virtual interface it did not create (a persistent tun or tap made by a setup unit, or one moved in from another network namespace; the creator, where known, is protecting too), a policy rule catching all traffic, DNS servers on its interface, the system proxy, or firewall rules that drop or redirect traffic. Planarian attributes every routing, interface, rule and nftables change to the unit that made it from inside the kernel (eBPF, written with aya, on the netlink receive paths, the legacy netfilter setsockopt path, and network-device registration, so an interface created by ioctl such as a tun is attributed too; only changes in the host’s network namespace count; crates/backends/netwatch), and keeps each unit’s current footprint in /run. A network-security unit is a network tool that is protecting traffic. Its own interfaces and peer endpoints, read from its footprint, are what the hold passes; holdInterfaces and holdAllow may add to them. Fail closed: a change the kernel could not attribute, a footprint missing or stale when a network tool fails, or a watch that failed to attach, all count as protecting. Network tools start after the watch (owner decision, Nov 2). Every network tool is ordered after planarian-netwatch.service. That service reports ready only once its probes are attached and its first footprints are written, so no network tool can change how traffic leaves the machine before the watch sees it. The ordering is not a dependency: if the watch fails to start, network tools still start, and the watch counts as failed (fail closed, above). The watch in the initrd (owner decision, Nov 3). With the systemd initrd, the watch also runs in the initrd, ordered before every network tool there. Its probes and event queue stay in the kernel across the switch to the root. The stage-2 watch takes over the initrd’s footprints and the events queued meanwhile. So no network tool, in either stage, acts unwatched. If the stage-2 watch cannot take over (a version mismatch, or the probes are gone), it attaches afresh, and anything it cannot attribute counts as protecting. A unit the watch itself needs in order to start cannot wait for it. The module computes those units, lists them in the export (networkToolsUnordered), and rejects a configuration in which the ordering would form a cycle; for those units, a failure before the watch attaches holds traffic. With a scripted initrd, the initrd runs no network tools. When a network-security unit has failed (observed Failed, or not running while it is desired running; a stop through an attested override is not a failure), its traffic is held: an nftables table drops all traffic in and out except loopback, address assignment (DHCP, router advertisements), traffic to the endpoints the unit’s manifest entry declares in holdAllow, traffic over the unit’s own interfaces (holdInterfaces: while it is down they carry nothing, and once it is back its traffic goes only through it, as a VPN kill switch does; owner decision, Nov 2), and established connections of administrator sessions; new connections, new SSH logins among them, are refused. The hold is applied by systemd through OnFailure= on the unit (a crash or a kill) and by planariad for a stop outside Planarian that is not attributed (always a fault, §11 residue), so it does not depend on the daemon; the table is Sensitivity::Core, survives a restart of planariad, and is in place before the incident opens. The unit’s restart runs at once, with an alarm (“Running and its Readiness probes pass. If repair fails (the attempt budget is exhausted, or RecoveryFailed), the hold stays and administrators are notified: the machine never falls back to unprotected traffic on its own. An authorized principal may lift a hold with planarian hold release <unit> [--until], journaled as an attested Override on the hold; a hold protects a Security-sensitive unit, so releasing it needs administrator authority, and a non-administrator’s release is held as AskFirst with ApproverClass::Administrator (§11). Gym cases (VM): kill a WireGuard unit, then new outbound connections fail, loopback works, an administrator’s established SSH session survives and a new SSH login is refused, the restart restores the tunnel and the hold lifts, with one alarm; the same with the restart failing every attempt, then the hold stays, administrators are notified and nothing is reachable; planarian stop on the unit holds nothing; a non-administrator’s hold release asks for an administrator; at boot, every network tool becomes active only after the watch is ready, and nothing is unattributed; with the watch failing to start, the tools still start and a failing one is held; with the systemd initrd, every network tool the initrd runs is attributed (journald, udevd, module loading), and no footprint carries pre-attach after switch-root.
A network tool Planarian does not manage (owner decision, Nov 3). Such a unit is a network-security unit with no services.planarian.units entry. When it fails: it is held like any other; Planarian does not repair it, because it repairs only declared units; the hold lifts when the unit is Running again by other means, or on hold release; the alarm says it needs a person: “
Core hash in A–D. The reference hash for I2 covers every Planarian binary and script the generation installs (planariad, planarian-recovery, the CLI, the posture units’ scripts), recorded at build. Signatures replace it in E. A test build (the gym-hooks package, §15) installed outside the gym VM tests is an Integrity failure, and the module asserts against it.
Lock the boot chain where the machine allows it: bootloader editing disabled (boot.loader.systemd-boot.editor = false); Secure Boot with a signed unified kernel image (Lanzaboote) from C; TPM measured boot later. In C, pre-Planarian boot entries are garbage-collected after enrollment. Entries below the security floor (§12e) stay in the boot menu (I1) but are labeled, are never the automatic fallback, and, where the bootloader supports it, require administrator confirmation.
§12e. Security floor
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The security floor is the newest committed generation whose closure contains a security fix absent from every earlier generation (in A: absent from the current floor generation and from every generation between it and the candidate; a Revert to an older generation that merely lacks a newer package is therefore never a fix). It is a derived fact, computed as part of every generation commit on every commit path (I25) and journaled before Committed; a commit whose floor record cannot be written does not commit. It is never set by a proposal. The inspected set is the whole system closure: every package reachable from the generation’s toplevel, including service packages, the kernel, and firmware, not only environment.systemPackages. In A the sources are nixpkgs vulnerability metadata, evaluated for every package in the closure (a package whose previous version carried knownVulnerabilities that the new one lacks), and an administrator mark (planarian mark-security-floor); from B, a CVE feed matched against the closure (e.g. vulnix) is added. A fix the inspected set does not show does not move the floor; the administrator mark is the remedy. Generations below the floor are not eligible as automatic revert targets, as automatic boot fallbacks, or as Rollback targets; going below the floor is AskFirst with ApproverClass::Administrator (I16). Until the floor generation is boot-confirmed, known_good may be below the floor: it stays bootable and in the menu (I1) as a manual, administrator-confirmed fallback, while the floor generation is the boot default. The floor never advances to a generation that has not at least committed live. If no eligible known-good generation exists above the floor when a revert or rollback is needed, the daemon escalates the incident with EscalationReason::RevertBlockedByFloor instead of reverting. Gym cases: a security update is applied; a probe failure is induced afterward; the revert does not cross the floor and the incident escalates. The same, with the fix in a service package (openssh) that is not in environment.systemPackages. The fixing generation commits via the daemon, via the watchdog after a crash at Committed-minus-one, and via the boot-only path; all three move the floor, and a Rollback to the vulnerable generation then asks. A Revert to an older generation sets no floor.
§12f. Activation scheduling
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
A verified candidate whose activation would restart or reload any unit in an active user session (audio, compositor, input, network, the session itself) waits in Built until an activation window: the session is idle, the user asks, or the next boot. It never activates while Foreground holds in any session, except a rollback the user or an Intent requests, or a repair rollback, as below. Changes that only take effect at boot (kernel, initrd, drivers) take the boot-only path (§12, step 7): published as a one-shot next-boot entry in AwaitingBoot, with a non-urgent “restart to finish” notice, and confirmed or reverted at that boot. Repairs are exempt: restoring a failed unit is not an activation. A transaction waiting in Built holds no lease-gated mutation and counts as active for I6, so §12a write-backs queue behind it; if the daemon restarts, it is Rejected(Interrupted) per the crash table and re-prepared with its original caller. “The next boot” as a window therefore means: re-prepared after boot and activated before any user session starts. Parking is bounded (owner decision, 30 Oct): a transaction may wait in Built for at most the parking limit (24 h, CLOCK_BOOTTIME); when it expires the transaction is Cancelled(Daemon) with a notice naming the change and how to re-issue it, and the incident, if any, reopens. AwaitingBoot has no limit — it waits for the reboot it was published for — but the console and planarian status report it as waiting for a restart, distinct from any failure, and the “restart to finish” notice is repeated daily. I12 is qualified accordingly.
Rollbacks (owner decision, Oct 31). A Rollback with Source::User or Source::Intent (planarian undo, “roll back”) runs at once: the request is the activation window, also while Foreground holds (owner decision, Nov 2). A Rollback with Source::Diagnoser, proposed after in-place repair of an incident has failed (§13 orders it last), is a repair: it does not wait for an activation window, and is not parked in Built. Being a repair exempts it from waiting, never from asking: the §11 disruption floors apply to it as to any transition, so a repair rollback that would end a session is AskFirst. While Foreground holds, a repair rollback runs only if its incident’s target is one the foreground work plausibly depends on (the list under Quiet mode); otherwise the diagnoser does not submit it — it is deferred with the incident, as quiet mode defers any repair, with no transaction opened, and runs when quiet mode ends. The revert of a candidate that fails its own checks (Reverting, §12 step 7) is that transaction’s recovery, not an activation, and is never deferred. I6 still holds: a rollback is a generation operation. When a repair rollback, or a rollback the user or an Intent requests, is about to run (admitted, and approved if it had to ask), an active generation transaction that has not yet mutated (one waiting in Built for its window) is withdrawn for it, Rejected(Interrupted), and prepared again once the rollback is terminal; one past Built is waited for, like any other. A rollback still waiting for approval withdraws nothing (owner decisions, Nov 2). Gym cases: rollback-repair-foreground-dependency (a committed update breaks Wi-Fi during a Foreground workload; the rollback runs at once, with one notice); rollback-repair-foreground-unrelated (the same update breaks the printer service; the rollback waits for the workload to end, then runs, and the incident’s budget does not advance meanwhile); rollback-repair-session-loss-asks (a repair rollback that would restart the compositor of an active session is AskFirst and does not run without approval).
Quiet mode. While Foreground holds for a session: Diagnostic probes and the gym’s trend work are deferred and Readiness probes drop to their slowest interval; activation of staged generations, garbage collection, store optimization, catalog and model updates, and sandbox snapshot creation are deferred; non-urgent notifications are queued and delivered as one batch when quiet mode ends; the model is not loaded (§20). Not deferred: repairs of units the foreground work plausibly depends on (audio, compositor, input devices, network, Bluetooth), including a repair rollback whose incident targets one of them (see Rollbacks), failure-posture responses, and repairs the user requests. Deferred repairs are deferred in fact: their incidents’ budgets and backoff do not advance while quiet mode holds (§13), and the repair loop skips them rather than running them. Gym case: a reference game workload runs while a garbage collection, a staged update, and a non-urgent fault are queued; frame-time p99 matches a baseline with Planarian stopped, and each deferred item runs within its window afterward; a PipeWire kill during the workload is repaired immediately and produces exactly one notice.
§12g. Sandbox tier (Phase B)
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Agent-speed iteration happens beside the real machine, not on it. Three tiers, cheapest first: dry-run (verify_candidate, risk derivation, and closure diff, with no activation: seconds); sandbox (the candidate generation booted in a Firecracker microVM restored from a snapshot of the settled reference generation, probes run inside, VM discarded; snapshots keyed by generation hash; built with microvm.nix); real (this section’s transaction path). From B, a proposal at tier Tested or above reaches the real path only with a passing sandbox run against the same staged_source, recorded in the journal as evidence. The sandbox pass does not shorten the real settle window. Where KVM is unavailable, the daemon reports sandbox: unavailable and Tested is floored to AskFirst: the machine degrades to a person looking first, never to skipping the check. The sandbox’s verdict is evidence, like a probe; it never promotes anything, and it runs untrusted candidates under Firecracker’s jailer. Firecracker cannot host device-family faults (GPU, Wi-Fi hardware, USB, suspend/resume); those stay on the QEMU or bare-metal gym target.
§12h. Time
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Everything that does not survive a reboot is timed on CLOCK_BOOTTIME, never wall-clock time: approval-token expiry (tokens are not carried across daemon restarts at all, §12 step 4; the checkpoint keeps consumed nonces only), lease deadlines, cooldowns, settle windows, activation windows, and the parking limit (§12f). Wall-clock time is used only for what is shown to people and for records that must be compared across boots. The posture options recommend authenticated time (NTS) and report unauthenticated NTP as a notice. Gym case: the wall clock is moved back 48 h after an approval is issued; the token still expires 24 h after issue.
§13. Incidents and the diagnoser (crates incident, diagnose)
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Incident lifecycle
- Open when a unit is expected (desired condition holds, no override) and its derived status is Failed, Degraded, Stopped-but-desired-Running, Detected-but-desired-Bound, or Absent-but-desired-present, with fresh evidence for two consecutive ticks.
- Defer to systemd first. If the unit has
Restart=and systemd is within its ownStartLimitBurst, the incident waits for systemd’s attempt (up to the unit’sRestartSecplus settle) before Planarian acts, so two controllers don’t restart the same service. - Attempts are transactions; each outcome is appended. Budget 3 per incident, persisted in the journal (survives daemon restart). Backoff between attempts: 10 s, 60 s, 300 s.
- Closed when the target satisfies its desired predicate (Running, Bound, Stopped, or Absent as desired) with fresh evidence for a stabilization window (5 min).
- Regressed: if the same target diverges again within 30 min of Close, the incident reopens with
count+1and a longer backoff; atcount = 3it goes straight to Escalated (report to user; cooldown 1 h; no autonomous action on that target until a human acts or the cooldown ends). - Cooldown after budget exhaustion: 1 h, then one more attempt, then 6 h.
- An override on the target closes the incident (I13).
- A person acting on the target (
release,override,stop, a manual repair) reopens the incident with a fresh attempt and no cooldown — but only when the action was authorized and succeeded. A request that is refused, parked, or fails leaves the incident record exactly as it was: budget, cooldown, escalation and next attempt unchanged. Rejection grants no indirect control over the repair loop. Gym case: an unauthorizedplanarian release sshdduring sshd’s cooldown changes nothing about the incident. - Quiet mode. While
Foregroundholds, incidents on units the foreground work does not depend on wait (§12f); their budgets and backoff do not advance while they wait.
Diagnoser
- From the open incidents, walk
Requiresedges (andDiagnosticedges with confidence ≥ 0.7) to the deepest divergent unit; that is the target.OrderingandWantsedges are never followed: a failed wanted unit is its own incident and is never assumed to be the cause of the wanting unit’s failure. Cycles: the walked subgraph is first collapsed into strongly connected components; “deepest” is defined on the resulting DAG; within a component, the target is the unit with the earliest definiteObserved::Failedtimestamp, else the one with the most failed Readiness probes, else lexical order. Gym case: an optional wanted service fails while its wanting service stays healthy (two incidents at most, no false root cause). - Enumerate candidate transitions, depth ≤ 2, whose precondition holds and whose prediction closes the divergence. Exclude exhausted and cooling targets.
- Order by: tier ascending, then disruption ascending, then recovery (Idempotent < Revertible), then cost.
Rollbacklast. - Submit one at a time as
Proposal { source: Diagnoser }. Stop at the firstCommittedwhose expectation held. - Exhausted →
DiagnoserExhausted→ model (B) or user report (A).
Phase A ApplyChange candidates are limited to catalog entries tagged repair: (re-enable a predicted option; reapply a previously-committed change for the same target — the latter is in the catalog from v4.6; it was specified and not shipped in v4.5).
§14. Intent matcher (crate intent)
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Deterministic; no language model; identical behavior in degraded mode. Not in the Verus subset.
- Index: every
UnitDef.synonyms, every transition verb with a phrasing list, every change-catalog entry’s description tokens, every runtime-settable option. - Match: normalized token overlap plus a fixed pattern set (“restart X”, “reconnect X”, “turn X on/off”, “make X bigger/smaller”, “stop/pause X [for N minutes|until I say]”, “start X again”/“done with X”). Output
(Transition, confidence). - Thresholds: ≥ 0.8 propose with
Source::Intent; 0.5–0.8 ask the user to confirm one of the top two; < 0.5 unmatched (A: say so; B: summon the model). - Stopping: “stop X” and its variants map to
StopAndSuppress { until: parsed or Explicit }, never to a bareStopUnitor a bareSetOverride; the override is journaled before the stop (§12), so there is no window in which the repair loop restarts the unit. “Turn wifi off” and similar map to an override on the network unit. “Start X again”/“done with X” →LiftOverride, after which the unit is desired-Running again and is repaired if needed. AStopUnitissued through the CLI by an authorized user is rewritten toStopAndSuppress { until: NextBoot }with a notice. The first time a user’s raw stop is caught as the unattributed residue (§11), the notice teaches the attributed form. - Authorization applies as in §11; an unauthorized match is reported as such, not silently dropped.
§15. Gym (crate gym)
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
- Catalog entry:
Fault { id, family, inject: Transition | RawAction, required_post: Expectation, intent: Option<Intent>, tags }.intentmarks cases where the correct behavior is no intervention (user stopped the service; login screen idle; probe timed out but the service is fine). - Phase A catalog, minimum families: service killed; service misconfigured; required dependency stopped; ordering-only predecessor stopped (must NOT be treated as cause); driver unbound; bad generation activated; daemon killed during Activating, Checking, and Reverting; revert fails (RecoveryFailed path); probe delayed past timeout with the service healthy; external config edit during a build; intentional service stop; suspend/resume; network unavailable at the gateway with the driver fine; flapping service (fails 30 s after each restart); storage full; manifest edited to weaken a probe; login screen idle with autologin off; boot-to-desktop failure.
- v4-review acceptance cases (all required in Phase A): kill and immediately restart the daemon while a deliberately slow activation worker survives (activation and revert never overlap; the old owner cannot commit); an override-only candidate changes the evaluated output; the owner edits the source during a build (transaction rejected
Stale); a crash injected at each ofActivating,Checking,Published,Promoted,Reverting(running state, boot selection, override source, and journal agree after recovery); disable then re-enable a service with a canonical Readiness probe (no revert of the authorized change); Diagnostic evidence expires on a healthy machine (status stays Running); a definite failed service while another unit’s probe is stale (status Failed, not Unknown); authentication succeeds but the compositor never reaches its target (incident opens); idle greeter with autologin off (no intervention); a user stops a running service (stays stopped, no repair race); an unprivileged user tries to override a system Security unit (held for administrator approval; the unit stays up); an unprivileged user tries to stop a system unit that is neither Security-sensitive nor user-controllable (rejectedUnauthorized); the daemon proposes a session-dropping driver rebind and a runtime Wi-Fi change (both AskFirst); an optionalWants=unit fails while its wanting unit stays healthy (no false root cause); the journal filesystem is filled to the reserved-block boundary as the daemon’s uid (emergency collection recorded and executed, or explicitNoSpace/RecoveryFailed). - v4.4 acceptance cases (all required in Phase A): for each failure class under the Standard posture, inject the failure (mask the unit; kill it past its budget; corrupt the core hash; break the journal chain; plant an undeclared early unit) and verify exactly the declared responses, that no agent principal started, and that the recovery exit works (the other postures from C); a security update followed by an induced probe failure (revert does not cross the floor; the incident escalates with
RevertBlockedByFloor); a kernel update (takes the boot-only path; confirmed at the next boot, and reverted when the injected boot fails); a user-session principal overrides the firewall (held for administrator approval; firewall stays up); a non-administrator stops the network while another uid’s session is online (AskFirst; the other session is notified); two graphical sessions for one uid where the second compositor fails (an incident opens for the second session only); logging out of one of two sessions (user services keep running); an unattributedkill -9of a service under Standard (accepted until next boot withattribution: Inferredand an undo), of the firewall (restarted and alarmed), and an OOM kill (repaired as a fault); a retained journal record edited on disk (detected on read as anIntegrityfailure); a staged update whileForegroundholds (not activated until the window); the fake backend run against everydocs/assumptions.mdentry under injectedPartial,Unknown, and fenced outcomes. - v4.6 acceptance cases (all required in Phase A; each closes a finding §28 names):
agent-name-collision(alice’s running agent stays unauthorized for a system unit after an administrator re-runsagent runwith the same name; the second start is refused naming the owner); agents launched through the API, thenAbsent,CrashedandIntegrityinjected (every launched agent stops; new starts fail; a per-uid launch over the limit is refused); a deniedreleaseduring a cooldown (the incident record is unchanged);SetOverride(Stopped)andStopAndSuppressondisplay-managerfrom a remote administrator (identical disruption and floor); a rollback between vetted generations with equal manifests and a changed network unit (ConnectivityLoss scheduling); a fixing generation committed via daemon, via watchdog and via boot-only (the floor moves in all three); a crash betweenOpBegin(publish_boot_once)andOpEnd(after restart and reboot, boot selection, roots, overrides and journal agree); a boot-only candidate admitted but failing the 10-minute confirmation (Reverted, default restored, notice);verify_candidateon a store path whose NAR hash differs from the recorded one (refused); an approval issued, the daemon restarted, the change re-requests approval; a transaction re-prepared after restart keeps its original caller (own-session exemption still applies); an unauthorized caller’sApplyChange(zerostage/evaluate/buildcalls); a checkpoint with a bad CRC (Integrity, notAbsent);planarian-recoverycrash-looping (Crashedraised); a generation that lowers the posture switched live (the booted posture stays in force until reboot); a unit Degraded before a change that loses a second probe (Checking fails); a dry activation that exits nonzero, times out, and emits truncated output (no autonomous activation in any case); a stalled resolver and a trickling HTTP endpoint (the batch returns by the deadline; the repair loop ticks during it); an owner edit while a change waits for an idle session (Stalewhen the window opens);SetOverrideover another principal’s attested override (Rejected(Precondition)); an approval, thenplanarian lognames the approver; a transaction parked past the limit (Cancelled(Daemon)with a notice); an SSH-only login (no graphical incident); a manifest with a 0.5-confidence edge to an external dependency (rejected at evaluation); a user’s first rawkillcaught as the residue (the notice namesplanarian stop <unit>); aRevertto an older generation (no floor set); a §12a write-back issued while a generation transaction is active (queued, then committed, no notice); a crash duringbuild(the orphan root is gone after the age limit);rollback-repair-foreground-dependency,rollback-repair-foreground-unrelated,rollback-repair-session-loss-asks(§12f Rollbacks). - Harness contract. The runners report what the catalog asked, never what they managed to run: a runner (fake, VM, by-hand
run-vm.sh) exits nonzero on anyFAIL,PARTIALorERRORand on anUNSUPPORTEDverdict for amust-passoracceptanceentry that has no waiver indocs/deviations.mdnaming that entry and harness. CI runs the fake catalog with--strict. A waiver is a reviewed row, not a default. - Fault families also include a malicious declaration: a declared unit with a known-bad payload; Planarian cannot tell it from a legitimate service, so the case scores whether the incident report surfaces the declaration’s origin (who declared it, when, through which path).
- Scoring per fault: pass iff
required_postholds (or, for intent cases, iff no autonomous transaction was opened after the user’s own setup action; the user’s authorizedStopAndSuppressor similar is the setup, not a failure). Gamed success (probe tampering,StopUnit(target), an override whosebyis the daemon, the watchdog or the model, or whose log record is attributed to an agent) fails the run. AnUnattributedinferred override is legitimate only in the catalog cases that declare it as the expected outcome. - Test builds. Fault injection in the real binaries lives behind the
gym-hooksfeature, in a separately named package; it is never installed outside the gym’s VM tests (§12d). - Metrics reported: repair rate; unnecessary interventions (transactions on intent cases); disruption incurred (sum of
Disruptionof executed transitions); time-to-repair; restart count per target;RecoveryFailedcount (must be 0 unless injected); the invisibility budget (§21: idle CPU and memory are measured over the service cgroups ofplanariadandplanarian-recovery, so spawned helpers count; the frame-time figure is a real p99 over frame samples, and a stand-in metric is reported as a stand-in, never as the budget); AskFirst prompts per user-week; catalog coverage per profile (§9); the verification ratio, the trust count, and the internal-unproven count (§17a); open bypasses by severity (§17b). The invisibility budget and the verification numbers are ratchets: every gate requires that they not degrade. Every gate requires zero open High bypasses. - Phase B: fault families are split into train/held-out; GB is measured on held-out families.
- Targets: NixOS 26.05 VM gates. Non-gating from Phase A: an Asterinas NixOS VM and a Firecracker microVM (from a snapshot of the settled reference generation; service, configuration, transaction, and intent families only), failures classed
Driver/Backend/Core; onlyCorefailures are bugs. The Firecracker target becomes gating for the families it can host in B.
§16. CLI and console
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
CLI: planarian status | log | incidents | approve <hash> | resolve <txn> --as-committed|--as-reverted|--manual-repaired | undo | mark-good | mark-security-floor | override <unit> [--leave-alone|--stopped|--absent|--detached] [--until] | release <unit> | stop <unit> [--until] | ack | report boot | posture | hold release <unit> [--until] | agent run <name> -- <command> | gc, and ask "<phrase>". agent run starts an agent with the caller’s permissions, attributed to the agent (§11); it refuses a name already in use (I24). override/stop/release create and lift Overrides with the caller’s identity. ack acknowledges a failure-posture alarm (administrator for Integrity) and, with a unit, an inferred override (§11). report boot prints the last boot report; posture prints the declared posture and timeout and whether a weaker declaration is waiting for the next boot (§12d). Every verb §16 names exists in A; changing the posture or timeout is a configuration change (§12d). approve and mark-good require root and an interactive TTY in Phase A, the TTY established by the daemon from the peer for both verbs (§11; known weakness: any root process with a terminal qualifies; broker in C). mark-good changes the recovery baseline and requires administrator authority. known_good is always boot-confirmed (I1); mark-good on a boot-confirmed generation moves known_good to it. mark-good --accept-unconfirmed instead sets a separate accepted_baseline, which the revert path may use as revert_to when it is newer than known_good, but which never replaces known_good in the boot menu until a reboot confirms it. Both are refused if any Readiness probe is failing. Initial enrollment: the first generation booted after install becomes known_good once its Readiness probes pass at the 10-minute checkpoint on that boot; until then Planarian runs with max_tier = Runtime and reports “no baseline yet.”
Approvals show the change (owner decision, Nov 2). For a generation transition (ApplyChange, SwitchGeneration, Revert, Rollback) that asks, planarian approve, the console and the notice show the configuration diff it would apply: each option added, removed or changed, with its old and new value and its sensitivity, the Security- and External-sensitive ones first, and the units it would remove or stop. The same diff is part of the pending approval as structured data (planarian approve --json; IPC PendingApproval.diff), so an agent reads exactly what a person sees. The core computes it from the evaluated generations, never from the proposal; if it differs when the facts are re-derived at approval, the approval does not apply: the change is refused as stale, or asked for again when it re-binds.
Operational limits (normative): attempt budget 3 per incident; backoff 10 s / 60 s / 300 s; stabilization 5 min; regression window 30 min; escalation at 3 regressions; cooldowns 1 h then 6 h; token expiry 24 h; tick 5 s; probe timeouts as declared, default 10 s; settle default 15 s; generation deadline 15 min (networking 90 s); watchdog period 10 s; job-drain wait 60 s; journal preallocation 64 MB, compaction at 75%.
§16a. Visual console (read-only in A)
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Socket limits. Connections are bounded per uid and in total; a share of the total is reserved for administrators and for callers in a logind session, so services with dynamic uids cannot exhaust it. Work the socket can cause before a proposal is authorized (§12 step 2) is bounded the same way: one build slot per caller class, charged to the caller.
Frontends. Planarian exposes one interface, its socket API. The CLI is always present. The console is one optional frontend, shipped in the personal profile; any other frontend (a web dashboard, a TUI, an agent harness, a fleet console) uses the same API and gets the same identity derivation and policy checks. No frontend is privileged.
iced; COSMIC reference desktop; autostart as a user service on graphical-session.target, minimized unless there is an open incident, a pending approval, a reconciliation record, or the last boot reverted. Views: Graph (desired vs observed vs health per node: three small indicators, not one color; edge style by kind; overridden nodes hatched; Unknown nodes grey with a stale-clock mark), Timeline (transactions with journal states and outcomes), Generations (current, previous, known_good, live_tested, candidate; boot-confirmed marker), Incidents (state, attempts, cooldown). Buttons in A: undo, override/release (Runtime, user-actor). No approval in A. Reads only the daemon socket.
§17. Pinned versions and conventions
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
NixOS 26.05 (nixos-26.05 channel pinned to a commit in flake.lock; recorded in docs/pins.md; moves only by PR; 26.11 waits for Phase C). COSMIC from the channel module. Rust stable pinned. zbus, serde+JSON. No unsafe outside backends/ and watchdog/. License MPL-2.0. CI: tests, gym Phase A catalog, dependency-boundary check, Verus proofs, Kani harnesses.
§17a. Verification plan and trusted assumptions
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Proof targets, in order: Phase A — schema::digest; policy::decide (purity; I5; I11; I14 in the sense that decide has no Proposal input; probe-tamper → AskFirst or Reject; I16 in decide and revert-target selection; I19: no token whose approver is the proposer, no self-grant); txn kernel (I6, including Built-waiting and AwaitingBoot; I12: every journaled transaction reaches a terminal state under the modeled outcomes; write-ahead ordering). Added 2026-10-29: the journal’s hash chain (I18: every record written commits to its predecessor’s hash, and the check on read reports the first break), in txn under Verus in A, trusting only SHA-256; the residue classifier (§11: which unit endings are a person’s stop and which are faults) extracted as a pure function with Kani harnesses in A, Verus with exec in C; I17 (user_authorization gives root the administrator capability, so the set is never empty while root exists) in policy in A. exec orchestration gets Kani harnesses and proptest in A, Verus in C, together with I13 (the incident planner never targets an overridden unit). Diagnoser soundness and scorer in B. Broker in C. Switch/rollback and supervisor in D (store integrity proven only for content-addressed paths; input-addressed paths are verified by signature and NAR hash, not by content). OSTD, model channel, core update path in E.
docs/assumptions.md lists every externally specified behavior the proofs rely on, maintained as Verus external_body/assume sites are added. Phase A entries: backend Outcome is honest (Succeeded means applied; Unknown means the backend cannot say); activate_live(revert_to) restores the activation state of revert_to; publish_boot and the promotion rename are idempotent; the journal’s fsync is durable and the preallocation is writable; systemd reports unit state truthfully over D-Bus and honors X-RestartIfChanged/X-StopIfChanged; fencing is effective (a stale-epoch call is refused before it mutates); the kernel and systemd are correct. Re-observation after a backend call detects divergence from the assumed postcondition; it does not undo an irreversible effect, which is why the NonReversible floor exists.
Progress assumptions for I12 (liveness is conditional on these, stated in the proof): some lease holder (daemon or watchdog) eventually runs; a fenced worker eventually stops issuing calls or its calls are refused; journal writes eventually succeed (guaranteed by the preallocation for recovery records); backend calls terminate. Under those, every transaction reaches a terminal state; with them violated the proof says nothing, and RecoveryFailed plus alerting is the designed fallback.
Verification ratio. The trusted computing base is 𝒱 ∪ 𝒯: 𝒱, the proven base, is what is verified (schema, policy, txn, and later crates as they enter the Verus subset); 𝒯, the trusted base, is what is trusted but unproven (backends, Nix, systemd, the kernel, firmware), with every trust recorded in docs/assumptions.md. 𝒯 is accounted in three parts. Platform trust (P, G): what the machine underneath does. Proof-validity trust (V, plus the specifications the proofs prove against, Verus, Z3, and rustc): what makes a proof mean what it says. Internal unproven code (I-ids: exec::derive, user_authorization and the admin lookup, the residue classifier, journal I/O, the identity resolution of §10): Planarian’s own code that the proven gates take input from. Internal unproven code is not an assumption about the world; it is work not yet proved, so it is reported as its own number, the internal-unproven count, never folded into the trust count, and it must not grow and is expected to fall from C. Every proved invariant also has at least one gym entry written from the spec’s English rather than from its formal specification, since a proof certifies a wrong specification as readily as a right one. Every gate reports three numbers: the trust count (the count of platform and proof-validity entries in assumptions.md, including external_body and assume sites), the internal-unproven count, and the ratio d:a, discharged (d) to assumed (a) obligations, per invariant (“I12 rests on 3 discharged lemmas and 5 assumptions”) and summed globally. GA reports both as the baseline. Every later gate requires that neither the global ratio nor any per-invariant ratio degrades and that the trust count does not grow. The v4.5 re-accounting (proof-validity entries added, internal code moved out of the trust count) sets a new baseline once, recorded with its reason; it is not a regression. D and E are expected to raise the ratio substantially; the gate requires only that it not fall, so that no one improves it with proofs that discharge nothing load-bearing.
The accounting is itself checked. planarian-gym verification rejects a duplicate obligation id; requires every invariant in the baseline to be present in the current accounting (a removed invariant is a failure, not an absence); and ties each discharged obligation to a proof artifact the verifier actually produced (the Verus check’s output, or a reviewed manifest naming the proof function), not to the textual presence of a function name. Gym mutations: delete an invariant, duplicate an obligation, rename a proof function; each fails the gate.
Status of the Phase A proof targets at v4.6. Proved: policy::decide (I5, I11, I14-decide, I16-decide, I19, I21, I22), I17 (policy::i17_administrators_never_empty), the txn kernel (I6, I12 conditional, I15, I23, write-ahead ordering), the hash chain (I18). Checked by Kani harnesses: the residue classifier (a Security unit’s stop is never inferred as intended, a crash is always a fault, residue is inferred only where the posture allows it; crates/exec/src/kani.rs). Property-tested, not proved (deviations): schema::digest. Every target is counted in docs/verification-ratio.toml, and CI fails when a count differs.
§17b. Bypass register
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
docs/bypasses.md lists every known route by which the machine can reach a state this spec says cannot happen, or by which a stated guarantee stops describing the real machine, whether or not a proof is involved. One row per route: id (B for open, X for accepted; never reused), route, what it breaks (always invoked, tamperproof, or correct), severity (High: defeats a stated guarantee in the shipped configuration without root; Medium: needs root, a network position, or a likely deployment, or the spec overstates the mechanism; Low: narrow, availability-only, or process; Accepted: outside the claim by design), status, mitigation, phase, and the invariants it limits (all, or — for none; §17c). Any change that adds a path to the machine (a request type, backend call, hook, or posture option) adds a row or states in its PR that it opens none. planarian-gym verification --gate checks the file’s format and fails a gate with an open High row. The file has three tables — Open, Closed (status Closed (<sha>), kept forever), and Accepted — and a row moves between them when its status changes; the format check rejects a Closed row in the Open table. Rows added at v4.6: B-20 (High, Open: agent-name collision rebinds a running agent’s authority; closed by I24); X-12 (Accepted: an override-file writer that does not take the daemon’s lock).
Verus subset applies to schema, policy, txn only. exec, diagnose, incident, state keep the no-dyn and no-unsafe rules so they can be brought in later; intent, console, daemon, cli, gym are unconstrained.
§17c. Guarantees site
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Planarian publishes a generated documentation site organized around its guarantees, as docs.rs is organized around a crate’s API: every page is computed from the repository at one commit, never written by hand, and every claim links down to the code, proofs, tests and limits that carry it. It exists so an auditor can start from “what does this system promise?” and reach the exact lines that keep the promise, at the exact commit under audit.
Sources. The site is built only from files the repository already maintains: the invariant table (§7), docs/verification-ratio.toml (obligations and assumptions per invariant), docs/assumptions.md, docs/bypasses.md, the gym catalog (each entry’s spec and invariants fields) and its latest run results, docs/spec-history.md, and the source tree (to locate each obligation by name). Nothing on the site is entered anywhere else.
Pages.
- One page per invariant: the statement verbatim from §7; proof phase, status and d:a against the baseline; each discharged obligation with its kind (verified fn, proof fn, Kani harness), what it guarantees, and a permalink to its line at the commit; the assumptions it rests on, each with its text, its kind (platform, proof-validity, internal unproven) and the other invariants that rest on it; the global proof-validity assumptions that apply to every proof; the gym entries that exercise it, with harness, expectation and the verdict from the run at that commit; the bypass rows that limit it; and its history across spec versions.
- Reverse indexes: per assumption (which invariants fall if it is false); per bypass (which guarantees it narrows); per source file (which invariants and obligations live there); per gym entry (what it exercises).
- The spec itself, one page per section, with cross-references as links.
- API docs:
cargo docoutput for the workspace, linked from invariant pages for the types they concern. Proof obligations link to source lines, not rustdoc, because rustdoc rendersverus!blocks poorly.
Versions. A snapshot is built for every tagged release and for the head of main. Tagged snapshots are immutable and kept; main is rebuilt on every push. Every page names its commit and spec version in its first line and links to the same page in other snapshots.
The build is the check. The generator fails the build on any reference it cannot resolve: an obligation name with no matching function, an assumption or bypass id with no entry, an invariant with no page, a gym entry naming an invariant that does not exist, a bypass row with no invariants column. CI runs it on every push, so a docs failure is a build failure; a gate claim (§18 rule 19) may cite the site’s snapshot at the gate commit.
Where it lives. The generator is a subcommand of planarian-gym (planarian-gym site), beside verification, which already parses these files and checks the same names. The site is an mdBook published to GitHub Pages from CI. It is generated output and is never committed to the repository.
§18. Instructions to the coding agent (AGENTS.md)
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
- First deliverable, before any other code: the Phase A fault catalog (
crates/gym/catalog/phase-a/:00-format.tomland one<family>.tomlper §15 family, read in filename order as one document), one entry per §15 family at minimum, each withinject,required_post,intent,family; stop for owner review. - Second deliverable: the GA demonstration script (
gym/demo.sh): fault → repair; bad config → revert; killplanariadduringActivating; watchdog reverts; restart daemon; reconciliation record printed. Build toward making it pass; everything else is in service of it. - Implement Phase A only; traits for B–E with one implementation.
- Build order (historical; every crate exists at v4.6):
schema→txn(with the journal and thefakebackends) →policy→state+backends/linux(systemd, logind, sysfs) →exec+backends/linux(nixos-rebuild) →watchdog→changes→incident→diagnose→gym→intent→cli→daemon→console. The console Graph view may be built as soon asstateworks. - Preserve however the schema evolves: I5, I6, I10–I25;
digest()excludes resources and timestamps;Proposalis the only write path; policy facts derived by the executor; gym scores onrequired_postand intent cases, never on absence of errors; diagnoser before any model; probes with side effects other thanNoneor a boundedMeasurementnever on a cadence. - Do not resolve §19 items;
TODO(owner)with the stated default. - No language model, LLM API, or prompt anywhere in Phase A.
- Where NixOS and this spec disagree on mechanism, follow NixOS and record in
docs/deviations.md. - Every executor outcome must be reproducible from the log: proposal, pre/post state,
DerivedFacts,PolicyVersion, probe evidence, decision, outcome. schema,policy,txnin the Verus subset; landpolicyproofs before startingexec.- The Asterinas and Firecracker gym targets may stay red in A. Classify failures; fix only
Core. - Every new
assume/external_bodyadds a line todocs/assumptions.mdin the same PR. - The fake backend used by the transaction kernel MUST satisfy every entry in
docs/assumptions.md, not only the trait signatures: it is a second real implementation, not a mock. The gym runs the kernel against it under injectedPartial,Unknown, and fenced outcomes. - The journal is hash-chained from the first commit that writes it (I18).
- Never implement Planarian’s response to its own failure inside
planariad; it lives innix/posture.nixas systemd and initrd units (§12d). - Report the invisibility budget (§21) and the verification ratio (§17a) from the first gym run; treat any degradation as a failing test.
docs/future-work.mdis non-normative. Cite it; do not implement from it. If anything there seems to block Phase A, stop and ask.- Phase A was held open for v4.4: the schema-affecting changes (
Override, theActorshape, the security floor, the hash-chained journal) land before the Phase A proofs are written, not as migrations after them. - Keep
docs/bypasses.mdcurrent (§17b): a PR that adds a path to the machine adds a row or says it opens none. - v4.6 handoff, in this order. (a) Reconcile: the close-out review in the project (“Phase A close-out review, head 805cd1c”) lists every finding with its status at that head; before touching anything, confirm each item you take on is still open at your head and record which commit closed the ones that aren’t. (b) B-20 first (I24; §11, §16): register the row, fix, add
agent-name-collisionto both harnesses. (c) The documentation sweep: every doc agrees with head and with this spec before any further status is cited (ga-status.md’s summary;guarantees.md’s accounting;bypasses.md’s three tables;AGENTS.mdmirroring §18 rules 0–23;catalog-review.md’s counts and a v4.6 section; the catalog’s header (phase-a/00-format.toml); the deviations rows §28 names). (d) The §28 required changes, one PR each, each with its §15 acceptance case landing in the same PR. (e) §15’s harness contract and §17a’s accounting checks. (f) Then, and only then, run the whole VM catalog on one commit and updatega-status.mdfrom that run; a gate claim cites one commit and one run, never a union of runs. - Equivalent effects, equivalent treatment. When two spellings of a request, two paths to a commit, or two sources of a proposal produce the same effect on the machine, they get the same derived facts, the same floors, and the same bookkeeping (§11, I25). A PR that adds a spelling, a path or a source states which existing one it is equivalent to and shows the gym case that holds them equal.
- Nothing is derived from a failed tool. A backend or helper that exits nonzero, times out, or returns truncated output never contributes a smaller plan, a
Nonedisruption, or a passing verdict; the step fails or the change parks (§9 step 3, §15 harness contract). - The guarantees site (§17c) builds on every push. A change that renames an obligation, adds an invariant or assumption, or adds a bypass row makes the site resolve in the same PR.
- Property tests for logic, regression tests for bugs (owner decision, Nov 3). A PR that changes a pure fold, parser, classifier, ordering or state machine carries a property test (proptest, or a Kani harness where one fits) stating the invariant it changes, not only an example input, and shows that the test fails without the change. A PR that fixes a bug adds a test that fails on the unfixed code and a row to
docs/regression-ledger.md(bug, fix, test, kind, evidence it fails on revert); a bug that only a kernel or a VM can show cites its VM test there. Nix wiring, documentation and fixtures need none and say so.
§19. Open questions (owner decides)
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
- GA threshold 60%; NixOS 26.05; MPL-2.0.
- All of Future Work v0.2 §1 accepted into v4.4; schema-affecting items land in Phase A (Oct 28).
- Invisibility budget targets: idle CPU < 0.5% of one core; resident memory < 50 MB excluding any model; no statistically significant frame-time or input-latency difference with Planarian running vs. stopped. Reference workload: one GPU-heavy game under Proton; the agent proposes the title and a scripted benchmark for owner sign-off.
- Probe kinds: typed kinds by default; administrator-authored
Execprobes allowed, flagged. - Hash-chained journal is a Phase A requirement.
- Unattributed-stop residue: accepted with an undo under Relaxed/Standard; ask window under Guarded/Sealed.
- Multi-user system-scope overrides: AskFirst for non-administrators when another uid’s session is online.
- Sandbox pass required for Tested where KVM exists, floored to AskFirst where not; Firecracker gym target non-gating from A.
- Installer selects the posture; boot-ready timeout configurable (60 s default, 15–600 s).
- Phase B grants to non-human principals: catalog and model-catalog authority only; merge authority requires two distinct approvers.
- Learned
Diagnosticedges are never admitted without a passing gym case. - Spec vulnerability review (Oct 29) merged as v4.5: a launcher for agents; the floor from the whole closure; no request field decides authority; trust accounting in three parts; I2, I14, I16, I18 qualified; the bypass register as a gate.
- Repairs run while an admitted boot-only change awaits its confirmation; a canonical repair in that window means it is not confirmed (Oct 31; I6, §10).
- Guarantees site: generated per commit and per tag, organized around invariants, published to GitHub Pages; never hand-edited (Oct 31; §17c).
- Rollbacks in activation scheduling: user-requested runs at once; diagnoser rollback after failed repair is a repair, floors and quiet mode still apply (Oct 31; §12f).
- Agents act with their user’s permissions, attributed per instance (Oct 30; §11, I24). GC stays Runtime with
retainAllGenerations(Oct 30; I21, §12b). - Close-out review (Oct 30) merged as v4.6. Decided: boot-only fallback is the publishing (running) generation, never
known_goodautomatically (§12 step 7); parking inBuiltis bounded at 24 h,AwaitingBootis unbounded and reported as waiting (§12f, I12); an external dependency needs a traversable edge, and the module rejects weaker ones (§10); approval tokens die with the daemon (§12 step 4, §12h). Taken as defaults, overturnable here: boot confirmation has two milestones, admission and the 10-minute confirmation (§10); only seated sessions carry a graphical expectation (§10); §6 describes the repository as it is. -
assets/identity/source/prompts.mdrecords that the raster master was generated with an image model. Keep the provenance note in the public repository, or remove it? And is the identity kit under the repository’s MPL-2.0 or reserved? (Not a spec matter; recorded here so it is decided.) - Rule 5 (
TODO(owner)markers) has never been used: no marker exists in the tree. Either every default below was resolved (then say so in AGENTS.md and retire the rule) or the convention should start being followed. - Provisional, revisit at each review:
inferStops(§11) stands in for a unit-importance property. Revisit if importance should also affect daemon-proposed stops, quiet-mode deferral, diagnoser ordering, or the console. If it should, addimportanceas its own field, not aSensitivity: sensitivity is the risk of changing a unit, importance the harm of its absence. - Catalog coverage threshold for the personal-profile gate: set after the first catalog survey.
- Choice of the reference game and benchmark sequence for the invisibility budget.
- Phase B threshold Y.
- Six observed states plus Unknown/Transitioning — revisit after the A catalog.
- Phase C snapshot backend: btrfs only, or btrfs + ZFS?
- Nix vs Nickel for the Phase D evaluator.
- Boot model family/size (default ~4B, 4-bit, CPU, 8 GB floor).
- Reference laptop (E). Fork Asterinas NixOS or parallel distribution (E).
- Should
Diagnosticedges be learnable from incident history in C, or stay manifest-only?
§20. Later phases — design notes carried forward
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Not Phase A work; kept so the design is not lost.
Model service (B). The model is treated as an adversary: its judgement is never trusted and is not a vector, because any proposal it can make is one an attacker with the same socket could make, and every proposal passes the same checks. What must hold is containment (I9). Until I9 is proved in E, it is held by the unit’s sandbox, which is normative: DynamicUser=; no capabilities; ProtectSystem=strict, ProtectHome=yes, PrivateTmp=yes, read-only access to the model files and the report socket; RestrictAddressFamilies=AF_UNIX, widened only for a declared remote endpoint (future-work §3); a SystemCallFilter= allowlist; device access limited to the GPU’s render node when GPU inference is enabled; the proposal socket as its only write path. Gym case (B): from inside the service, attempt every other write path (the journal, the override file, the store, another unit’s socket, a capability-changing syscall); each fails and none leaves a trace outside the service. Unprivileged systemd service; reads reports over a Unix socket; emits Proposals. Boot model ~4B at 4-bit on CPU via mistral.rs or candle; RAM floor 8 GB. Residency is declared like any other unit: unit:model-service desired Resident when All[Not(Foreground), memory free ≥ model size + headroom], otherwise Unloaded. Loads lazily when an incident escalates to it (zero steady-state memory; 5–15 s cold start); unloads after a declared idle period (default 10 min) and immediately on GPU or memory pressure from any foreground principal: the user’s session always outranks agent inference. If an escalation arrives while loading is not allowed, it falls back in order to the deterministic path, CPU inference where available outside quiet mode, a configured remote endpoint (future-work §3) within its disclosure profile, or deferral until the condition clears. It never loads over a foreground app. keep_warm offered at ≥ 16 GB outside quiet mode. Summoned only on DiagnoserExhausted or intent < 0.5. Tools: inventory, health report (including attempts and the incident), dependency graph, config read, local manual search, ask-user, propose (cataloged changes or new catalog entries for human review; never raw Nix). All text inputs tagged untrusted. Training: distillation in the gym, scored on diagnoser-failed and held-out faults. Model planner: largest catalog model that fits measured resources; installed as a Tested generation; switched only if it beats the currently active model on repair accuracy; falls back to the boot model on resource loss. Catalog models hash-pinned with training manifest and eval results.
Approval broker and secure attention (C, E). Separate process minting ApprovalTokens for any principal holding approval capability (§11), never for its own proposals. C: its own socket and a confirmation surface the compositor cannot draw over. E: a kernel-intercepted secure attention key hands the display to the broker.
Remote principals, apps, fleets (future). The endpoint catalog and relay for remote agents, app capability manifests, fleet membership (Raft and BFT behind one interface, BFT the intended default), attestation, and gossip of shared knowledge are directions in docs/future-work.md §3–§5. The hooks in §9 (signed external desired state) and §10 (signed health report), and the reserved Actor::Agent, exist so they can arrive without reopening the proven crates.
Rust system layer (D). Snix (git.snix.dev/snix/snix; crates GPL-3.0, protobuf definitions MIT) as the GenerationBackend, spoken to over its MIT protocols from a separate process. Rust PID 1 plus supervisor as UnitBackend, driven by Requires edges with Wants preserved as weak. Rust CoW filesystem as SnapshotBackend. relibc for Rust std (vendors openlibm, C; a named exception). Store integrity is proven for content-addressed paths and signature-and-NAR-hash-checked for input-addressed ones. Backend contracts are revised at the start of D from docs/assumptions.md and docs/deviations.md.
Asterinas (E). Fork at a pinned release (0.19.0 at writing). Driver priority for a reference laptop: NVMe, input, display → Wi-Fi, Ethernet, USB → audio, power, suspend → GPU modesetting → GPU 3D/compute (port Nova or Tyr) → Bluetooth, webcam, fingerprint. Firmware blobs allowed. Desktop: Smithay compositor with Rust software renderer; iced UI; COSMIC later. Verification: OSTD (vostd), executor, policy, diagnoser, broker, switch/rollback, store integrity, model channel, core update path. Core image: signed bundle installed only with project signature plus secure attention; previous core stays bootable.
Trust base accepted as unproven at every phase: firmware, bootloader, Rust compiler, CPU; plus the Linux kernel and systemd in A–D.
§21. Expected resource profile
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
| Situation | Cost |
|---|---|
| Idle | Single-digit MB resident, < 0.5% of one core (the invisibility budget below); watchdog sleeps on a 10 s timer |
| Invisibility budget (normative, ratchet) | Idle CPU < 0.5% of one core; resident memory < 50 MB excluding any model; wakeups per second at idle reported; input-to-display latency and p99 frame time on the reference game workload with no statistically significant difference vs. Planarian stopped; notifications per user-week reported, and zero for self-resolved faults the user could not perceive |
| Fault → detection | One tick plus probe timeout (5–30 s); up to one cadence period for hardware probes |
| Fault → deterministic fix | 10–60 s per attempt plus settle; defer-to-systemd adds up to RestartSec + settle |
| Fault → model-assisted fix (B) | 1–3 min, plus cold start if not warm |
| Config change (Tested) | 30 s to several minutes, dominated by Nix evaluation and builds; plus the stage copy (seconds) |
| Runtime-settable option | Immediate; write-back follows in the background |
| Journal | One fsync per state change, roughly ten per generation transaction; 64 MB preallocated |
| Disk | Bounded by keep-N and space budgets; BudgetUnsatisfiable reported if the protected set alone exceeds them |
| Executor log | Tens of KB/day in steady state; rotated at 50 MB, kept 30 days |
§22. Changes from v3
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
- Restored principles (now 12) and invariants (I1–I14) that v3 referenced but omitted.
- Desired/observed/health separated; conditions (
AfterLogin,DeviceConnected, power); suppressions;Unknown,Transitioning, stale evidence; user-scoped units. Fixes the login-screen and user-stopped-service faults. - Edges typed (
Ordering/Requires/Diagnostic);After=no longer implies dependency. - Probe contract: sandbox, limits, side effects; disruptive probes OnDemand only;
establishesfield. - Sensitivity composable;
DisruptionandRecoveryas separate properties;Decision::Rejectseparate from tiers;max_tiercan no longer make a forbidden action approvable. - Policy facts derived by the executor from the evaluated generation (I14);
OptionChange.sensitivityremoved from proposals; refinement check and executor-owned requirements made explicit. - Approval bound to the concrete candidate, base, policy version, cumulative hash, derived facts; re-validated at execution; single-use.
- Transaction kernel with write-ahead journal,
Partial/Unknownoutcomes, recovery watchdog as a separate unit, daemon restart reconciliation, self-protection during activation;live_testedvs boot-confirmedknown_good. - Change catalog with handlers replaces free-form
ConfigDiffin proposals; override file promoted only on commit; user config pinned per transaction. - Incident lifecycle: defer to systemd, backoff, durable budgets, regression and flap handling, cooldowns, escalation.
- Gym: lifecycle and intent families; unnecessary-intervention and disruption metrics; held-out families for B; GA includes the kill-mid-activation demonstration.
- Verification narrowed to
schema/policy/txnwithdocs/assumptions.md; re-observation claim corrected; store-integrity claim corrected for input-addressed paths. - GC: in-flight candidates protected; space budgets.
mark-goodauthorization; log record contents;dyn/generics contradiction resolved.- Phase A scope kept full and Asterinas target kept non-gating, per owner decision.
§22–27. Earlier changes
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
The changes from v3 through v4.4 (§22–§27) are in docs/spec-history.md, with their section numbers unchanged. Only the latest revision’s changes stay in this file.
§23. Changes from v4
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
- §12: exclusive transaction ownership with lease epochs; worker fencing via pidfd and job draining; the watchdog may not commit before
Published; the daemon waits for a watchdog-held lease; I15 added; gym case for overlapping owners. - §9/§12: immutable staged source (NAR-hashed) evaluated with pure-eval; build before approval;
verify_candidatebefore activation; the token binds expectation, revert target, and recovery strategy;activate_liveandpublish_bootseparated;Published/Promotedjournaled; atomic-rename-plus-dir-fsync promotion; watchdog revert restores the override source; crash-recovery table. - §8/§9/§10:
ProbeRoleReadiness vs Diagnostic; side-effect probes are Diagnostic; status derivation puts definiteFailedabove Unknown; probe applicability follows the candidate’s authorized desired state, distinct from definition tampering. - §8/§9:
Condition::SessionEstablishedfrom logind replaces the circularAfterLogin; the session probe is the result, not the prerequisite. - §8/§12/§14:
StopAndSuppresscomposite; suppression journaled before stop; no post-hoc prompt; CLIStopUnitrewritten. - §11:
SO_PEERCREDauthentication; authorization rule separate from risk; actor fields overwritten; global floors for NonReversible and disruption apply to every transition kind. - §8/§9/§13:
Wantsedge kind preserved as weak; SCC-based root-causing for cycles. - §10/§12c: per-filesystem storage units; preallocated journal; ENOSPC behavior by state;
CollectGarbage { emergency };BudgetUnsatisfiable. - §12: read-only vs mutating backend calls;
Preparedon the Allow path; per-transitionRecoveryStrategy; Runtime outcomes never trigger generation reversion;restartIfChanged/stopIfChangedas the real self-protection mechanism. - §16:
accepted_baselinedistinct from boot-confirmedknown_good; initial enrollment; operational limits made normative. - §17a: progress assumptions stated for I12; fencing and idempotence added to the trusted-assumptions list.
- §12a, §14, §20, §21 made self-contained.
§24. Changes from v4.1
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
- §12: takeover requires confirmed quiescence (jobs cancelled and gone, workers reaped); a drain timeout ends in
RecoveryFailed, never in proceeding; lease transfer is atomic viaflockplus rename; the watchdog triggers onPublished/Promotedtoo and, fromChecking, completes the whole authorized commit path or reverts. - §8/§12: per-transition
HandlerandRecoveryStrategyindependent of tier;Rollbackis a generation operation even at Runtime tier;BindDriveris a device operation withUnbindDevicerecovery; the crash-recovery table branches on strategy. - §9/§12b: transaction-specific
base.nixwith content-hash identities; promotion and restoration idempotent by hash comparison; no shared.prev; GC root created atBuiltand kept through approval; dirty-Git staging defined by Nix-visible bytes. - §12c: length-prefixed CRC’d records with torn-tail handling; checkpoint-then-truncate compaction preserving all live state; filesystem contract for ext4/XFS/btrfs/ZFS with a sentinel fallback.
- §8/§10:
SideEffects::Measurementpermits bounded network/local I/O for Readiness probes;RemoteStateChangestays Diagnostic; external dependencies are units with edges.
§25. Changes from v4.2
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
- §9:
base_source(owner source with base override) separated fromstaged_source(candidate tree); stale check compares the working tree tobase_sourceonly; both bound to the token; Git-revision wording removed; step numbering fixed. - §9/§12:
buildroots its own output undergcroots/planarian/builds/without a lease; the transaction adopts the root; orphan roots cleaned at startup. - §12: durable
OpIdjournaled before every mutating call and tagged onto the backend job; quiescence covers jobs discovered byjobs_taggedfor unacknowledged ops; a backend that cannot say →RecoveryFailed. - §12/§12c:
RecoveryFailedsets a persisted recovery interlock carried in the checkpoint; cleared only by administratorplanarian resolve; crash table row forChecking, ReobserveOnly. - Consistency:
ProbeRole, canonical-probe paragraph, and agent instruction 4 all carry theMeasurementexception; I12’s terminal set includesRejectedandCancelled. - I4 reworded to applicable Readiness probes; atomic transaction creation with initial lease;
Rejected/Cancelledterminal states; incident closure on the desired predicate;SessionEstablishedscoped touser-class sessions and a specific session id; intent-case scoring scoped to autonomous action.
§26. Changes from v4.3
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Incorporates the owner-approved conceptual review (Future Work v0.2 §1.1–§1.11 and the decisions in its §13).
- §7: principles 13–15 (reference monitor and species-blind principals; invisible under normal use; failure response enforced outside the monitor); principle 9 generalized to attributed overrides. Invariants I16 (security floor), I17 (administrator set non-empty), I18 (hash-chained journal), I19 (no self-approval, no self-escalation), I20 (no agent without the monitor).
- §8:
Suppressiongeneralized toOverride { desired: LeaveAlone | Stopped | Absent | Detected, attribution };Suppress/UnsuppressbecomeSetOverride/LiftOverride;UnitStatus::Overridden.Actor::Usercarries session, seat, and active;Actor::Agentreserved.Condition::Foreground.security_floorinSystemState;below_security_floorandother_sessions_affectedinDerivedFacts. Typed probe kindsDBus,FilePresent;Execadministrator-only.Posture,FailureClass.RejectReasongains theUnauthorized,Interrupted, andNoSpacevariants v4.3 used but did not declare. - §9: probe-kind rule; signed external desired-state source (hook); catalog coverage as a gym metric;
NotInCatalogby phase; graphical-session readiness bound to the session’s compositor; learned edges need a gym case. New §9a: PlanarianOS as a distribution with three profiles, per-profile gates, accounts, live mode. - §10: foreground signals; user services desired while any session of the uid exists; actor resolution from logind; boot report; signed health report (hook) with a core-computed disclosure profile.
- §11: principals and capabilities; Phase B non-human grants; overrides on Security units need an administrator; per-session “own session”; multi-user and security-floor floors; approval as a capability with no self-approval; deliberate actions carry identity; the unattributed residue by posture.
- §12: activation window and sandbox check on the generation path. New §12d (boot ordering,
planarian-ready.target, failure classes, responses, four postures, configurable timeout, boot-chain locking), §12e (security floor), §12f (activation scheduling and quiet mode), §12g (sandbox tier, Phase B). §12c: hash-chained records and chain head in the checkpoint. - §13–§14: overrides; quiet-mode deferral; attributed stop phrasings.
- §15: v4.4 acceptance cases; malicious-declaration family; invisibility, coverage, AskFirst-rate, and verification metrics as ratchets; Firecracker target non-gating from A.
- §16: CLI additions; frontends section (the console is one optional frontend).
- §17a: the verification ratio d:a and the trust count reported at every gate. Phase A proof targets add I16 (in
decideand target selection) and I19. - §18: agent instructions 10–17; rule 4’s preserve list extended to I10–I20.
- §4, §5, §6, §19, §20, §21: roadmap rows (Phase C is PlanarianOS), scope, layout, decisions, model residency, broker and future directions, invisibility budget. §20’s idle unload stays at 10 min, now declared rather than fixed.
- §1–§3: overview prose, the diagram, and feature-table rows updated to overrides, attributed actions, the security floor, failure posture, quiet mode, and the sandbox tier.
- Consistency pass after review:
Decision::Allowcarries anApproverClass;ApprovalTokenbinds its approver;PrincipalId,Capability,Grant, andAuthorizationdeclared;Actor::Unattributedfor inferred overrides, which never apply to Security-sensitive units;EscalationReason(includingRevertBlockedByFloor); a boot-only generation path with theAwaitingBootstate; I1 and I6 clarified against the floor and waiting transactions; the v4-review Security-override case changed from rejected to held for administrator approval; CLI verbsoverride/releasereplacesuppress/unsuppress; §12c’s checkpoint also carries overrides and the security floor.
§27. Changes from v4.4
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Incorporates the spec vulnerability review of Oct 29 (findings B-1–B-18 against commit 1bc09b2).
- §5, §6, §7, §17b, §18: the bypass register
docs/bypasses.md; principle 16 (name every way around); agent rule 18; no gate passes with an open High row (B-14 and the review’s appendix). - §7: principle 6 and I14 extended from proposals to every request field (B-3); I2 states what the A–D core hash detects (B-12); I16 states the inspected set (B-2); I18 states whom the chain holds against (B-8).
- §10, §11: identity from a pidfd, never a bare pid (B-6); a caller property that matters is established by the daemon (B-3); agents are their own principals, started by
planarian agent runwithDynamicUser=(B-1); approval’s terminal established by the daemon (B-3);inferStops = falseexempts units from residue inference, and inferred overrides stay listed until acknowledged (B-11; the review’sCriticalsensitivity became this manifest option, so no proved type changes); sensitivity completeness with fail-closed defaults and a gym entry (B-5). - §10: the boot report covers units in A, processes from C, and flags a test build (B-10, B-13).
- §12c: chain head in the signed health report for off-machine witnessing (C), measured-boot anchor (E); the external source’s sequence number in the checkpoint (B-8, B-16).
- §12d: alarms are local in A; a dead-man signal from C; the core hash covers every Planarian binary and script; a test build is an
Integrityfailure (B-9, B-12, B-13). - §12e: the floor is derived from the whole system closure, with a CVE feed from B; gym case for a service-package fix (B-2).
- §12h (new): time on
CLOCK_BOOTTIMEfor everything that does not survive a reboot; NTS recommended (B-7). - §20, I9: the model is treated as an adversary, so its judgement is not a vector; containment is, and the model service’s sandbox is now normative, with a gym case in B (B-18; the review’s accepted row X-9 withdrawn).
- §11, §19:
inferStopsmarked provisional, with the condition for replacing it by animportanceproperty. - §15, §16: test builds named and confined; socket slots reserved for administrators and sessions;
agent run; new metrics (B-13, B-15, B-1). - §17a: 𝒯 accounted as platform trust, proof-validity trust (now including the specifications, Verus, Z3, and rustc), and the internal-unproven count; every proved invariant gets a gym entry written from the English (B-4, B-14).
- Not spec changes:
guarantees.mdsays Kani runs by hand, but CI runs it (B-17; correct the doc).
§28. Changes from v4.5
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Incorporates the Phase A close-out review of Oct 30 against commit 805cd1c: four read-only code audits of Oct 29 (ids C/P/D/K/H in the review document), the third-party handoff of Oct 30 (R01–R16, S01–S07), and the owner decisions of Oct 30. Ids in brackets name the finding each change closes; the review document in the project carries the file:line evidence for each.
New invariants and schema
- I24: agent authority bound per instance;
AgentStarted { name, instance, by };agent runrefuses a name in use; B-20 (High) registered and closed by this [new finding; R05 context]. - I25: post-commit bookkeeping on every commit path; a commit whose floor record cannot be written does not commit [R02, C8].
- I6 and §12 Concurrency carry the same exclusive-state list, now including
CheckingandPublishing;TxnState::exclusiveis that list and the ledger reads it [S07, D11, C20]. - I7: probe evidence per probe and the approver’s identity in the record [C1, C17]. I8: re-validation also when leaving
Built[C2, P1]. I12: parked states bounded or reported [S03]. I20: launched agents inside the rule [R05]. I23: the override CAS is under the daemon’s lock; X-12 for writers outside it [R11]. TxnState::Publishingfor the boot-only path [R01].RejectReason::{Precondition, ActivationUnknown}[C3, R10].
verify_candidatechecks the recorded NAR hash of the store path, not existence; the dry activation must succeed, in time, untruncated [C6/P2, R10].- Promote/restore under
flock;txn/<id>/by exclusivemkdirand deleted after terminal + checkpoint; the content-addressed override store pruned [C5, R11]. - Orphan build roots aged out at startup and before every GC, including crashed builds [C4/P5].
- Stale check, re-derive and re-
decideon every path intoActivating, including fromBuilt[C2]. - Executor-owned post-check per probe against the pre-change set, and over every observed unit [R07, C22].
- Probe
timeoutis a whole-probe deadline; the batch never waits past it; typed probes’ sandboxing deferred to C withbytes_maxenforced for every reading kind [R06, P10]. Slow ticks warned [P9]. - Graphical expectation for seated sessions only;
SessionEstablishedunchanged [S05]. - External dependencies need a traversable edge (Requires or Diagnostic ≥ 0.7); the module rejects weaker ones [S06, P12].
- Boot confirmation as two milestones, admission and confirmation [S02].
- Equivalent effects, equivalent facts:
SetOverride{Stopped|Absent|Detected}carries the effects it causes; existing-generation operations derive from a dry activation of the target and its recorded override content [R03/C11, R04/C7]. - Preconditions checked by the executor for every proposal [C3].
- Agent identity per instance;
Actor::Modelauthorized as an agent, never as the daemon [new finding, C14].
- Admission before work: authorization, catalog membership and the interlock checked before
stage/evaluate/build; prebuild work bounded and charged [R09]. - Tokens die with the daemon; consumed nonces in the checkpoint; approvals recorded with approver and
bound_to; the approved execution logged [S07, C1]. - Boot-only path:
Publishingjournaled beforepublish_boot_once; the publishing generation is the fallback, neverknown_goodautomatically; crash-table row forPublishing;AwaitingBootnever re-prepared [R01, S01]. - Re-prepared transactions keep their original caller and source [C21].
- Post-commit bookkeeping in the kernel’s commit on every path [R02, C8]. Floor derived relative to every earlier generation, so a
Revertsets no floor [C9/H17]. - Worker subprocess wording replaced: a worker thread in A;
Lease.worker_pid/jobsreserved for D (deviations row) [C13/P3]. - §12a write-backs queue behind an active generation transaction [P7]. §12f: parking in
Builtbounded at 24 h;AwaitingBootreported as waiting; deferred repairs are deferred in fact [S03, P8]. §12c: a damaged checkpoint or record isIntegrity, neverAbsent[C16]. - §12d: launched agents in
planarian-agents.slice, ordered and conditioned on readiness, stopped wholesale by the posture, with per-launch and per-uid limits [R05];greetdordered when it is the display manager [P13]; “administrators” means Planarian’s, notwheel[P14];planarian-recoveryhas a start limit and raisesCrashed[P15]; weakening posture/timeout takes effect at the next boot because the posture units read the booted generation [P16].
- Incident control state changes only on an authorized, successful human action [R08].
- The
repair:reapplycatalog entry ships [P22]. - Residue notice teaches the attributed form [P23] (unchanged text in §14; the requirement is restated as a §15 case).
- §15: the v4.6 acceptance cases; the harness contract (runners exit nonzero on
ERROR/FAIL/PARTIALand on unwaivedUNSUPPORTED; CI strict) [R12, R15]; invisibility measured over service cgroups with a real p99 [R13]. - §16:
report boot,postureexist;ack <unit>;mark-good’s TTY from the daemon;agent runrefuses names in use [P17, P18, K10].
- §17a: the accounting is checked (no duplicate ids, baseline invariants required, obligations tied to verifier output) [R14, D1]; the Phase A proof status stated, with the residue Kani harnesses and the I17 proof named as still required [D4].
- §17b: three tables, Closed rows kept; B-20 and X-12 [D13].
- §17c (new, Oct 31): the guarantees site; bypass rows name the invariants they limit; rule 22.
- I6, §10, §12 Concurrency (owner decision, Oct 31): unit and device repairs run during a boot-only
Promoted, and a canonical unit that needed a repair since admission means the change is not confirmed. - §12f Rollbacks (owner decision, Oct 31): a user- or intent-requested rollback runs at once; a diagnoser rollback after failed in-place repair is a repair, exempt from activation windows but not from the disruption floors, and deferred with its incident under quiet mode unless its target is a foreground dependency; the revert of a failing candidate is never deferred. Three §15 cases.
- §18: rules 19–21 (the handoff order; equivalent effects; nothing from a failed tool); rule 4’s preserve list to I25 [D5].
- §19: the Oct 30 decisions recorded; two owner items added [Q7, Q8].
- §6: the layout describes the repository as it is [D14, H14].
Documentation, to be corrected in the sweep (not spec changes): ga-status.md describes the pre-805cd1c agent model and two of three verification numbers; guarantees.md carries the v4.4 trust count and P9; AGENTS.md lacks rules 12–21 and says the console is unbuilt; catalog-review.md counts a 71-entry catalog; phase-a.toml says v4.4; vm.yml and AGENTS.md count 7/62 jobs and 70 runner minutes; assumptions.md P5 names txn::journal::chain; verification.md cites §8 for §7 [D6–D10, D12, H2–H4, R16]. docs/why.md is still to be written; the review’s §5 has notes on the draft.
§Abstract
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
Planarian is a self-healing layer for declaratively configured computers. It keeps three things distinct: what the system is meant to be (desired state, with conditions and user overrides), what it observes (with explicit unknowns and stale evidence), and whether what it observes is healthy (probes with a contract). A divergence between desired and observed state opens an incident. Incidents are repaired deterministically first, by a planner that searches typed transitions over a dependency graph; a small local language model is consulted only when deterministic repair fails or a user’s request is too fuzzy to match. Every change, whatever proposed it, passes through one executor that derives the risk classification itself, runs the change as a durable transaction with a journal that survives the daemon dying, and reverts it if health checks fail. The executor is a reference monitor: always invoked, tamperproof, and small enough to verify. Everything above it is a principal with an identity and a set of things it may request, and none holds authority on its own: not a user, not root acting through Planarian, not the on-device model, not an agent (an agent acts with exactly the permissions of the person who started it, under its own name in the record, and in Phase A can approve nothing, since approval needs a person at a terminal). Each submits typed proposals.
Planarian ships first as a Rust service on NixOS 26.05, where it is useful immediately and gathers real fault data. Its backends (service supervisor, configuration evaluator, snapshot filesystem, kernel) sit behind contracts and are replaced with Rust implementations over later phases, converging on an all-Rust operating system built on the Asterinas kernel. The contract stays the same at every step; what sits underneath it changes.
Part I — For human readers
§Sources
planarian @ a924e09 · Spec v4.6 · snapshot main · all versions
- NixOS manual: what happens during a system switch · systemd.unit: dependencies · Verus: trusted components · Nix glossary: content vs input addressing
- NixOS 26.05 release · COSMIC on NixOS
- Firecracker · microvm.nix · Lanzaboote · Feral GameMode · Anderson, Computer Security Technology Planning Study, ESD-TR-73-51 (1972) · Burgess, “Computer Immunology” (1998)
- Asterinas · paper · 0.19 · vostd · Verus · Snix · relibc · mistral.rs · Nova · Tyr · Smithay · niri · COSMIC 1.10