ian-operator lib/safety-compose.js Pure · no I/O · no imports of the state layer Folio III of VII

Precedence of restrictions

A gate verdict is not the only thing that can restrain a tick. Taint, a web confinement, a sanctioned retry and a terminal status may all apply at once. This article sets out the order in which they are read, and the rule that decides the outcome when they conflict.

Article III — Of dispositions and their composition

Article III Of dispositions

3.1

A second, purer layer

The gate answers one question about one action. A running tick, however, has several safety inputs at once: what the gate said; whether the intent has already reached a terminal status; whether this tick has read untrusted external content; whether a recovery retry has been sanctioned by the kernel; whether a web body was auto-dispatched.

lib/safety-compose.js folds all of them into one disposition — a frozen object carrying a label, four boolean consequences, the coarse gate feedback and a reason list. The module’s contract is unusually explicit about its own constraints: it is “part of the kernel — deterministic code the brain can never reach,” and it is pure: “no LLM, no I/O, no DB, no imports of operator.js / s5-lib.mjs.”

3.2

The lattice

Five levels. The first two short-circuit; the rest only refine. Restrictions compose by intersection — most restrictive wins — and taint is monotone: it ORs across every input and propagates to everything derived from them.

The five-level precedence lattice of resolveDisposition A context object enters at the top with kernel-minted keys stripped. Level P0 is a hard block on a blocked policy decision or a terminal intent status, producing halt_blocked at rank five. Level P1 is taint from consumed-untrusted content, producing halt_tainted at rank four; both halts forbid escalation and durable memory writes. Level P2 marks a kernel-sanctioned retry, rank two, which lifts only a behavioural penalty. Level P3 confines an auto-dispatched web body to read only, rank three. Level P4 coarsens gate feedback to a verb and a category. A side panel lists the four consequence flags carried by each disposition. resolveDisposition(ctx) reads only: policy, intentStatus, consumedUntrusted, taintInputs, progressRow, webAutoDispatch stripped first, always sanctioned · capability · disposition · allowEscalation · allowMemoryWrite · readOnly → kernel-minted only P0 · HARD-BLOCK policy.decision === 'blocked' (case-folded) OR intentStatus ∈ terminal set short-circuits · a kernel sanction can never lift it halt_blocked rank 5 P1 · TAINT Boolean(consumedUntrusted) OR any truthy entry of taintInputs short-circuits · beats P2 · no escalation, no durable memory fail-closed truthiness — a deny gate must trip on any truthy value halt_tainted rank 4 past here, escalation and durable memory are permitted; rules only refine P3 · WEB CONFINEMENT Boolean(webAutoDispatch) → readOnly: no submit, no send, no purchase over-confinement is the safe side web_readonly rank 3 P2 · SANCTIONED RETRY progressRow.metadata.sanctioned === true, minted by the recovery ladder lifts the repetitiveness penalty only — never a safety gate sanctioned_retry rank 2 P4 · GATE FEEDBACK verb ∈ {INTERRUPT, VERIFY, FALLBACK} + one coarse category never field, amount, recipient, threshold or action hash proceed rank 1 · default FLAGS disposition allowEscalation allowMemoryWrite sanctioned readOnly taint gateFeedback reasons[] Object.freeze on every return halt ⇒ readOnly taint ⇒ readOnly no escalation and no memory write on either Ranks order composeRestrictions; a fold takes the highest-ranked label present, and taint forces the label up to halt_tainted.
Figure 4 — the precedence lattice P0 and P1 short-circuit and are drawn in order of evaluation; P3 outranks P2 by design, so a sanctioned retry that also carries a web dispatch is labelled web_readonly, the more restrictive of the two. lib/safety-compose.js:150–225

3.3

Fail-closed truthiness

One asymmetry in this module is worth reading closely, because it encodes a general principle about how permissions and denials should treat sloppy input.

A grant is tested with strict identity. isSanctionedRetry accepts only === true, and refuses the string 'true'. A denial is tested with coercion: taint trips on Boolean(consumedUntrusted), so the integer 1, the string 'untrusted' or a non-empty object all taint.

lib/safety-compose.js:156–161monotone taint
// FAIL-CLOSED: a deny/safety gate must taint on ANY truthy consumedUntrusted (1, 'untrusted',
// a non-empty object), matching taintInputs' Boolean() truthiness — the OPPOSITE of a grant
// like sanctioned (strict===true). Using === true here let a truthy-but-not-true value from an
// upstream integration silently bypass the P1 halt and WRONGLY allow escalation + memory write.
const taint = Boolean(safe.consumedUntrusted)
  || (Array.isArray(safe.taintInputs) && safe.taintInputs.some(Boolean));

