§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.