The same asymmetry appears at P0, where the policy decision and intent status are both case-folded before comparison so a non-canonical 'BLOCKED' still halts, and at P3, where any truthy dispatch descriptor confines the body.

3.4

Taint at the source

Taint is not inferred from content; it is derived deterministically from the type of the action a tick produced. One frozen set, single-sourced in the pure kernel module so the staging tick and the verifying executor tick “classify taint identically and can never drift”:

Action types that taint their tick
GroupTypes
Correspondenceemail, send_email, reply, email_reply
Webweb, web_search, browse
Systems of recordcrm, crm_lookup
Inboundtelegram_read, inbound, ingest

Reading the operator’s own wiki or memory is trusted and is not flagged. The consequence of taint is narrow and severe: the tick may not escalate to a higher-capability body, and it may not write durable memory — “a lesson can never be minted from a possibly-poisoned tick.” The perceiving layer applies the same principle at the database: observations default to untrusted = 1, and the MCP tool that writes them has no untrusted field at all, so an external caller cannot un-taint by construction.

3.5

Coarsening: what the model is allowed to learn from a refusal

coarsenGateFeedback takes a policy verdict and returns at most two strings. The granular reasons array is discarded outright.

Coarse categories, matched against the reason text in severity order
MatchedCategory returned
spend · money · purchase · subscription · paidspending requires human approval
irreversible · high-impact · delete · publish · deployirreversible action requires human approval
phone · callphone calls are not autonomous
secret · token · redactsensitive content needs human review
night · recipientrecipient restricted for this shift
sender · disclosurerequired disclosure missing
anything elserequires human approval

The verb is INTERRUPT for a block, VERIFY for a required approval, and FALLBACK for a fallback decision. An allowed verdict returns null — there is nothing to say.

Coarsening is applied at four call sites, not one: the audit detail written with the intent, the gate_feedback field of every pending intent in the context packet, and the failed_reason stored by both the fast tick and the general tick-runner on a gate stop. The last two are pinned by tests that name the exact source lines they defend.

3.6

Composition by intersection

When several dispositions must be folded into one — the intended consumer is a work router that receives more than one input — composeRestrictions applies four rules in a fixed order:

  • taint firstMonotone OR across every input, computed before anything else.
  • label by rankThe highest-ranked disposition label present wins; a tainted fold is forced up to halt_tainted if no input already carried a halt label.
  • bind flags to labelA halted or tainted fold can never escalate or write durable memory regardless of the input flags — a self-inconsistent input cannot smuggle permissions in behind a halt label.
  • conservative ANDEscalation and memory require unanimity; readOnly is a disjunction, and taint or halt imply it.

The third rule is the interesting one. Its comment explains the failure it prevents: without it, “most-restrictive wins” would have picked the halt label but not its meaning, and a malformed or future input could defeat the monotone propagation guarantee. Every return value is frozen, and a test asserts immutability.

3.7

The brain-cannot-assert guarantee

Six key names are declared kernel-minted. Before any field of the context is read, those six are deleted from a shallow copy. The module states the design intent: the brain “MAY try to assert sanctioned / capability / disposition / allow* — they are ALWAYS stripped and re-minted internally.”

This mirrors a rule enforced one layer up. Every runner overwrites the actor field on the model’s proposed arguments unconditionally, before dispatch, with its own fixed identity:

src/runner/fast-tick.mjs:328–332the actor hard-overwrite
// Slice 8.1 security MUST-FIX A: HARD-OVERWRITE the actor UNCONDITIONALLY. A model-supplied
// actor (e.g. forging 'ian' to look like a human approver) is NEVER honored — the runner's
// identity is fixed here and 'operator-fast-tick' matches RUNNER_ACTOR_PATTERN, so it can
// never be treated as a human approver by the gate.
args.actor = { subject: 'operator-fast-tick', name: FAST_MODEL };

Two independent mechanisms therefore have to fail before a model could claim to be a person: the runner would have to forget the overwrite, and the approval function would have to accept a subject matching its own runner pattern.

3.8

Verification of effects, severed from self-report

A related module-level rule governs how a tick’s success is judged. effectActionConsistent binds a declared effect to the action’s real type before any world check happens: a file effect declared for an external send is not merely unverified, it is unbound, and mints nothing.

Checkable effects
Effect typeBound whenVerdict
file, note, draft, researcha path is declared and the action really writes a filesuccess if the file exists; otherwise a verified failure of type tool_execution_error
task_statusan expected status is declared and the action mutates a taskcompared against the status observed in the database, never the model’s claim
external, irreversible, absentneverunknown — never rewarded

The point of the binding is named in the source: it is an anti-Goodhart measure. Without it, a model could declare a trivially satisfiable effect for a difficult action and harvest a success label. The success and failure label is written only by the deterministic verifier; the model’s own outcome column survives for display and is never the learning label.