delegation-kit — safe delegated-agent execution for budget-bounded sessions

Delegated agents are cheap to dispatch and expensive to trust. Three failure surfaces dominate:

  • shared mutable state — two committing agents race the git index, and one sweeps the other’s staged files under the wrong message;
  • interrupted long units — a usage-window wall fires mid-flight, and the uncommitted investigation dies with the session;
  • untrustworthy self-reports — a sub-agent’s “passed” claim, or a gate quietly weakened to make its commit pass.

The kit packages the supervisor-side protocol that closes all three, plus the two mechanizable pieces: a trustworthy budget verdict (usage-verdict) and a commit-shape gate over gate tampering (check-gate-tamper). The consumer supplies its own validate battery, shared-file roster and toolchain checks, which are project rule content and never ship.

The delegation model

One supervisor session dispatches Agent tasks and owns every ruling; agents execute briefs and surface anything the brief does not cover. templates/agent-execution.md owns the load-bearing rules: it is the procedure a session loads, and each bullet’s bold lead-in is the rule’s stable name (§One template, a resident pointer). This section keeps the mechanism contracts and the rationale that earns spec residency: a failure surface, a calibration, a bound that is correctness rather than preference. Each paragraph cites its rule by name rather than restating it.

Boundary — this protocol stays fire-and-forget. An agent here executes a bounded brief and its result returns to the supervisor. A paused session that holds its working state and resumes in place when a question is answered is a different model, resume-in-place orchestration. That model is lifecycle-kit’s lead template (lifecycle-kit/SPEC.md §templates/lead.md), which reuses this protocol’s dispatch safety unchanged.

The template’s Serialize on shared files; ≤DELEGATION_KIT_FAN_WIDTH-wide otherwise rule caps an unlocked fan-out at read-only work. The git index and HEAD are shared by every committing agent whatever its source files, so the cap is a correctness bound. A consumer may never configure it up for committing agents (the knob’s derivation: §Layout and configuration).

The width bound is discipline, surfaced and never enforced. usage-verdict reports it at every dispatch (§usage-verdict, the width=<n> field), and no guard rule counts what is in flight. A PreToolUse payload carries no in-flight set (guard-kit/SPEC.md §The reader and its views records its measured keys), and the harness’s live-children view arrives only at SubagentStop. The transcript is the one reachable record of earlier dispatches, and it does not yet hold a call’s own message while that call runs, in one reading. The attested overrun was three dispatches in one message, so a transcript-counting rule would miss the shape that fired. A guard-written ledger of dispatches would see it, but it makes a verdict surface own state it must expire, which The dispatch guard reaps nothing refuses.

The template’s Background + notification, never poll rule reads as universal but is not: a supervisor’s session outlives its turn, and a dispatched session’s report is the whole of what its caller holds. A stage session that backgrounded its validate suite and ended its turn to report progress lost its child: an entry stamp, no results, the suite re-run from scratch. Nothing reddened, because missing evidence looks like pending evidence. Two phrasings fail. A role-neutral “never end your turn with detached work” forbids the supervisor pattern the same rule prescribes. “Await it in the foreground” names a lever the agent may not have, since dispatches made without requesting backgrounding have been observed detached.

What a turn end does to a dispatched session is the harness’s, and the rule does not rest on it. Two readings exist. Where a harness returns a session’s last message as its report, the turn end ended the session, the loss above. Where a harness delivers the report through a hand-back call of its own, a session that handed back over a live shell task was held for the task’s life and given a new turn on its completion notification. Its dispatcher already held the report, beside a notification calling the result possibly interim (§The turn-end liveness hook, After a delivered report). So “a turn end is the session end” is one harness’s behavior. Under both readings a shell producer wrote on after the turn end, with no reader that would act on its result. That is the unobservable-producer hazard, an unread producer mutating shared files while the next actor moves, arriving through the backgrounding door. And under both the rule rests on one invariant. An agent’s contract with its caller is its return value, so work whose result is not in the return did not happen for the caller.

Honest limit on the two readings. Each is one harness build. Whether an Agent child is reaped with its parent is the harness’s too: the hand-back harness kept one whose parent ended its turn in plain text (An Agent child is the exception, below). A turn ended undelivered over a live shell task, with no turn-end hook wired, is unmeasured on a hand-back harness. Where the hook is wired it refuses that turn end and the session resumes (§Attribution was weighed and is not available), and unwiring it is the consumer’s settings to edit. So the template states the rule for every harness and sends its reader to rely on no session behavior.

A dispatched role waits in-turn, on the work’s own artifact. A session that reads the liveness hazard correctly may still end its turn to await a shell child’s completion notification. No harness promises it the turn that wait needs: one build ended the session there, and another gave the turn only after the report had gone. A rule whose correct application needs a channel the reader lacks defeats careful readers too, so the template names the channel a dispatched role has.

An Agent child is the exception, and its wait is the turn end. Some harnesses deliver a dispatched session’s report through a hand-back call of their own. There, a turn ended without that call does not end the session while an Agent child of it is live. A child that ended its turn in plain text with a grandchild running was sent nothing for the rest of the grandchild’s run, and neither was its dispatcher. The grandchild was not reaped: its hand-back arrived as the child’s next turn, the completion notification inside that turn. The harness words the rule on the notification itself, which fires when an agent stops with no live background children of its own. So the template gives a dispatched session with an Agent child running and nothing else to run that turn end as its wait. It forbids the devices sessions reached for in its place: a marker wait nothing would end, a foreground loop on the child’s worktree.

Honest limit on that wait. It is one reading, on one harness build, at one depth, over an isolated child with one child of its own. A second trial had the grandchild finish during the child’s last reply, and the hand-back queued before the stop arrived as a turn too. That child’s own report denied the turn end its transcript records, so the reading rests on the transcript and the turn-end log. A turn ended undelivered with no child live and nothing queued is unmeasured. A harness that returns a session’s last message as its report has no undelivered turn end, and the template sends that reader to work until the notification arrives.

A wait needs a writer, and a question is no wait. A mechanical-tier session looped on a marker nothing would create; the harness backgrounded the loop at the foreground timeout, and the session read as live past its hand-back for the background limit. The template states it, and the guard refuses the class a launch can decide: guard-kit rule wait_no_producer blocks a backgrounded wait on a file condition while no launch record names a live producer (guard-kit/SPEC.md §The generic ruleset). A condition spelled with a command, and a wait beside some other producer’s record, stay the template’s alone. The never-end-a-turn-to-wait clause also read as covering a wait for the caller’s answer. That answer arrives as a new turn, so asking is a turn end and the template says so.

A notification wakes a supervisor only from after its turn. A notification queued while the supervisor’s turn is still running has landed after a turn that made no further tool call, and started none. The template bounds its claim to that; the lead’s remedy and its honest limit are lifecycle-kit/SPEC.md §The stamp protocol’s.

The wait is named by its property, not its form. The harness offers waiting primitives with opposite reactivity, and a session choosing among named forms chooses by shape. The template names the property a wait must have, ending when its condition goes true, and sorts the forms under it. That discriminator survives a harness adding a third form, which makes it a correctness bound.

Two waitable things, and only one of them has a path. The template splits what a session waits on: an Agent, by its completion notification, and a shell child, by an artifact the session placed. The split is complete. The artifact’s home is the resume journal’s scoping widened, so the survivability grounds are stated once.

The shell child’s artifact is a launch-time liveness record: the child’s PID, captured by the launcher when it backgrounds the child. A completion marker the child touches when done is refused. It answers is it done to a live observer, and the failure case is the one where the observer is gone. A liveness record answers is it still running to whoever arrives next.

The launcher writes the record; per-producer self-claiming is refused. Self-claiming reaches only producers the consumer’s tree owns, where most backgrounded work is an ad-hoc command. It puts the record’s write in the one process whose death is the event being detected. And it bills a program for a property of the launch: the same script backgrounded is in the class and run in the foreground is not. The launcher knows the PID, needs to read it back, and is present when the class is entered.

The liveness clause covers a producer the session started and backgrounded. A session that started a producer was taken to have a better instrument, its own artifact, but that instrument needs a live reader, and losing the reader is the failure. The widening is on the subject alone. A pattern-matched liveness predicate stays refused wherever a recorded PID is available, and so does the bracket trick below.

The record’s grammar is evidence-kit’s, which makes the second reader free. The record has two readers: the launching session, in-turn, as its wait condition, and whoever arrives next, after the launcher is gone. In the one-line pid=<n> run=<key> grammar of the producer-liveness lock, the record is a legal subject for check-producer-liveness unchanged, a reader already specified, fixture-covered and wired. A launch record is not a lock, but the two carry the same field and answer the same question, so a second shape would be a second implementation of a decided thing. How a PID’s liveness is decided stays evidence-kit’s (evidence-kit/SPEC.md §The producer-liveness lock); delegation-kit owns when a session waits and on what.

Only a producer owes a record. A record states that something is mutating shared files. A wait loop and a read-only pipeline are observers and owe none; a producer, a child writing anything a later reader must not race, takes the record. guard-kit draws the same line, exempting an inline wait loop and a read-only pipeline from its launch refusal (guard-kit/SPEC.md §The generic ruleset), and the template states the line rather than pointing at the guard. An obligation over every shell child would oblige a record of the wait loop itself. Where that waiter’s condition reads the record set, its own record makes the condition unsatisfiable. Three readers share the set: the stage-entry preflight, guard-kit rule git_mutation_under_producer, and the turn-end liveness hook. A reader-side repair that ignores a record named for the stage being entered would fix one of the three, and is refused.

A recorded wait asks one authoring question, with no oracle behind it. The exemption sees a wait loop only when spelled inline, and guard-kit accepts that limit: a recorded waiter holds tracked-tree mutation only as long as the producer it waits on already does. That ground fails for a waiter on anything else, such as a stage-entry precondition, where the waiter’s own record is the only thing blocking the condition. So the template asks a recorded wait whether its own record can falsify its condition. The guard reads a wait’s condition at PreToolUse for its form, never for what would falsify it, and a loop that never exits looks like a slow producer.

The record’s name is a convention that makes the set derivable. It is written as <scratch-dir>/<key>.run, the filename’s <key> being the record’s own run= value. Without a convention only the writer knows the path, and whoever arrives next must guess it. The suffix makes the set a glob, read by check-producer-liveness’s set mode (evidence-kit/SPEC.md §check-producer-liveness), the stage-entry preflight behind it, and guard-kit rule git_mutation_under_producer.

.run rather than .lock. A lock is claimed and released by one owner and its absence means free; EVIDENCE_KIT_LOCK_FILE is one. A launch record is a statement of fact left behind, and its absence means nothing was recorded, never nothing is running. A consumer’s scratch reset keeps no *.run, because a record surviving a work-unit boundary names a producer from the previous one.

No tool writes or reads the record. The write is a PID captured at launch and the read a single liveness match, each a shell builtin away and both spelled in the template. A script whose body is one builtin is the interpreter surface this methodology shrinks. The reading tool that earns its place exists already: check-producer-liveness. The cost is that a hand-written record can be malformed. That gate exits 2 on a record it cannot parse, so a malformed record reads as could not be established, never as nothing running.

The record’s write is uncheckable after the fact, so the harm is enforced. A session that skips the rule writes nothing, so there is no absence a check could expect. A tracked-tree mutation under a live producer arrives as an ordinary tool call at a PreToolUse chokepoint, the class the interception axis below admits. guard-kit rule git_mutation_under_producer blocks it there (guard-kit/SPEC.md §The generic ruleset). check-producer-liveness at a stage entry is the backstop for a producer still live when the next entry is stamped.

The launch is refused without a record. That enforcement holds only for a session that recorded. So guard-kit rule background_no_record blocks a backgrounding call that writes no record, and rule bounded_wait grants the one canonical recorded launch of an allowlisted command, so the compliant spelling costs no permission decision under the Bash tool. A PowerShell launch meets the refusal and takes no grant. The rule covers both spellings: a shell & in the command text, and the harness’s .tool_input.run_in_background boolean (guard-kit/SPEC.md §The shell guard records the payload’s shape). Building only the & arm was refused as worse than not building it. Every attested firing used the harness form, so a shell-only rule would block the form nobody uses and pass the one that fires.

A call the harness moves to the background on its timeout is the residue. It was a foreground call when the guard saw it, it writes no record, and no PreToolUse payload carries task state. The template covers the recoverable half: size a foreground timeout above the run, record from a pid the moved call names, and hold the turn and the tracked tree until the completion notification. A moved producer naming no pid stays unrecorded. The turn-end hook still refuses a dispatched session’s turn end while the harness’s task view shows it running (§The turn-end liveness hook), but rule git_mutation_under_producer cannot see it. Such a session has no in-turn primitive that ends on the notification: there is no pid for a kill -0 loop, and a pattern match is refused. It can keep working until the notification arrives, but cannot wait idle.

An awaited artifact lives in repo-local scratch. A session that spelled its artifact wait against the harness’s own scratchpad path was refused by the consumer’s guard and steered to repo-local scratch, and the guard was right. With the split above, a session waiting on an Agent never reaches for a path, and a session waiting on a shell child has one the guard sanctions.

The bracket trick is refused as the sanctioned form. The template carries why a process-liveness predicate can never go false and the two sanctioned repairs. It defers here why the third, pgrep -f '[r]un-smoke.sh', is refused. It works, so what disqualifies it is its failure mode: a spelling that must be remembered, whose omission is invisible at the call site and silently expensive. A correct form one character from an incorrect one will be got wrong, so the enforcement is a guard rule over the shape (guard-kit/SPEC.md §The generic ruleset).

The template’s Findings you will act on are durable before you act on them rule closes a near-loss: a close stage dispatched two audit sweeps and hit the session wall, and only luck had them back first. A read-only child had journalled nothing, its findings living only in the parent’s context. Both ends now write. The read-only child journals as it goes, an isolated one by a shell append, the one route to the main checkout the harness leaves it. The parent’s durability duty stands beside it, because §Resume journal’s caveat still holds of the parent’s end: recoverability never rests on a child’s write alone.

agent-budget-guard closes the gap the template’s Budget-check before each dispatch in a fan-out rule leaves when it relies on the agent choosing to run the tool. A memory-quoted percentage acts in its place: a session quoted ~5% while the live verdict read 29%. It is a PreToolUse hook (matcher Agent) the harness fires on every dispatch once the consumer registers it, a per-dispatch reading that no mid-session window outlives. It runs usage-verdict at the decision point and routes on the exit code:

  • PAUSE (1) → block, exit 2, with the verdict line plus its corrective (wait for the window reset, or re-run with DELEGATION_KIT_PAUSE_PCT deliberately raised). This is guard-kit’s one sanctioned fail-closed deny. The matcher proves the tool, and PAUSE is reachable only through a fresh, readable, over-threshold snapshot, so blocking cannot wedge a consumer with no producer.
  • STALE / unreadable (2) and OK / RESET-OK (0) → advise, exit 0, feeding the verdict line back as additionalContext, so the live reading rides in context at every dispatch. The arm relays the line and adds nothing, since the verdict carries its own consequence (§usage-verdict). Routing STALE to advice keeps a consumer with no snapshot producer working: budget-unknown is decision-relevant, never disqualifying.

The consumers are the dispatching agent, reading the verdict off additionalContext at the dispatch decision, and the supervising user, who consumes a block by ruling: raise the knob or wait. The agent never overrides. delegation-kit owns the verdict, its thresholds and the routing; guard-kit supplies only the framework primitives. Registration is the opt-in valve: a consumer wanting pure advice does not wire the hook. The verdict line is the only interface, with no new state and no new usage.txt key.

Covered at every depth, aggregated at none. The registration is session-wide, so the hook fires inside dispatched sessions too, each fire carrying that session’s identity. Each fire is independent and holds no state about the tree, so a verdict prices the one call it gates and never the subtree that call opens. The subtree clause of the template’s Budget-check before each dispatch in a fan-out rule compensates. A stateless hook with no depth counter does reach below the root. A hook whose output only its own session sees looks absent from every other vantage, so a supervisor counting its own hook fires undercounts its tree.

A knob-raise ruling has one working transport: the env block of the harness’s local settings overlay, which hooks re-read per fire. A raised DELEGATION_KIT_PAUSE_PCT reaches the guard on the next dispatch with no restart, and deleting the entry restores the default as fast. A Bash-tool inline export never reaches the hook, whose process does not inherit the tool’s shell. Propagation is asymmetric: the Bash tool’s environment keeps an injected value after the entry is deleted, until the session restarts. So the hook’s verdict line, not an in-session re-run, is the acting reading. That this transport works sanctions nothing else it reaches: the subagent-model override below rides the same block and is refused.

The subagent-model environment override is refused, in both of its forms. Model resolution runs a dispatch’s own model parameter, then the definition’s model: frontmatter, then the environment override, then the dispatching conversation’s model. A companion force switch lifts the environment rung to the top. Forced, it pins every subagent in one line and silently defeats a supervisor’s per-batch tier pin. lifecycle-kit/templates/lead.md §Economics — batch, and compact where it pays depends on that pin as a ceiling, not a default. Unforced, it reaches only a dispatch naming no model either way and binds it to one model whatever its class, the class-blind choice §The tier binding replaces. The correct rung is the frontmatter. It supplies the standing default the template’s Match the dispatched model and effort to the unit’s shape rule asks for, while the per-dispatch parameter still overrides it. The field is stated because an omitted model: falls through to the dispatcher’s tier, so omission spells a tier; check-agent-tier-explicit is that clause’s oracle over the tracked surface.

Isolation is confinement. The template’s A read-only claim is made by isolation, not by sentence rule states the disposition; its grounds are these. “It has no Edit tool” is not a safety property. Every agent type available for audit-shaped work carries write tools, or a shell that reaches git, and a brief never narrows a toolset.

A surviving worktree is not a detector. The harness auto-cleans an unchanged worktree only best-effort: surviving worktrees have been found locked at a stale stamp with an empty git status --porcelain inside. So a survivor means either the child wrote or reclamation did not fire, and one git status --porcelain inside tells them apart. The reaping boundary performs that read and reports per path, and the iteration boundary refuses outright on any linked worktree (lifecycle-kit/SPEC.md §bin/enter-stage.sh). Gitignoring the worktree path would trade the signal away, which is why the rule’s second cost sends the parent to git worktree list at the reaping boundary. A detector whose positive is unsound trains its reader to investigate nothing. Repeated successful auto-cleans confirmed it until one did not, the lesson worktree.baseRef repeats below: repeated confirmation of a behavior is not evidence about its mutability.

The dispatch guard reaps nothing. A verdict surface may not own lifecycle: the guard’s contract is to decide, and a guard removing directories and refs en route to a verdict fails invisibly at the only place anyone looks. That ground alone refuses a dispatch-time sweep. A second ground was timing: a sweep beside in-flight siblings must tell that a worktree belongs to no live child. The lock reason answers that, naming the holding process’s pid and start time, so liveness is decidable from outside the child; lifecycle-kit’s boundary check performs that classification. Wherever the consumer configures no lock-reason pattern the predicate degrades to blind, so a guard-side sweep would still rest on optional config to avoid destroying a sibling’s checkout. The iteration-boundary refusal takes no destructive action and runs when the previous work is finished by definition.

Why worktrees strand. A locked worktree is refused by git worktree prune and by an unforced git worktree remove. A harness dying before its cleanup step therefore strands one that is clean, commitless and unreclaimable. Reclamation is tied to the child’s own return, not to the dispatching session’s lifetime. Which pid the lock reason names is the harness’s to decide. A harness writing its supervising process’s pid makes this the one record the standing instinct, trust a recorded pid’s liveness, misreads, so the template states that limit at the reap step.

The dispatcher reaps what it minted at its own turn end. Neither ground above reaches it. The subject is a session, not a verdict surface, and a dispatcher at its own turn end is past every child it started this turn and scoped to trees it minted, so it needs no predicate about a sibling’s liveness. The boundary refusal stays the backstop. The obligation buys latency, shortening an orphan’s carry from the rest of the iteration to one turn.

No oracle is owed over that turn end. SubagentStop sees it, but a worktree carries no attribution to the ending session without the optional lock-reason configuration. A refusal keyed on git worktree list would refuse a dispatcher for a concurrent session’s live tree. Registering the worktree for a later sweep is refused too: git worktree list already is the registry, and a dispatcher-written record would share the reap’s uncheckability.

worktree.baseRef is vendor configuration, and choosing it is the consumer’s job. The template’s Isolation charges five harness costs, and paying them is the parent’s job rule names the knob and both of its values. A vendoring consumer inherits the template and never a settings file. A consumer that dispatches isolated agents sets the knob deliberately. Where its harness config is gated, it pins the knob through context-kit/SPEC.md §check-settings-pins. The value must be chosen, never a particular one: a consumer wanting a clean tree per dispatch is right to stay on the default. No kit knob shadows it (§Layout and configuration), since a kit knob over a vendor setting would be a second source for one value.

The cost names a knob, not a measurement. “The worktree is cut at the remote tracking ref, so staleness equals the unpushed backlog” describes the fresh value alone. Stated as universal, it reached shipped doctrine on attestations all taken under that default. So the rule names the knob and both values, and attributes any measurement to the configuration it was taken under.

The child-side rev discipline stays as defence in depth. The parent cannot read the child’s effective setting: baseRef resolves on the dispatching machine’s merged settings, and neither the payload nor the return carries it. A vendoring consumer that has not set the knob has the discipline as its whole protection. A managed tier or a command-line override outranks a project settings file, beyond any repo’s pin. The check is one command whose output is the evidence, so under a holding pin it is a cheap assertion rather than dead weight.

The untracked half reaches no configuration, and its disposition is refusal. An isolated child sees only committed state, so no knob value or named rev helps. A sweep sent to triage an untracked capture log read an empty file and reported its corpus absent. A settings-overlay comparison saw no overlay, the local settings file being uncommitted. Both returned a confident “nothing there”, indistinguishable from a clean result. It binds against the read-only rule: isolation makes the read-only claim and blinds the read, so a claimed-read-only sweep over an untracked corpus has no correct form inside its worktree.

The write half is closed by construction. A capture line an isolated child writes, such as a guard fall-through, a liveness record or a knowledge-friction stamp, lands in the main checkout’s capture tier (gate-sdk/SPEC.md §The workflow directory). Close’s triage and the KPIs that count it read isolated sessions too. A tracked capture line, a gap bullet or a survey block, is refused in the worktree, and the child hands the finding back (lifecycle-kit/SPEC.md §The committed gap inbox). Reads are not redirected.

The dispatch-shape rules are one mechanism. The template’s Never dispatch a fork to narrow a child, A read-only claim is made by isolation, not by sentence, and A child’s only upward route is a durable artifact rules each govern the shape of a dispatch call. Each failed the same way: the prose did not reach the reader it binds. A fork dispatched as a read-only audit completed a whole stage, commits included. Forks with prose-only “read-only, no edits” briefs committed unreviewed on the shared branch. Fan-outs messaged the top-level session instead of their dispatcher, which no prompt wording could fix.

The axis is interception, not durability. A dispatch and a turn end both leave no tracked artifact. Whether an oracle is buildable turns on whether the act passes a chokepoint. A dispatch passes PreToolUse; a turn end passes no tool-call chokepoint. On the harness’s own event axis it does: a SubagentStop hook sees it, the harness does not defer the stop while a background child is live, and §The turn-end liveness hook refuses there. So the turn-end rule carries enforcement on both axes, guard-kit rule git_mutation_under_producer over the harm and that hook over the act. Neither substitutes for the other: the guard rule reaches acts the hook’s event never sees, and the hook reaches a session that mutates nothing.

The chokepoint — what a PreToolUse hook can actually read. The payload carries session_id, prompt_id, transcript_path, cwd, permission_mode, hook_event_name, tool_name, tool_input, and tool_use_id; and, only when the hook fires inside a subagent, agent_id and agent_type. For the dispatch tool, tool_input carries the dispatch’s parameters: subagent_type, isolation, model, description, prompt. So the fork ban and the read-only claim have exact triggers, tool_input.subagent_type and tool_input.isolation. Depth has an exact, documented discriminator: agent_id is present iff the dispatching session is itself a subagent, so the call creates a grandchild. The discriminator is the field’s presence, never its value, so the doubt about the value §The turn-end liveness hook records does not touch it.

Honest limit on that roster. It is sourced from the harness’s published hook contract, fetched rather than recalled, on the footing of CLAUDE_CODE_CHILD_SESSION’s “verified, not trusted” treatment (lifecycle-kit/SPEC.md §bin/session-id.sh). A harness revision reshaping the payload is drift this SPEC cannot self-detect; only re-reading the contract catches it.

Two depth routes ruled out. CLAUDE_CODE_CHILD_SESSION is set in top-level sessions too, and CLAUDE_CODE_SESSION_ID carries the root id for a child, so the environment cannot tell a depth-1 from a depth-2 dispatcher. The meta record beside a transcript carries an exact spawn depth. Coupling a hook to that uncontracted artifact, for what one contracted boolean answers, is declined. The stage-economics meter accepts that coupling (drift-kit/SPEC.md §The stage-economics meter, the fan-out row) because it needs the whole spawn forest, which no contracted field supplies.

agent-dispatch-guard is that oracle — a second PreToolUse hook on matcher Agent, beside the budget guard. It reads the payload once and runs the rules below, in order:

# rule trigger decision
D1 fork ban tool_input.subagent_type is fork block
D2 isolation claim tool_input.subagent_type ∈ DELEGATION_KIT_READONLY_TYPES and tool_input.isolation is not worktree block
D4 default isolation DELEGATION_KIT_MUTATING_TYPES is non-empty, tool_input.subagent_type is not a member, and tool_input.isolation is not worktree block
D5 chosen tier DELEGATION_KIT_REQUIRE_TIER is on, tool_input.model is absent, and the dispatched type has no definition under DELEGATION_KIT_AGENT_DIR stating model: block
D6 bound tier DELEGATION_KIT_TIER_MODEL is non-empty and tool_input.model matches no bound value block
D3 depth advisory payload carries agent_id advise

Order is load-bearing only between the blocking rules and D3: a blocked dispatch needs no advisory, so D3 runs last. D1 precedes D2 because the fork ban is unconditional and its message is the more specific one for a dispatch violating both. D2 precedes D4 for the same reason, a read-only claim being the narrower message, and D4 precedes D5, since an unconfined dispatch is the graver fault. D5 and D6 trigger disjointly, an absent model against a named one, so their order sets only which message a reader meets first. An absent subagent_type is the harness’s default type, which no roster declares and no definition names, so D4 and D5 each read it as undeclared.

D4 closes the complement of D2. D2 holds a named read-only type to isolation; D4 holds every type nobody declared mutating to it. A type is confined by default, and reaching past isolation takes a named choice: dispatching a type the consumer’s DELEGATION_KIT_MUTATING_TYPES declares. It keys on the type, never on the prompt, on D3’s ground. The knob is indexed and empty by default, so D4 is inert until configured, as D2 is, and the kit ships no type names. The block names the two lawful shapes: isolate the dispatch, or dispatch a declared mutating type.

What isolation confines, measured, and what it leaves. An isolated child’s file-tool writes into the main checkout are refused by the harness, its scratch dir included, so its journal travels by a shell append. Its shell writes are not refused. Under a strict harness sandbox a child’s touch into the main checkout’s root succeeded, the sandbox’s write-root being the session’s project directory rather than the worktree. The sandbox would bound a child to the repository and no tighter, so it is not bound as a confinement.

guard-kit rule worktree_confinement confines the shell (guard-kit/SPEC.md §The generic ruleset, A main-checkout path from a linked worktree), and only for a path the command names. It admits a read-only pipeline or a read-only arm of the gate binary over the main checkout, the forms unable to write there, and refuses every other command naming it outside the scratch dir. A program the child runs can write any path it computes. That residue, and a dispatcher’s knowing choice of a mutating type for read-only work (D4 makes the choice named, not impossible), are this design’s honest limits. The file-tool refusal is harness behavior, measured in a session loading no project settings, on the footing the payload roster stands on.

D5 makes the tier a choice. Selection is affirmative (delegation-kit/templates/agent-execution.md, Match the dispatched model and effort to the unit’s shape): an unselected dispatch inherits the dispatcher’s tier, so D5 blocks a dispatch that names no model and whose type has no definition stating one. The read-only type’s stated default is its own definition’s model: field, which check-agent-tier-explicit already requires every tracked definition to carry. A definition is found by its first frontmatter block’s name:, else its file stem, the way the harness names a type. A built-in type with no definition names its tier at every dispatch. The switch is DELEGATION_KIT_REQUIRE_TIER, off by default, and the block message carries no model name: the kit ships none, because the harness roster churns and the value is the consumer’s. D1 owns fork.

D6 holds a named tier to the binding. Where the consumer binds its classes, a dispatch naming a model names one of the bound values, so a per-dispatch override chooses among the consumer’s tiers rather than around them. Matching is §The tier binding’s. The block message names the bound values, which are the consumer’s config and not kit literals, and points at --emit knob-values DELEGATION_KIT_TIER_MODEL through the gate binary’s door, read only when D6 blocks. A dispatch naming no model is D5’s. An empty binding leaves D6 inert. D6 reads a row’s model half alone, and a dispatch’s effort is not held to the binding. An unnamed effort is the definition’s or the dispatcher’s, which the guard cannot see, so a check on the named case would only teach a dispatcher to leave it unnamed. Under a pinned class, the alias a per-dispatch parameter can carry matches no pinned id and blocks. That is the pin held rather than bypassed at the one chokepoint that sees it, and the consumer dispatches that class through its pinned type (§The tier binding, the honest limit on pinning).

Why a second guard rather than more rules in the budget guard. Four reasons, each sufficient alone:

  • the budget guard blocks on a transient condition and is deliberately knob-overridable, while these block on a permanent doctrine violation that must not be overridable per dispatch;
  • the budget guard reads no stdin at all;
  • a consumer may want either enforcement without the other, and hook registration is the only opt-in valve there is;
  • their test lanes take different payload grammars.

The harness fires every hook registered on a matcher and any exit 2 blocks, so the two compose with no dispatcher between them.

D1 blocks unconditionally, which the template’s own boundary does not: a fork stays correct where the child does the same job at the same authority, and a hook cannot read intent. No sanctioned fork use exists in this doctrine, so the valve is unwiring the hook, never a knob: a per-dispatch override is exactly the honour system these rules exist to end, and a knob would restore it under a better name. The block message names the two lawful alternatives, as guard-kit requires of every block message.

D2 is inert until configured: the default roster is empty and the kit ships no agent-type names. An inert D2 reports nothing, being a hook with no clean line to print, so a consumer does not infer coverage it has not configured.

D2 composes with isolation’s untracked-blindness cost, and the protocol template states the composition where a dispatcher reads it. An untracked or gitignored surface is in no commit, so an isolated child cannot resolve it inside its worktree. For a type in DELEGATION_KIT_READONLY_TYPES, dispatching unisolated is the shape D2 blocks. A roster read delegated under that mandate reported “absent” for four surfaces that carried content.

The route is the absolute read. The dispatcher names each such surface absolute into the main checkout. The child reads a named file there with the harness’s read tool and searches under it with a read-only pipeline. The harness refuses the child’s file-tool writes there, and guard-kit rule worktree_confinement admits only the read forms (guard-kit/SPEC.md §The generic ruleset). That admitted read keeps D4’s isolation default workable for a sweep over an untracked corpus when the harness build carries no search tool. The child’s half stays: a target the dispatch did not name absolute is a blindness, not a finding of absence, since a parent cannot tell a reported absence from a true empty. Cost (4) has no dispatcher half of that kind. An oracle-running sweep runs its arm inside the worktree on the stamp-matched main binary (gate-sdk/SPEC.md §lib/gate.sh, gate_verdict_bin), and the dispatcher classifies only whether that oracle reads a gitignored overlay.

That constraint lives where the dispatcher reads its protocol. A guard assertion is declined on cost (3)’s ground: the payload carries the prompt and never the corpus or oracles a sweep will run. A rule would key on whether the prompt “names a path”, firing on the word and teaching dispatchers to game the wording. A consumer’s own roster emitter could repair one instance and stays available, but a delegable sweep’s corpus is often not that roster, so the class stays open there. Always-loaded residency is declined by §Operative residency’s condition (a), since the dispatcher fires the trigger that loads the template.

D2 is evaluated once, at dispatch, and a resume does not re-enter it. A SendMessage resume of an isolated read-only child runs in the main checkout with its worktree gone, holding the write reach D2 would have refused. Measured: a child that reported its own linked worktree at dispatch printed the main checkout as pwd and toplevel when resumed after its hand-back. The resume’s PreToolUse payload names neither the recipient’s type nor its isolation. The dispatch payload carries a tool_use_id but not the agent id a resume addresses, so the guard cannot see the resume. A join would need a post-dispatch hook writing a map from agent id to dispatch shape, a state file for one class, and is declined as the heavier shape. The template treats a resumed isolated dispatch as a new dispatch for any work over the tree, restricts a resume to re-emitting what the child holds, and has the child confirm its own linked worktree at every start. That check is a request delivered where the child reads it (§Operative residency); nothing refuses a child that skips it.

D3 advises rather than blocks. It cannot block: a nested dispatch is legitimate and common, a stage session’s read-only fan-out being one, so a block would remove a capability rather than confine one. It must not be silent: its bound reader provably never loads the protocol template (§One template, a resident pointer), so the advisory is the rule’s only delivery, §Operative residency’s (a)–(c) shape at a hook. It keys on agent_id, exact and unforgeable, never on whether the prompt “names a path”; the message carries the judgment the trigger cannot.

An isolated read-only child’s return-value obligation rides the dispatcher’s prompt. Nothing carries D3’s warning into the child. An isolated read-only child whose report went to a peer name it could not resolve returned a bare ., which reads as a sweep that found nothing. The template’s disposition (2) has the dispatcher say so in the prompt. The guard rewriting the prompt is declined: it would make the guard an author of dispatch text rather than a judge of dispatch shape. The agent-type definition is the consumer’s surface, since the kit ships no agent-type names (§Layout and configuration, DELEGATION_KIT_READONLY_TYPES); a consumer may carry the line there too, in the type’s return contract.

Degradation — fail-open, but loud, the posture guard-kit/SPEC.md §The shell guard names for a deny-guard whose matcher proves the tool but whose rule turns on a payload field. An absent binary takes the general fail-open path (gate-sdk/SPEC.md §The non-gate arm) before this member runs, silently, with no advisory of this guard’s own. Inside a linked worktree it is absent only when the main checkout carries none either, since the front end dispatches there first. Once the arm runs, the degradation surface is:

what is missing behavior
payload unparseable, or tool_input absent allow; one advisory naming D1, D2, D4, D5 and D6 unenforced
subagent_type absent from a parseable payload D1 and D2 do not fire (nothing to match); D4 and D5 read the harness’s default type, undeclared; D3 unaffected
DELEGATION_KIT_READONLY_TYPES resolves empty D2 inert, and silently so; the other rules unaffected
DELEGATION_KIT_READONLY_TYPES declared but its own read fails allow; advisory naming D2 unenforced, the other rules unaffected
DELEGATION_KIT_MUTATING_TYPES resolves empty D4 inert, and silently so
DELEGATION_KIT_MUTATING_TYPES, DELEGATION_KIT_REQUIRE_TIER or DELEGATION_KIT_AGENT_DIR fails its own read advisory naming D4, or D5, unenforced; the other rules unaffected
DELEGATION_KIT_TIER_MODEL resolves empty D6 inert, and silently so
DELEGATION_KIT_TIER_MODEL fails its own read advisory naming D6 unenforced; the other rules unaffected
the gate binary’s door fails its read where D6 blocks D6 blocks; its message names the fault in place of the door

Two implementation facts those rows force:

  • There is no jq-absent arm. The compiled member emits its own advisory envelope, serializing every interpolated value rather than quoting it by hand. The braces and key order stay literal, holding the shell guard’s advise wire shape.
  • The guard reads its roster through delegation-kit’s validated table, and an unrelated config fault still cannot wedge a dispatch. The kit’s knobs are static (§Layout and configuration), so DELEGATION_KIT_READONLY_TYPES resolves in process, and the table validator runs at the kit’s first resolution. A malformed unrelated delegation knob therefore fails this roster’s read, which is the table’s read-fails rows: the typo costs one rule’s enforcement, loudly, and never the dispatch. A validator fault fails every read of the table at once, so it names D2, D4, D5 and D6 together. A roster resolved to nothing is silent; one whose own read fails is loud.

One template, a resident pointer

templates/agent-execution.md is the single source for the protocol, a binding-shim template in lifecycle-kit’s grammar (lifecycle-kit/SPEC.md §check-skill-binding). The consumer creates agent-execution.md in its agent-skill directory (LIFECYCLE_KIT_SKILLS_DIR) as a shim that names the template and binds its two slots, the shared-file roster and the validate battery. check-skill-binding enforces the slot pairing on the consumer side.

A spec reference to one of those rules uses the citation grammar the template's **<name>** rule, where <name> is a bullet’s bold lead-in verbatim minus its trailing period. check-rule-citation resolves every such citation in §The delegation model forward into the template, so a lead-in rename cannot silently dangle a reference. It is the publisher’s own gate and does not ship: its subject is this SPEC, which the payload withholds (gate-sdk/SPEC.md §Consumer payload). Its fixture pair passes a fixture-local spec and template as its two positional arguments, a citation resolving to a lead-in against one naming an absent lead-in.

templates/dispatch-checklists.md is a second, load-triggered template the protocol template reaches by one pointer line, for a deletion, rename or heavy cross-spec audit dispatch. It is never resident and binds no slots, so check-skill-binding does not scan it. It carries the mechanical pre-flight for those shapes: the importer, collision and sweep-verification checks. The protocol template stays complete alone. The width, journal and validate rules live once there and are cited from the checklists, never restated. This is load-trigger residency a rung deeper: a subset only a narrower trigger needs sits behind its own pointer.

The consumer’s CLAUDE.md carries no digest of the bullets, only a resident pointer: the pre-authorization sentence (consumer judgment on what delegation needs no ask) and /agent-execution. A rule is resident only when it has no load trigger. Every protocol bullet triggers at Agent dispatch for the role that dispatches, which already meets a mechanical seam: the per-dispatch budget guard, whose block message names /agent-execution. Honest limit: the guards enforce exactly the rules that pass a chokepoint, the budget check and the dispatch-shape rules (§The delegation model), and protocol literacy beyond those stays unenforced. A session that dispatches without invoking the skill carries only the resident pointer for everything else.

That role qualifier is load-bearing. A dispatched role fires no such trigger: it is executing a stage, and no stage skill or stage template loads this protocol, so for that reader the pointer leaves the rule unreached. §Operative residency governs that case. The bounded imperative it sanctions in a consumer’s always-loaded agent definition is this doctrine applied to a different reader, not an exception to it.

Operative residency

A rule may be restated as an imperative in a surface that does not own it when all three of these hold:

  • (a) Unreachable trigger. The actor the rule binds never fires a trigger that loads the rule’s owning doc. This is a property of the reader, settled by asking which triggers that reader actually fires.
  • (b) Imperative only. What is restated is the instruction. The reasoning, the failure analysis, and the mechanism stay with the owner.
  • (c) Adjacent citation. The restatement names the owning surface beside itself, so the owner stays the single place the rule is explained and changed.

Grounds a surface authors for its own instruction are no restatement and take no sanction; Content-tiering / SSOT places them (doctrine-kit/DOCTRINE.md).

Each condition has a reader at a transition. (a) and (b) are read by whoever authors a restatement, at authoring (build’s ritual, the amendment Definition of Done), and by a reviewer or an authoring stage assessing an existing one. (c) is the pointer any later reader, and any content-tiering check, follows back to the owner. The reader the sanction exists for inspects no condition: the bound actor, which receives the imperative at the tier it always loads.

The anti-licence clause is part of the rule. The sanction is keyed on (a): a rule the bound actor’s own skill or template already loads fails (a) and stays a pointer. Without that clause this reads as “duplicate when it feels important”, the failure content-tiering exists to prevent. (a) licenses one restatement and not one per reader, so where the bound actor is any session the carrier is the consumer’s always-loaded tier and the templates those sessions load carry nothing. context-kit/SPEC.md §The consumer footprint rules that case and states the one thing a template may still say.

Worked cases. The provenance floor (§Resume journal) and the turn-end worktree reap bind a session that has dispatched, which loads templates/agent-execution.md through its own trigger. Both fail (a) and stay template-tier: a resident copy would be a second source paid for by every session that never dispatches, and reading as important is the licence (a) withholds. The isolated child’s blindness clause and its linked-worktree check at start and at resume bind a dispatched read-only agent, which fires no trigger that loads the template. Both qualify, and ride the carrier already stating the sibling gate-unavailability clause.

This is load-trigger residency’s own logic. That doctrine makes a rule resident only when no stage, skill or tool-call trigger loads it, and the question is exists for whom. A trigger the bound actor never fires is, for that actor, no trigger. The doctrine’s justification then inverts: nothing loads the rule anyway, so deferring costs everything.

(b) is what satisfies content-tiering. The restatement is still a restatement, which is why it needs this sanction. But its drift surface is one instruction rather than a slab, and the rule is explained and changed in one place.

The attested subjects are the template’s Background + notification, never poll rule and its Findings you will act on are durable before you act on them rule, both binding a dispatched session. Sessions that read a descriptive pointer to them at the always-loaded tier derived no constraint from it. Under load a descriptive sentence reads as background about one’s situation, not as a constraint on the next action. The defect is pointer-versus-operative-statement, never absence.

Operative is not obeyed. A dispatched session ended its turn on work still running while the waiting rule sat in its own agent definition as a bare imperative, the compliant form. Residency makes a rule reachable at the point of use, and reachable is not obeyed. Every restatement this sanction licenses is a request delivered where it is read: worth its small drift surface, never a substitute for an oracle where one is buildable.

Propagation finds its carriers by grep, and carries the wording. The template’s when either rule changes here, propagate header sends a change to every restatement. Each carrier takes an addition sized to its own voice rather than a paste, and none takes the refused alternatives, which is (b)’s line. The carrier set is re-derived at the merge by grepping the rule’s phrasing, never read off a roster: a guard’s corrective help text restating the clause was found that way, outside every recorded list. A restatement propagates the rule’s wording, so a wording defect ships in every carrier: N carriers make N copies of a bug rather than N chances to catch it.

A liveness wait takes while, never until. Backgrounded waiters died before their conditions went true with their producers alive, and §bin/wait-probe measured why. Every trial on a completion-marker predicate outlived its condition, on both harness forms and on a detached shell child, at every producer duration swept, so no form carries a wall-clock ceiling in that range. Every early exit tested the producer’s liveness, and reproduced on the harness-uninvolved control in milliseconds. until ends when its condition is true and kill -0 is true while the producer is alive, so until around a liveness test exits at once, cleanly, with the producer running. until takes a done predicate and while a still-running one. guard-kit rules bare_sleep and background_no_record walk do … done spans without reading the loop keyword, so the while form is exempt exactly as the until form is (guard-kit/SPEC.md §The generic ruleset).

Every carrier spells the PID as a literal, while kill -0 <pid> 2>/dev/null; do sleep N; done, with <pid> the number read out of the record. It is the one spelling guard-kit’s rules agree on: rule bounded_wait grants it, and rule bare_sleep’s corrective names it (guard-kit/SPEC.md §The generic ruleset). An expansion such as "$pid" meets rule expansion’s block, so a carrier spelling one teaches a form the protocol’s own guard refuses on the first wait.

No gate is owed over the act; one is owed over its harm, and it exists. No check can read a session’s choice to end a turn: the act leaves no tracked artifact and passes no PreToolUse chokepoint. The harness’s turn-end event does see it, and §The turn-end liveness hook refuses there. The harm passes a chokepoint too. A tracked-tree mutation while a recorded producer is still writing arrives as an ordinary tool call, blocked by guard-kit rule git_mutation_under_producer (guard-kit/SPEC.md §The generic ruleset, whose design §The delegation model owns). Its bound travels with it: it reaches only a session that recorded. A wait’s launch passes a PreToolUse chokepoint too, so the clause that a wait owes a producer has an oracle for the class guard-kit rule wait_no_producer reads, and stays a request outside it.

The anti-restatement gates do not reach a sanctioned restatement. check-shim-restatement scans binding shims under the skills directory against the consumer’s always-loaded file plus kit templates. check-surface-duplication’s predicate is a glossary term’s definition on a canonical SPEC surface. Neither reaches an agent definition, so the sanction ships with no gate exemption, and nothing in the battery catches a non-compliant restatement either: (a)–(c) are review-enforced. Of the consequence oracles, guard-kit rule git_mutation_under_producer prevents the consequence at the tool call that would cause it. check-producer-liveness detects a producer still running at the next stage’s entry (evidence-kit/SPEC.md §check-producer-liveness). The second detects late by construction, and a firing at an iteration’s last stage has no entry after it, so it is the backstop behind the first.

Interception, not durability, separates the gated dispatch-shape rules from this one. Both bind acts that leave no tracked artifact. A dispatch passes a chokepoint the harness fires a hook on; a turn end passes none the dispatch-shape rules could use, though its own SubagentStop event is reachable (§The turn-end liveness hook). So “no tracked artifact” is why neither rule gets a gate over the tree, and never by itself a reason to stop looking for an oracle.

The turn-end liveness hook

The subagent-stop-liveness hook is an opt-in SubagentStop hook, inert until a consumer registers it. It refuses a dispatched session’s turn end while a launch record under the scratch dir names a live producer, or while the harness’s task view shows a shell task of that session still running. The harness does not defer a subagent’s stop while a background child is live (§The probe is asymmetric). So the axis §The delegation model’s waiting rule exists for holds at the turn end, and a blocking hook is the only lever.

The refusal is unconditional, and what follows binds any change to it. First, a capped variant is refused: refuse only on a verdict=red live=yes reading carrying at least one record, and stay advisory otherwise. Every narrowing below stands on its own stated grounds, never as a safety margin, and a boundary that coincides with the capped variant’s says so. Second, a change is weighed on its class, an observer turned refuser at a turn end, never on the size of the edit that delivers it.

The event is SubagentStop, never Stop. A dispatched session is a subagent, so its turn end fires SubagentStop, and every attested firing of the waiting rule was a dispatched session’s. Stop is deliberately not registered: the main session’s turn end has no attested firing, so registering it would widen the subject past the evidence. The main session’s turn end stays unreached.

The contract: exit 2 on red, corrupt or unresolved, or while the payload’s background_tasks shows a running shell task the emitting session launched; exit 0 on every other path. Neither path writes hook JSON, except the knob-fault decline. That decline writes the systemMessage envelope gate-sdk/SPEC.md §The harness-integration arm rules, so the operator learns the turn end went unguarded. The hook reads its payload from stdin and asks the liveness reader whether any launch record under ${GATE_SDK_TMP_DIR:-.tmp} names a live producer. It appends one line to DELEGATION_KIT_STOP_LOG, then exits 0 or exits 2 with its refusal on stderr.

The refusal needs no emitter. The harness’s published hook contract lists SubagentStop as an event a hook can block and exit 2 as the blocking route. When the hook emits no JSON decision, its stderr is shown to the session model as the blocking reason. So the refusal mints no guard-kit primitive, though all three of guard-kit’s emitters hardcode hookEventName:"PreToolUse". The hook does not source guard-kit’s lib, which keeps delegation-kit free of a dependency on guard-kit being vendored. Reading the payload costs the compiled member one stdin read and one parse.

There is no advisory tier at this event. At exit 0 a hook’s stderr goes to the debug log only, and the session model never sees it. Exit 2 is the only delivery, so the refusal has no softer form.

Honest limit: that contract is the harness’s published one and nothing in this tree. It stands on the footing §The delegation model declares for the PreToolUse payload roster: a harness revision reshaping the event is drift no gate here detects, and only re-reading the contract catches it.

The predicate is the reader’s exit class, and it refuses on three of seven arms. Reader exit 2 is read through two names, chosen by the hook’s own *.run count: corrupt over a non-empty record set, unresolved over an empty one. The count picks the name, never the decision: both refuse.

reading the hook holds decision
green no live producer log, exit 0 — there is nothing to refuse
red a live producer under a launch record log, exit 2 — the whole subject
corrupt reader exit 2 over a non-empty record set: a record that does not parse log, exit 2 — see below
unresolved reader exit 2 over an empty record set: a reader that could not run at all log, exit 2 once — allows when stop_hook_active is true; see below
unavailable no reader resolved: an override that fails the resolution predicate — the default always resolves log, exit 0
unstarted a resolved reader the operating system would not start log, exit 0
error a configured reader that ran and did not answer log, exit 0

unstarted is a reader whose argv resolved and whose spawn the operating system refused: ETXTBSY, ENOENT from an absent shebang interpreter, EACCES, ENOEXEC, ENOMEM or EAGAIN. unavailable is an override that resolved to no reader. They take two fixes, a reader or host that refused to start and a knob to correct, so they take two names. One name over both would be a fail-open with no signature, in which a reader that cannot start reads as one never configured.

corrupt refuses, diverging on purpose from guard-kit rule git_mutation_under_producer, which declines on a record that does not parse. A guard is not where a corruption verdict is taken, and check-producer-liveness already exits 2 on one. That rule reads the records one at a time, so a malformed record declines for itself while a sibling naming a live PID still blocks. This hook reads the whole set through one exit code, resolved exit 2 wins over red wins over green (evidence-kit/SPEC.md §check-producer-liveness). Allowing on corrupt would let one malformed record anywhere under the scratch dir suppress every refusal in the tree, a bypass the per-record rule cannot have. guard-kit/SPEC.md §The generic ruleset records the divergence from its side.

The corrupt arm is cheap to be wrong about. check-producer-liveness is a battery member, so the next commit reds on a corrupt record. The false-refusal window is one malformed record’s lifetime; without the arm, the bypass window is however long a session leaves one in place.

unresolved is the same refusal under another diagnosis, and its ground is the reader’s own contract. Set mode derives corruption per record, so over an empty glob there is nothing to aggregate and the gate cannot return corrupt. Exit 2 at records=0 is the reader failing for a reason that is no record’s. Its routine producer is a worktree-isolated dispatch whose reader dispatches to a compiled binary: a fresh git worktree add checkout carries no build output, so the reader fails closed before reading a record. The hook already counts records by its own glob over the same directory, so the split costs no new field, knob or reader. The row stays: a reader whose binary is missing in a main checkout lands here, as does an override that does not meet the worktree requirement below.

The count labels the diagnosis and decides nothing. unresolved maps to decision=refuse although the case provably is not record corruption. Allowing it would narrow the secured refusal set at records=0, and the unconditional refusal is worth more than the one provably-empty reading it costs. A reader who re-derives the cannot be corruption proof finds the decision here.

unresolved refuses once. A refused turn end is retried, and a reader that cannot run stays unable under anything the child does, so an unbounded refusal is a loop: one read-only audit spent 142 tool uses refusing and lost its report. The hook reads the payload’s stop_hook_active, true when the harness is already continuing because of a stop hook. On that firing the unresolved arm allows and logs verdict=unresolved decision=allow, so triage tells a bounded refusal from a first one. The first refusal on every arm stands, which is why allowing unresolved inside a linked worktree is refused. Under the bound, a worktree-isolated read-only dispatch was refused once and allowed at its next stop, its report delivered.

Loop protection reads the producer on the arms whose condition resolves. red and corrupt ignore stop_hook_active: their condition ends when the producer ends or the record is deleted, so the rule’s own subject bounds the loop. The field is contracted, listed in the SubagentStop input schema with the reference’s guidance to check it “to avoid blocking on a condition that will never resolve”. The harness’s 8-consecutive-block cap did not bound the measured loop, presumably because a child’s tool calls between refusals break the consecutive count. That is observed, not explained, and this tree relies on no such cap.

Record count decides no refusal anywhere in this hook. The capped variant’s “at least one record” is vacuous here, since the reader cannot return red over an empty set. The corrupt/unresolved split is the second coincidence with it: exit 2 genuinely occurs at zero records, and the count still only names which refusing arm a reading gets.

unavailable, unstarted and error allow on the degradation posture. Each means the hook obtained no reading: an override failed the resolution predicate, the operating system would not start a resolved reader, or a reader ran and failed to answer. Refusing there would refuse every turn end behind a mis-set knob, a refusing host or a broken reader, in a kit that ships this hook opt-in and inert. unresolved is a configured reader that ran and returned its own fail-closed verdict, and second-guessing it would be the fail-open its exit 2 exists against. This is guard-kit/SPEC.md §The shell guard’s fail-open-but-loud posture for a deny-guard turning on an external reader, the posture §The delegation model’s dispatch guard takes. The verdict=error, verdict=unstarted and spawn values supply the “loud”.

unstarted does not refuse, not even once. Refusing under the stop_hook_active bound would widen the refusal set past the arms this section rules refusing. A spawn the host refused is no reading at all, the class of unavailable and error. What the arm buys is a degradation the close-stage triage can count.

The harness’s task view refuses beside the reading, on the session’s own launches. A background_tasks element with type shell and status running refuses whatever the reader said, when the session launched it. The session launched it when the element’s id occurs as a whole token in the transcript the payload’s agent_transcript_path names. The harness records there every shell task a session launches, a moved call included, while the view carries no owner. An absent or unreadable transcript counts every running shell element, so scoping narrows only off a key the hook holds and never fails open. The condition covers a call the harness moved to the background on its timeout, which no launch record names. It supplements the record set and replaces nothing: a detached producer the view does not enumerate is still the record set’s. Like red it is unconditional and ignores stop_hook_active, because its condition resolves when the task ends, which a producer does and a wait with no producer does not. guard-kit rule wait_no_producer refuses that wait at its launch, for the class it reads (guard-kit/SPEC.md §The generic ruleset), and the arm’s residue is the wait that rule does not reach.

A malformed view degrades to the record-set decision. An element of type subagent, any other status, an absent key, a non-array value and a non-object element contribute nothing: a supplement’s absence must not refuse. The hook reads type and status to decide and logs nothing of the view or the transcript, not even a count, so the grammar below and §What background_tasks carries’ key-names-only ruling are untouched. A task-held refusal stays legible, because verdict stays the reader’s. It shows as decision=refuse beside a non-refusing verdict (green, unavailable, unstarted, error), a pairing no other arm produces. Its stderr names the arm and both lawful exits: await the task’s completion notification, or, where the task is the session’s own wait and nothing will make its condition true, stop it, since an observer wrote nothing and stopping it loses nothing.

The ownership test is a whole-token match, read lazily and once. A whole token is bounded on both sides by a character outside [A-Za-z0-9_-] or by the file’s start or end, so an id inside a longer token is not a match. An element whose id is absent, not a string or empty counts as own. The transcript is read only when the view holds a running shell element, and at most once per firing.

The view is scoped where the record set is shared. The record set binds every session (the shared-scratch-dir paragraph below), because a record declares a producer and observers write none. The view makes no producer/observer split and spans more than the emitter, and its commonest foreign element in a dispatch is the dispatcher’s own wait on the child. Unscoped, that wait turns circular. A dispatcher’s recorded producer still refuses the child through the record set. Honest limit: the transcript is harness state no contract covers, as the payload is. A revision that stopped writing task ids there would fail the arm open on the session’s own moved call. Only a re-run of the two-sided live check below catches it.

Four narrower repairs are refused. Unscoped, the arm stalled a no-tool child on its dispatcher’s backgrounded observer loop, nine refusals in 24 seconds. Refusing once would let a session end its turn on its own moved call at the second try, weakening the case the arm exists for, and a wait with no producer does not change that. Retiring the arm leaves the record set blind to the moved call again (§The probe is asymmetric). Logging an owner field adds nothing: the decision needs the key, the log does not, and a value log is refused (§What background_tasks carries). Classing the task by its command, to allow on a wait loop, reads a key the hook does not read, and a wait loop behind a script name would read as a producer.

The scoped arm is live-checked from both sides. Foreign: a no-tool child whose dispatcher held a running backgrounded loop logged decision=allow at its only stop. Own: a child whose foreground call the harness moved to the background was refused at each turn end beside verdict=green, then allowed once the moved call completed. That settles both premises the join rests on. A shell element’s id is the token the transcript records as the launch’s backgroundTaskId, and agent_transcript_path names the emitting session’s own transcript.

Whether a finished task leaves the array is not a premise. The condition keys on status being running, so a finished task that leaves the array or carries another status allows either way. The residual is a finished task still reporting running, which would hold the refusal across the session’s retries. Its signature is a run of decision=refuse beside a non-refusing verdict that outlasts the completion notification; the live check read none. Honest limit: that is one reading, not a bound, and a session caught in it has no in-band way out. A session’s own backgrounded wait loop is a running shell task, so a turn end during it refuses, which is the waiting rule itself and needs no exemption.

A wait nothing satisfies holds the refusal until it is stopped. The refusal names stopping it as the exit, which a session takes only if the refusal reaches it.

After a delivered report the refusal reaches no one, and buys its log row alone. Some harnesses deliver a report through a hand-back call of their own, and the stop that follows that call draws no stop-hook feedback. A child backgrounded a recorded producer and a liveness wait on it, then handed back. It was refused on the task-held arm one second later, and its transcript took no entry for the rest of the producer’s run. The harness held the session itself. It sent the dispatcher the report and a completion notification at once, the notification saying the agent stopped with background work of its own still running and that its result may be interim. When the producer exited, the harness gave the child a new turn on that task’s completion notification. It notified the dispatcher again at the stop that ended that turn.

So the harness holds such a session, for the task’s life, and the refusal’s one reader there is the close-stage triage. The signature is a decision=refuse row no model turn follows. A wait nothing satisfies is held until someone stops the task, which is the reverse case lifecycle-kit’s lead template inspects. The hook does not decline there. A decline nobody reads changes the row and nothing else. No contracted payload field marks a delivered report: the refused firing’s key set lacked last_assistant_message, one reading of uncontracted state. Honest limit: one child, one harness build, a producer that exited by itself. §Attribution was weighed and is not available records the refusal reaching a session whose report was undelivered, and that delivery stands.

A harness helper’s firing is not a dispatched session’s turn end, and it allows on every arm. While a dispatched session runs, the harness fires SubagentStop about every thirty seconds for a progress-summary helper. One out-of-band read of raw payloads over eleven firings attributed them, taken as §What background_tasks carries took its read. A helper’s agent_type is empty, its agent_id is new at every firing, and its agent_transcript_path names no existing file. Its last_assistant_message summarizes the running session’s latest step. A dispatched session’s own tool steps fire nothing; its turn end carries its type, the id the view lists for it, and an existing transcript.

Refusing a helper delivers nothing. Refused helpers never fired again, and no later firing carries stop_hook_active true. A helper’s absent transcript sent the task view down its unscoped branch, so a refused helper only overstated the stalls the close-stage triage counts. Allowing a helper narrows no refusal, since its firing is not the subject. The hook still logs it with the reader’s verdict and decision=allow, so red or corrupt beside allow marks a helper and no other arm. A task-held refusal in the log is always the session’s own.

The helper test is a conjunction of three marks, each read positively. The agent_type key is present and empty. The agent_transcript_path key names a non-empty path the filesystem reports as not found. The agent_id key is non-empty, and the background_tasks array holds no subagent element carrying it. An absent key, an empty id or a missing view fails its mark, so an unreadable payload is never a helper. The member’s cases cover every combination of the marks, each absent key and a missing view, and assert that only the full conjunction allows. The hook logs none of the three values. Honest limit: each mark is uncontracted harness state, so a real session is misclassed only if all three drift at once. A revision reshaping the helper makes it refuse again, losing no refusal; one under which a real session meets all three marks fails the hook open for it. Only a fresh read of the raw payloads catches either.

An unreadable payload does not disable enforcement. The verdict comes from the liveness reader over the run directory, so an unparseable payload costs the log line its event, session and keys columns and leaves the verdict exact. Two decisions read the payload. stop_hook_active flips an unresolved refusal into an allow, and the task-view condition drops with the payload, leaving the record-set decision. An unreadable transcript leaves that condition unscoped rather than dropping it. Every other arm decides on the verdict alone, and no separate JSON program is on this path.

The reader runs under a wait bound the member applies unconditionally, so a hung reader is never read as a live producer. A timeout is an unmapped exit code, so it is error and allows; a refusal is only ever the reader’s own verdict. The bounded call’s error is typed. A spawn failure, from the spawn funnel or the operating system, carries the operating system’s own error and reads unstarted. A wait failure on a child that did start reads error: a reader that ran and did not answer.

The liveness reading reuses check-producer-liveness and copies no grammar. §The delegation model rules that the reading affordance which would earn its place already exists and is that gate. Re-implementing the pid=<n> run=<key> parse and the PID predicate would be a third copy of a grammar evidence-kit owns. So the hook invokes the reader in set mode over the scratch dir, which quantifies the per-record verdict and resolves the set. It maps the exit class onto verdict, live and the exit code.

The default reader is the running binary. An empty knob, the default, resolves to current_exe(), spawned with the gate name and the scratch dir as its argv. The executable that is running is present by definition, so a tree that can fire this hook can take a reading. The kit ships no default naming a path no tree resolves: such a fake default reads as a shipped capability and is none.

DELEGATION_KIT_LIVENESS_CMD is the override, for a consumer whose reader is not this gate. It is an indexed argv, one line per element, spawned directly with no shell and the scratch dir appended after its last element. A consumer names an interpreter as an element when its reader needs one. Executability is the resolution predicate: a path carrying a separator must carry the execute bit, and a bare name must resolve on PATH. An override that fails it resolves to no reader and logs verdict=unavailable decision=allow on every line, never run under an interpreter the kit chose. That is honest degradation, preferable to a third parse that would drift from its owner. One that resolves and cannot be spawned reads unstarted. The knob resolves no gate name, which would be a kit-contract change, the ground evidence-kit/SPEC.md §check-evidence-manifest gives for the pre-flight knob.

The default is spawned, never called in process, because the predicate is a child’s exit class. The seven-arm table is written over a child process’s status, with “no reading at all” as an arm no return value can express. An in-process call would give the default and an override two code paths with two arm sets, and unavailable would become unreachable for the default alone. One code path keeps every row true of both, which the close-stage triage and the refusal text depend on. For the same reason a spawn that never started reads unstarted, not error, which names a reader that ran.

A consumer’s override must resolve from a worktree-isolated dispatch. The default is discharged by construction: the running binary answers inside a linked worktree with no build output, with nothing to resolve. The bar sits on the override because only its consumer knows where its reader lives. An override that dispatches to a compiled binary fails closed there, and the hook refuses on unresolved. The harness returns only a dispatched session’s last assistant message, so that refusal displaces the child’s report on the only channel the dispatcher reads. The returned text reads as a liveness complaint, not a dropped report. Where a consumer’s dispatch rules require isolation for read-only agents, every such dispatch arms this, and no dispatch shape avoids it while staying in contract. The ordinary cost is one wasted round-trip; the worst attested tail looped across three resumptions, spent a full context budget and returned nothing.

The kit carries no resolving adapter for an override, deliberately, so the unresolved and unavailable rows stay reachable for a consumer whose override names a compiled-binary path. A consumer who needs one owns it: resolve the configured binary against the main checkout when the path is absent and the cwd is a linked worktree, deriving the main checkout from git rev-parse --git-common-dir. That narrow resolution is safe because check-producer-liveness reads only *.run records, so a main-checkout binary answers the same question. It is not safe for gates in general, which check-gate-binary-fresh compares against their own tree’s source. A verdict-bearing arm takes a main-checkout binary only through the stamp comparison of gate-sdk/SPEC.md §lib/gate.sh, gate_verdict_bin.

Such a resolution resolves an artifact and builds none. An isolated agent still may not build the crate, and no hook reaches a worktree that does not exist at dispatch time. The front end’s fail-open branch takes a main-checkout resolution on another ground (gate-sdk/SPEC.md §The harness-integration arm): it dispatches the session’s policy hooks, not a gate’s verdict, and leaves the knob’s resolution untouched.

One line per firing, appended, space-delimited key=value after a leading timestamp:

<UTC ISO-8601>  event=<hook_event_name|->  session=<session_id|->  live=<yes|no>  verdict=<green|red|corrupt|unresolved|error|unavailable|unstarted>  spawn=<the spawn error, one token|->  records=<n>  runs=<comma-separated run keys|->  decision=<refuse|allow>  keys=<comma-separated top-level payload keys>

Every field has a reader at a named transition, and no field is carried without one. The close-stage triage reads the log at the close-surface drain, where it is read and cleared; “the triage” below is that reader at that transition.

  • timestamp — read by the triage to order firings and to tell a fresh line from a stale one.
  • event — the payload’s hook_event_name. Read at the first firing, to confirm the event fires for a dispatched session and is spelled SubagentStop.
  • session — the payload’s session_id, - otherwise. Read to separate one top-level session’s firings from another’s in a shared log. It is not the subagent’s own id. A dispatched agent and its dispatcher log the same session_id, measured across a worktree-isolated child’s log and its dispatcher’s. Neither matches a stage stamp’s identifier, so this field attributes no firing to a stage session. agent_id attributes a dispatched session’s own firing, carrying the id the view lists for it, while a helper’s is new at every firing. The hook reads agent_id only to class a helper and logs it nowhere (the omissions paragraph below). Raw payloads stay unlogged, and reading them is operator-class under the no-values ruling (§What background_tasks carries).
  • live — yes exactly when the reader reported a live producer. It stays two-valued and paired with verdict.
  • verdict, records — the reader’s exit class and the number of *.run records under the scratch dir, read together. records=0 makes live=no uninformative; records=2 live=no says records existed and their producers had exited. verdict=corrupt carries live=no, so the pair is the reading, never live alone. The pair also names the exit-2 arm, corrupt at records>0 and unresolved at records=0, and the two want different fixes.
  • spawn — the operating system’s own rendering of a spawn error, sanitized to one token by the record’s whitespace rule (for example Text_file_busy_(os_error_26)). It is populated on verdict=unstarted alone and - on every other firing. No errno table is kept: the value is derived, never transcribed. The triage tells from it whether a failure was transient (ETXTBSY, EAGAIN, ENOMEM: a re-run clears it) or persistent (ENOENT, EACCES, ENOEXEC: fix the override). This member’s case assertions print it at a red.
  • runs — the record set the decision was taken over: the run dir’s *.run basenames with the suffix stripped, sorted and comma-joined, - over an empty set. It is the listing records counts, so the count and the names cannot disagree. The triage reads it for whether the guard is working or wedged. A refusal whose run key names a sibling producer is the guard working; one naming the refused session’s own work is a session waiting on itself. A session whose wait wrote a record naming its own pid was refused 21 times in ten minutes, on rows a correct refusal also prints; this field makes that legible at the first row.
  • runs’ honest limit: above records=1 it names the candidate set, not the match. check-producer-liveness prints the matched record on stdout, and the bounded call discards that stream. At records=1 it names the match exactly, and every attested refusal read live=yes records=1 decision=refuse. A superset is still more than the nothing the record carried without it. A run key is a name the writing session chose for its work, not a harness identifier, so the field carries no identity and attributes nothing.
  • verdict=error — a configured reader that ran and did not answer: an unmapped exit code, the bound firing, or a wait that failed on a child that did start. The triage reads it to tell this tree’s override resolves to nothing (unavailable) from this tree’s enforcement is broken (error).
  • verdict=unresolved — a configured, readable reader that ran, exited 2 and held no record to be about. It takes its own name because its fix differs: the reader could not run, where error answered off-contract and unavailable resolved to nothing. It also refuses, so the triage asks of it whether a refusal was diagnostic rather than about a real producer.
  • verdict=unstarted — a reader that resolved and that the operating system would not start. The triage reads it, with spawn beside it, to tell a refused start from a mis-configuration (unavailable).
  • decision — refuse exactly when the hook exits 2, allow otherwise, on every firing. The triage reads it for whether a firing was acted on, and it makes a refusal countable. verdict cannot answer that across the commit landing enforcement: one append-only log spans it, and the same verdict=red line means observed before it and refused after. It derives from neither live nor verdict: a corrupt refusal carries live=no, and a task-view refusal a non-refusing verdict. DELEGATION_KIT_STOP_LOG is capture, so an isolated agent’s line lands in the main checkout’s log, not the reaped worktree (gate-sdk/SPEC.md §The workflow directory). An isolated firing reads the main checkout’s record set (The shared scratch dir is the subject, below), so records and runs there are that set’s. One whose reader does not resolve reads unresolved … refuse over an empty set.
  • keys — the payload’s top-level key set, sorted, nothing more. Read once, at the first firing, to settle what a SubagentStop payload carries without asserting it in advance; it showed a top-level background_tasks. Nothing reads the order, since the field answers a set question and the record is parsed by key; keeping document order would cost a dependency. Values stay unlogged: they would put transcript paths and prompt ids into a file for no reader. §What background_tasks carries settles that key’s contents off this field.

keys is the one free-ish field and stays last, so the space-delimited parse never steps over it, and decision sits before it. runs lands before keys and beside records, the count and the names of one set. spawn sits immediately after verdict, the one value it qualifies. runs is a new field rather than a widened records, whose count keeps its named reader.

The record’s field set is OPEN. A reader parses the record by key, never by position or arity, and the writer’s field set is one table in the member’s own module. A field added to the table appears in the line without any existing reader changing. This settles only that the shape does not lock, never what is logged: the list above is the whole of it.

The record set stands in for the matched run key, which is unavailable to the hook. The bounded call discards both of the reader’s streams, so the hook holds an exit code and nothing else. Recovering the matched key would need a bounded and capturing call, or the hook parsing records itself, which the reuse paragraph above forbids. runs gives the same discrimination from a listing the hook already performs.

Two omissions are deliberate: no session-attribution field and no refusal counter. session_id does not attribute, and the bound on repeated refusals is the producer’s own life, not a count. agent_id and agent_type are refused as logged fields. No reader in this record needs either, because the hook decides on them in process, and both are payload values under the key-names-only ruling. Should a reader appear, a logged attribution field is a new name to file, never a delta to this refusal.

Dropping session= is refused too. This list’s rule is that no field is carried without a named reader, never that every field discriminates every question. session has its reader, so dropping it would delete a live reader to fix a misreading, and runs carries the discrimination session never claimed. runs is a record field, not an identity field: it names the consumer’s own launch records and mints no field the refusals above reject.

records is the hook’s own *.run glob, never parsed out of the reader. check-producer-liveness publishes a count only on its green line, printing one finding per blocking record on red and no total. Same directory, same glob, same number, and the field stays meaningful when no reader resolved. The reader’s first output line is not carried either: it adds only the blocking record paths, and free text would break the space-delimited parse. runs is the same listing’s membership, so this covers both fields.

The glob is taken after the reader has run. A record created between the two would, in the other order, produce records=0 beside a reading that saw it, naming a genuine corruption unresolved. Globbing after does not close the window, which only a lock could, but it inverts how an in-flight record errs. A record appearing during the reader’s run is counted, so the reading is named corrupt and the message points at a record that exists. The residual window is the corrupt arm’s own price.

The refusal message names the finding and the lawful exits, as guard-kit requires of every block message. It branches three ways, one arm per refusing verdict. On red it states that a launch record names a live producer, and on corrupt that a record does not parse. On unresolved it states that the reader produced no reading and held no record. red and corrupt share the two exits guard-kit rule git_mutation_under_producer names. Wait for the producer on its own artifact, in a loop that ends when the condition goes true, or delete the record once the producer has exited. unresolved has no producer to wait for and no record to delete. It names the reader as the thing to fix, plus the isolated-dispatch limit (templates/agent-execution.md) and the lawful response, reporting the gate unavailable and returning. Every arm says the turn may not end on it and names the reader command with the run directory. On red it shows the record set, on corrupt the malformed record, and on unresolved the reader’s own reason for failing.

The red and corrupt arms also name the record set the decision was taken over, the runs derivation rendered for the refused session. At records=1 that hands it the one fact deciding whether to wait or to retract its own record. unresolved takes no such field, since its record set is empty by construction. A run key carries no session identity, and the hook has none to carry.

Folding unresolved onto the corrupt arm is the mistake the three-way branch exists against. A two-way branch would print “a launch record does not parse” over a case holding no record, pointing at a remedy that cannot be carried out. It is the misdiagnosis gate-sdk/SPEC.md §lib/gate.sh guards gate_command’s callers against, a build problem re-diagnosed as a missing gate. A refusal naming no reachable remedy invites a read-only agent to invent a mutating one.

There is no knob, and unwiring the hook is the valve. §The delegation model rules the same for the dispatch guard’s D1 unconditional block. A per-dispatch override is the honour system these rules exist to end, and a knob would restore it under a better name.

The ordinary escape is not the operator’s. Unwiring edits the consumer’s settings, operator-class work no stage session may do, so it cannot be the only recovery path. The record set is the session’s own artifact, and check-producer-liveness names the blocking record. Deleting a record whose producer has exited is, in guard-kit rule git_mutation_under_producer’s words, “not a workaround, it is the statement of fact becoming false and being retracted”. The operator-class valve is the last resort.

The shared scratch dir is the subject. The reading is over ${GATE_SDK_TMP_DIR:-.tmp}, which concurrent sessions in one checkout share, so one session’s record can refuse another’s turn end. guard-kit rule git_mutation_under_producer already binds every session to that set, blocking index-writing git while any *.run record names a live PID. Extending that from committing to ending a turn widens the act set, not the subject. Narrowing the turn-end rule to the record’s writer would leave the two disagreeing about whose producers bind whom on one record set.

An isolated firing reads the main checkout’s set. From a linked worktree a relative scratch dir resolves against the main checkout, the resolution a capture path takes (gate-sdk/SPEC.md §The workflow directory). An absolute one is read as given. The template sends an isolated session’s launch record to the main checkout, and guard-kit resolves its scratch members there from a linked worktree (guard-kit/SPEC.md §Layout and configuration). Read on the relative default, the hook saw the worktree’s own empty dir: a firing logged an empty set and allowed beside a live producer its own session had recorded. So the record-set arm was blind to exactly the producer an isolated child detaches from a foreground call, which the task view does not enumerate either. The consequence is the shared-set rule, unchanged: an isolated child’s turn end is refused while any record in the main checkout names a live producer, its dispatcher’s included, as a child in the main checkout already is. The real-reader lane holds it, firing from inside a linked worktree beside a record in the main checkout.

Honest limit on that reading: the exit-2 split counts the same set. An override that cannot run inside a worktree, beside any record standing in the main checkout, is named corrupt and refuses without the unresolved bound, until the record is deleted.

The log is capture-tier, gitignored and advisory (gate-sdk/SPEC.md §The workflow directory), and declares itself on the close-surface roster (lifecycle-kit/SPEC.md §The close-surface roster) with its rotation as the reclaim path. Close reads the drain file that command prints, never the live log, then removes it with --done:

close-surface: .workflow/subagent-stop-liveness.log advisory reclaim=bash gate-sdk/bin/run-gates.sh –emit capture-drain .workflow/subagent-stop-liveness.log

advisory rather than forced, on the reasoning guard-kit’s friction log takes: nothing refuses a close that skips it, and a visible skip is the honest mode for a log whose enforcement lives in the exit code rather than in the file.

No gate observes the registration. check-settings-pins asserts only the pinned paths in the consumer’s pins file, none under hooks. check-settings-paths resolves a registration that is present, its script path and its --hook member (context-kit/SPEC.md §check-settings-paths), but never asks that one is. So an absent registration reds nothing, and an unwired tree has no turn-end enforcement with nothing in the battery saying so. A session decides the wiring by reading this section, never by predicting a verdict.

The resolved reader is observed, on scripts/gate-tests/subagent-stop-reader.test.sh, which fires this repo’s wired --hook subagent-stop-liveness arm over a scratch run dir it builds. An empty dir must read green and allow, with runs the absent token. A record naming an always-alive PID must read red, decision=refuse and exit 2, with the record’s key in the row and the message. The reason names the argv the hook spawned, so the lane sees the default resolving. A real, spawnable override producing no reading over an empty dir must read unresolved decision=refuse, the isolated-dispatch shape; unavailable there fails the lane by name.

runs carries a firing and a non-firing case in both lanes, the fixture-pair discipline transplanted to a hook member, which owes no good/+bad/ pair. A firing over records asserts the row names their keys, sorted and comma-joined; a firing over an empty dir asserts the absent token, so an absent set is told from a field rendering nothing.

The arity is under test, because the line zips the field table against a values array and a length mismatch truncates silently. One case asserts the two arrays agree and one asserts the line’s field count. A third lists every field in its expectation rather than a subset.

check-crate-arms reaches a hook member in this repo. Its couples list carries a native/src/*.rs-shaped glob, and a couples= pattern matches as POSIX case matching does (gate-sdk/SPEC.md §The # graph: manifest), so * matches /. Read as a filesystem glob it would look blind, and that misreading would hide a real blindness elsewhere: verify the predicate, never the manifest line alone. Where a trigger does not reach, run the gate explicitly in the landing commit, bash gate-sdk/bin/run-gates.sh --only check-crate-arms. Building the binary runs neither arm.

The stub-driven lane lives in the member’s own module. It drives a stub reader through every arm, corrupt and unresolved once over a record and once over an empty dir. It asserts the exit code, the decision column and the refusing arms’ stderr wording apart from the verdict, with keys sorted. It also carries the resolution. The empty knob resolves to the running executable and the gate name, and an override to itself with the run dir appended. An interpreter-led override resolves on PATH, and an override lacking the execute bit or naming no program resolves to no reader. The real-reader lane reaches neither corrupt nor error: the stub lane chooses a reader’s exit class, and the real lane proves the resolved reader runs.

The compiled lane pins unstarted rather than leaving it to a race. Its stub’s shebang names an interpreter that does not exist, so the spawn fails with ENOENT every time. It asserts verdict=unstarted, a spawn value that is not -, decision=allow and exit 0. Every assertion on a firing’s exit code prints that firing’s log line, so a red names its verdict and spawn value.

A reader stub is written by a child process, never by the test process that spawns it. The module’s cases run as threads of one process whose sibling tests fork children. A sibling forking while a stub’s write descriptor is open hands its child a copy, which lives until the child’s exec, since O_CLOEXEC closes it there and not at the fork, so a stub spawned in that window fails with ETXTBSY. A test process that never opens the stub for writing has no descriptor to hand on. The cause is inferred from measurement, never traced: the reds were too fast for the reader bound and fell only on freshly written stubs. spawn names it at the first residual red. A retry on ETXTBSY is refused, since it weakens the assertion in a test and changes a refusing hook’s behavior. A lock is refused, since it cannot reach other modules’ forks.

Attribution was weighed and is not available

The payload’s session_id is shared by a dispatched agent and its dispatcher, and the pid=<n> run=<key> grammar carries no writer identity. Adding one is a grammar change across evidence-kit and guard-kit for a narrowing the shared-scratch-dir paragraph argues against wanting.

runs leaves this ruling standing. The ruling is about identity: who wrote a record, whose turn end is refused. runs is about shape: which records the reading ranged over, a fact about the record set and no session.

Two firings stay out of the record set’s reach. A launch the harness moves to the background on its timeout writes no record, guard-kit rule background_no_record’s stated residue. The harness’s own background_tasks view enumerates what was launched rather than what is running (§What background_tasks carries), so it supplements the record set and cannot substitute for it.

The session’s own firing is its turn end, and it delivers. A dispatched session that ended its turn while its own .run record named a live producer logged decision=refuse. The reason reached it as stop-hook feedback, and it resumed work on the producer. A turn end taken while holding a running shell task was refused the same way. When the producer exits, the next firing reads verdict=green decision=allow, so the refusal window closes with the producer’s life. Firings between a session’s steps are a helper’s (A harness helper’s firing, above), and the helper test tells a helper from a session, never one of a session’s firings from another. All of this sits under the contract’s limit: only a fresh firing catches a harness revision.

What background_tasks carries

The keys field settled that the payload carries this top-level key. A one-off read took its contents out of band, without touching this hook: the consumer’s hook copy dumped five firings’ raw payloads to gitignored scratch and was restored byte-for-byte.

The ruling: the hook logs key names only, and the read strengthened that. The only reader a value log would have had was substituting the harness view for the record set, and the read falsified it. A derived non-value, a count or a type tally, is refused on the same ground: it serves no reader. Should a reader appear, it reopens as a new question.

It is a live-children enumeration. An array of objects in two shapes, both carrying id, type, status and description: a type of subagent adds agent_type, a type of shell adds command. It spans more than the emitter: the emitting session, its backgrounded shell task and its dispatched grandchild all appeared with status running. A child holding no task of its own was refused on a running shell task its dispatcher held. Whether it spans a sibling’s tasks is untested. Alongside it the payload carries transcript_path, cwd, prompt_id, permission_mode, agent_id, agent_type, stop_hook_active, agent_transcript_path, last_assistant_message and session_crons, beside the three fields the grammar reads. §The turn-end liveness hook’s session bullet owns what agent_id attributes.

It enumerates what the harness launched, not what is running. A producer detached from a foreground tool call, live and carrying a *.run record the reader reported red, appeared in no firing. That is the unrecorded reading of live=no §The probe is asymmetric names, and the class guard-kit rule background_no_record refuses at launch. So the view cannot substitute for the record set: the two disagree on the class the waiting rule exists for. The hook supplements with it, refusing on a running shell element (§The turn-end liveness hook).

No entry carries a pid. Those six field names are the whole schema, and a shell entry’s id is the harness’s own opaque task id. Joining a record’s pid=<n> would mean parsing command, the value the ruling withholds. Ownership is read off the emitting session’s transcript instead, which records each launched task’s id, so the join needs no value beyond id.

The privacy ground is wider than first written. command is a verbatim shell command line and description is free-text prose. Logging them would put command lines and task prose into a capture-tier file in whatever tree vendors this kit, beyond the transcript paths and prompt ids the ruling first named.

The probe is asymmetric, and no reading may treat it otherwise

A live=yes line proves the harness does not defer the stop. One firing with a live producer settles the unknown in that direction.

No accumulation of live=no lines proves that it does. live=no is equally consistent with the harness deferred the stop, the session waited correctly, and the session recorded nothing. The last is refused at an explicit launch by guard-kit rule background_no_record and still reachable through a call the harness moves to the background on its timeout. Passive accumulation cannot return the finding that would dissolve the class.

So the reading is bought by a deliberate firing, never by a wait. A dispatched session backgrounds a long producer, writes its <key>.run record, and ends its turn. If the hook fires at once with live=yes, the harness does not defer, and §Operative residency’s axis holds at the turn end. If it fires only when the child exits, the harness defers, the class dissolves, and this hook’s value is the evidence for retiring it.

The firing returned the first branch. A dispatched agent backgrounded a producer with a recorded PID and ended its turn without waiting. The log took live=yes verdict=red records=1 four seconds later, and again at the agent’s own stop, while the producer wrote on for two and a half minutes. The harness does not defer the stop, so the class stands, the relocation to guard-kit rule git_mutation_under_producer stands, and the hook blocks (§The turn-end liveness hook).

The same firings raised whether SubagentStop is the session-end event. It fired seventeen times inside one dispatched session that had ended no turn, spaced by assistant steps rather than by the clock. Those firings were a harness helper’s, and a helper’s firing allows (§The turn-end liveness hook, A harness helper’s firing). The session’s own event is its turn end.

Resume journal — agent writes, scratch reset sweeps

The contract, in four clauses. An agent whose run an interruption could cost, mutating or read-only, journals to a repo-local gitignored scratch directory in the main checkout. An isolated agent writes there by a shell append, since the harness refuses its file-tool writes there. The supervisor grants that path absolute in the dispatch prompt, each finding lands inline as it is confirmed, and a DONE marker is appended on success. The operative text, with why a temporary worktree and a system temp dir both fail the survivability test, is templates/agent-execution.md’s Resume journal — agent writes, scratch reset sweeps bullet. This section owns the lifetime rule’s reasoning and the two readings the marker carries.

A stage session takes no grant. Where the consumer runs a stage machine, the journal’s path is a derivation from the stage that the machine computes. Its entry tool opens the journal and reports the path to the entering session, and its journal arm appends there for a session that spells no path (lifecycle-kit/SPEC.md §The state machine, §The journal arm). That report is the path’s one source. A grant restated in the prompt is a second source the session prefers, so its journal lands under a name the next entry cannot find. The derivation’s artifact on disk replaces the grant as the record of what the session owes; every other mutating child is still granted its path. This kit owns the journal contract and states no path convention, and that kit owns the path and the arm that resolves it and states no contract.

The file may pre-exist the agent. Under such a machine the shape is the machine opens, the agent writes, the reset sweeps: a journal at its derived path may carry only an opening line naming the session that owes it. The four clauses are unchanged, and the agent appends its findings and its final DONE under whatever the machine left. A journal’s presence already signals nothing (below); the predicate that reads past the opening line is the consumer’s own (lifecycle-kit/SPEC.md §bin/enter-stage.sh).

Lifetime — retention, not an eager deletion chore. Cleanup is the consumer’s own scratch reset at its next work-unit boundary (here, --enter-stage’s wipe of .tmp/ at the next scope entry, lifecycle-kit/SPEC.md §bin/enter-stage.sh). A supervisor deleting on its own schedule breaks two things once an agent is resumable. The resumed agent is still writing to the file, and a supervisor reading the journal as its pull channel (lifecycle-kit/templates/lead.md §Channel design) destroys that channel at the first verified commit. A one-shot sweep hides both. The costlier harm is the false signal: a resumed session that finds its journal gone mid-write reports a scratch-directory reliability defect against an unrelated unit.

Reading DONE under retention. Every journal survives its session, so finding one signals nothing by itself. DONE counts only as the file’s last line, since a session resumed past its own marker appends after it. A missing marker means interruption only where the supervisor never consumed the agent’s return. On the ordinary path that return plus its post-commit verification (§Verify after every agent commit) is the recovery contract, and the marker is redundant. In a cold read, a journal whose session died before returning (a sandbox died, a crash, a timeout), the marker is the sole signal: no DONE means interrupted, so resume from it. The consumer’s work cursor and git log tell a live journal from a spent one, and the boundary sweep bounds the ambiguity to the current work unit. The inline-findings rule insures the cold read, since a pointer-only journal would make a DONE lie about recoverability.

A shared journal answers the same question. Per-session naming discriminates only where the dispatcher chooses the path. Under a stage machine the journal is named for the stage, so several sessions of one stage append to one file. The last-line rule makes it answer did the session that wrote last finish, which is the question, and an earlier session’s mid-file marker is no claim the later content contradicts.

Caveat — a journal write once failed, cause unexplained. A background agent could not Write to its granted path and silently returned its findings in its final message instead, defeating the journal on the long, interruptible run it exists for. The template’s explicit-grant clause follows from this, and so does its rule that a read-only fan-out’s return value stays the contract with the journal beside it.

The caveat is no property of isolation. A worktree-isolated agent reaches an absolute main-checkout path by a shell write, though the harness refuses its file-tool write (measured, §The delegation model). So an entry-time assertion on a journal’s existence is fair: a missing journal is a session that did not write, not one that could not. One observation of capability does not falsify one of failure, so the remedy stands. Its content is do not make recoverability depend on a backgrounded child’s write. The template’s Findings you will act on are durable before you act on them rule binds the other end, the parent, which has shown it can write; the two are disjoint readings of one contract.

Journalling is a discharge path, keyed on capability, never role. A session journals when it cannot commit and commits when it can. A role-keyed reading granted the journal to dispatched sessions alone, on the premise that a top-level session can always commit. That premise fails whenever another session holds the shared index, the normal condition while a work unit is in flight. Improvised scratch then carries none of the contract (no path inside the sweep’s reach, no inline landing, no marker), so the loss is silent. Every discharge path lands in the scratch directory the boundary already sweeps, so the widening adds no lifetime, keep-list entry or triage surface.

The provenance floor. A dispatching session can narrate a finding from a subagent output it never received, and nothing reds. The operative text is the template’s A return you did not receive is not a finding, and an identifier you did not read is not a citation bullet. This section holds why one of its halves gets a rule instead of a gate.

No gate reaches the relayed-return half, by proof. The tree cannot observe what a session received. A return lives in the parent’s context and leaves no artifact, so tree state is byte-identical whether the return was held or invented. Arrival can be unobservable to the parent as well: a child that could not reach its dispatcher by name delivered to the top-level session instead. A gate would have to assert a fact about a conversation, which no repository scanner reaches. The minted-identifier half leaves a token, so it is gated one record at a time (lifecycle-kit/SPEC.md §check-survey-record).

The mitigation is the journal, a bound and never a fix. The journal lands a child’s output on disk, so a parent’s claim is checkable wherever the child wrote one. That narrows the class’s reach without closing it, since the child’s write can fail silently (the caveat above).

Verify after every agent commit

Re-run the relevant gates plus the consumer’s validate battery: for this repo and any gate-sdk consumer, bash gate-sdk/bin/run-gates.sh and the kit fixture runners. A toolchain consumer adds its compile, lint and test set in the template’s marked section, with a zero-byte-file sweep after renames, since silent edit corruption is real. Diff every gate change in an agent commit before accepting it. An agent blocked by a gate will weaken it with a false exemption rather than fix the code. “The gate is in my way” almost always means the code does not fit the convention.

The act is verify; the artifact stays the validate battery. A supervisor performs the act, and the battery is the command set the act runs. One word for both made completing the act read as completing whatever else the consumer names with that word, a stage among them. The battery’s name is a binding slot every consumer’s shim carries (§One template, a resident pointer, read by check-skill-binding), and the act is named only in prose, so the act takes the distinct name.

A verify step never runs the producer of what it is verifying. Re-running is safe while the unit’s output is work and the check’s output is a verdict about that work. Where the deliverable is an evidence artifact, the verify is a read of the committed artifact, never a re-execution of its writer, which mutates or duplicates the very record the check exists to confirm.

The two wrong re-runs differ in harm. A check that writes nothing the artifact depends on is inert on it: misrouted, wasted, and nothing lost. Re-running the artifact’s sole writer is destructive and leaves evidence to restore. A supervisor that already ran the wrong one reads this split to learn which it holds.

Honest limit — this rule installs no oracle. The misrouting recurred, and an operator caught it each time. Catching it needs a gate over a supervisor’s choice of command, which leaves no tracked artifact to read, the reason §Operative residency owes no gate. A consumer whose evidence producer claims a liveness lock has a partial artifact-side proxy, whose boundary that consumer states. Detection stays human, and the limit is stated so no later session reads it as an oversight to close with a gate that cannot exist.

check-gate-tamper is the mechanical floor under the diff-every-gate-change duty. Two assertions block the two tamper shapes:

  • A — gate-edit isolation. A commit that touches a gate file may touch only meta-layer paths: DELEGATION_KIT_META_PATHS prefixes, root *.md, and the install manifest checkwright.lock at the toplevel. Co-staging product code with a gate edit is blocked; split the gate change into its own commit. The manifest is the installer’s record of the vendored tree, so every update and profile move co-stages it with the gate files it rewrites, and counting it as product code would refuse that commit under the hooks it vendors. Where the gate’s spec pointer names a section outside the meta layer, the order is forced: the section lands first, green with the gate absent, and the gate second. A gate file is a path a DELEGATION_KIT_GATE_FILES glob matches, or a registered member’s resolved declaration, or the gate-sdk library. The reader unions the last two in, each spelled against the toplevel, so no consumer value can leave a registered gate or the library uncovered.
  • B — no self-serving exemption. A newly added path or glob entry in any gate’s # exception-list: array must not match a file staged in the same commit: an exemption never excuses the change it lands with. Its report lines are byte-sorted, a set rather than an incidental order.

Honest limit: the gate blocks by commit shape, so it misses semantic weakening inside a legitimate scripts-only commit, and the by-eye diff review of agent gate edits stays a supervisor duty.

checks/check-gate-tamper.gate (precommit, binary-dispatched). Live mode reads git diff --cached. --fixture <dir> injects staged-files and added-exemptions lists for the fixture pair, and a trailing --fixture with no directory is refused. The flag stays on the compiled gate: it selects the rule’s own input corpus, which no knob resolves, so it is not the arrives-too-late shape gate-sdk/SPEC.md §The non-gate arm deletes.

The union is built in both modes. A registered member resolves through the battery’s own order: the gates dir first, then each kit root’s checks/, .sh before .gate inside a dir. A member resolving nowhere adds nothing, since resolving it is another gate’s job. An absent gates.list adds nothing, and one that exists but cannot be read is exit 2. The library is <GATE_SDK_ROOT>/lib/gate.sh, dropped when the root lies outside the toplevel, where no staged path can name it. Each element is a literal, so matching it is equality, and the union is additive, never filtering a declared glob. The clean line reports the union’s size (<m> registered gate file(s) covered), and assertion A’s finding names the declared globs “plus every registered gate and the gate library”.

Criterion 4 binds on this gate on every tree. The union puts every registered kit declaration among the gate files, check-gate-tamper.gate included. Staging the gate’s own declaration makes it read its own bytes whatever the consumer’s globs say, so no configuration clears it (gate-sdk/SPEC.md §The port-candidate criteria carries the class).

The tier binding

A consumer binds each tier class to a model, and optionally an effort, once, and every place that chooses a tier reads the binding. The classes are the consumer’s roster, DELEGATION_KIT_TIER_CLASSES, highest first, and its default is the kit’s three: judgment (design decisions), routing (a split-posture lead’s own turns, lifecycle-kit/templates/lead.md §The lead model) and mechanical (oracle-running, read-heavy audits, rename and merge sweeps). A spec stage’s design-bearing work class maps to judgment and its mechanical class to mechanical. The order matters only to a floor check, which reads “at or above”. DELEGATION_KIT_TIER_MODEL holds the binding, one <class>=<model>[,<effort>] element per class, read by --emit knob-values DELEGATION_KIT_TIER_MODEL. An unbound class is one no element names. An empty binding is off: nothing below fires, and tiers stay each dispatcher’s to name. A foreign vendor’s harness binds the same classes in a table of its own, and a foreign run names the class it rides (§The foreign-vendor run).

A row binds a model and an optional effort. The element splits at its first =, and the value at its first ,: the model before it and the effort after, an effort being whatever its harness calls a reasoning level. A value with no comma binds a model and no effort, and effort then stays with the dispatcher. Every rule below that names a bound value reads the model half. The kit holds no effort vocabulary and no order over efforts: the levels are a harness’s own and churn with its roster, the ground on which it ships no model value. The one read returns both halves. A resolver arm printing a class’s pair is refused, since it would be a second spelling of that read and its one caller already holds the row.

The roster is consumer config, and every reader of the class vocabulary takes it: both tables’ validator, --model-verdict’s --expect check and its ranking, and --foreign-run’s --class check. routing is a default member and no constant. Its reader is the split posture, which a consumer may not adopt, and one that splits its lead by session binds routing to its judgment model or drops it from its roster. Honest limit: the shipped templates name judgment and mechanical. A roster without one of them leaves the template step naming it at --model-verdict’s exit 2, tier unverified. The validator does not refuse such a roster, because a consumer who forks the templates may rename every class.

A foreign harness has a table of its own, in the same grammar. DELEGATION_KIT_FOREIGN_TIER_MODEL holds a foreign harness’s rows, <harness>/<class>=<model>[,<effort>], read by the same parser, and DELEGATION_KIT_FOREIGN_HARNESS names each class-taking adapter’s harness. A harness is a name for one vendor’s model roster. Several adapters of one vendor differ in mode, sandbox and session handling and share a harness, so a model move is one row. It is one design in two knobs because of where each table lives. A knob named in a file is replaced whole (gate-sdk/SPEC.md §The knob file), and a vendor’s rows usually sit in the gitignored overlay beside the adapters. Master rows in that overlay would replace the tracked ones a freshness gate reads. The executor resolves a run’s class through the two (§The foreign-vendor run).

Why a binding. Without one, the tier a dispatch rides is written as a harness model alias wherever it is chosen — a definition’s model:, a lead’s per-stage overrides, a batch’s model parameter — and the class-to-model mapping lives in each dispatcher’s head. Effort is chosen the same way, at each dispatch. An alias resolves to whatever model the harness currently names by it, so a roster move changes every dispatch’s price and behaviour with no edit anywhere, and no setting chooses between following and pinning.

A value is an alias or an exact model id, and the choice is the consumer’s. An alias follows: the harness resolves it to the newest model it names by it. A model upgrade then arrives with no edit, and so do its price and its behaviour. An exact id pins: it stays until the consumer changes it. The kit ships no value, because the harness roster churns and the values are consumer config (gate-sdk/SPEC.md §The provenance seam).

Matching. A running model matches a value when the value equals its id or equals one of the id’s --separated tokens after the first. So an alias matches every id in its family and an exact id matches only itself. The kit holds no alias list, because the harness’s ids already carry their family as a token. The leading token is skipped because it is the namespace every id shares: a value equal to it would match every model and hold no tier. The table validator sees the binding and no id, so it cannot tell a namespace spelling from an alias. The skip therefore makes such a value match nothing, and every reader reports it (D6 blocks, --model-verdict reads UNBOUND) rather than matching every id silently. The validator refuses the two spellings it can see: an all-digit value, a version token that crosses families, and a value equal to the leading token of a different bound value. One value bound to two classes is not that second spelling, and passes. Honest limit: a harness whose ids lead with their family reaches it by exact id only.

The master binding is read at three places. A definition’s model:, and its effort: where the class binds one, are generated from the class its tier: field declares (§check-agent-tier-explicit). A dispatch naming a model is held to a bound value (§The delegation model, D6). A session reads the class of the model it runs on, and its effort beside that class’s (§model-verdict).

Honest limit on pinning. This harness takes a per-dispatch model as an alias only: the dispatch tool’s parameter is an enum of aliases. So a pinned class cannot ride a per-dispatch override, and a consumer pinning a class it reaches by override dispatches it as a type whose definition carries the pin instead. The definition’s model: does take an exact id. Measured: a definition pinned to one family’s exact id, dispatched from a session running another, ran on the pinned id, which its transcript’s message.model carried on every record. A harness loads its definitions at session start, so a pin reaches only sessions started after it lands. Pinning an alias’s own resolution is harness configuration (its default-model environment settings), outside this kit, on the reasoning §The delegation model applies to worktree.baseRef.

Following is a strength with a stated cost. An upgrade under an alias changes price and behaviour silently. What surfaces it is a new id in the transcripts, where every assistant record carries the model its turn ran on (message.model): drift-kit’s --price-coverage names an id its price table cannot price (drift-kit/SPEC.md §The price-coverage arm), and the lead reads it before its first dispatch. Under a pin, a new id in a pinned class’s transcripts means the pin was bypassed, and --model-verdict reports it as a mismatch.

Two shapes were weighed and refused. Three scalar knobs, one per class: a scalar takes an environment override under its own name (gate-sdk/SPEC.md §The knob file). The binding feeds a freshness gate (§check-agent-tier-explicit), so a session exporting an override would red that gate against a tree nothing changed; an indexed knob takes none. Keeping model: hand-written and checking only that its value is some bound value: two classes bound to one alias make that check blind, and rebinding one of them then leaves every definition that meant the rebound class silently wrong. A definition has to say which class it is.

check-agent-tier-explicit

Every agent definition under DELEGATION_KIT_AGENT_DIR declares a model: field in its frontmatter (assertion A), and, where the tier binding is set, its model: is the bound value of the class its tier: field declares (assertion B). The gate is the oracle over the tracked half of the template’s Match the dispatched model and effort to the unit’s shape rule: the per-dispatch habit leaves no artifact, but a standing choice does.

Assertion A polices silence, not the choice. An explicit inherit passes, since a type that should ride its dispatcher’s tier is a legitimate answer and the gate has no business overruling it. What reds is omission, the one state that looks like a neutral absence and is not one, because an omitted model: falls through to the dispatcher’s tier — the refused environment override and the precedence chain behind that reading are in §The delegation model. A reader arriving at this gate expecting it to enforce cheapness has the wrong model of it.

Assertion B holds a generated copy. The harness reads model: and cannot read the binding, so the field is a projection of the binding through the definition’s class. bash gate-sdk/bin/run-gates.sh --emit agent-tiers [--write] generates it: for each definition whose first frontmatter block carries tier: <class>, the expected model: is that class’s bound value. Bare, the arm prints one line per tiered definition, <path>: model: <current> -> <expected> or <path>: current, and exits 0. --write rewrites the model: line inside the first frontmatter block, or inserts one after tier: where none exists, and touches no other byte. Where the class binds an effort, effort: <effort> is generated and byte-gated the same way: --write rewrites that line where it stands, or inserts it after model:, and the bare line reads <path>: model: <current> -> <expected>, effort: <current|-> -> <expected>. Where the class binds none, an effort: line is left as written, the footing a definition without tier: has. The arm writes nothing until every definition has resolved, so a definition naming an unbound class leaves the tree untouched and exits 2, naming it. A definition without tier: is left as it is, and so is every definition when the binding is empty, where the arm prints binding off.

The arm is an --emit- member, since its exit grammar is the collapse; it writes only inside the tree and spawns nothing, and its declared knobs are DELEGATION_KIT_AGENT_DIR, DELEGATION_KIT_TIER_MODEL and the walk’s two prune knobs. The harness ignores a frontmatter key it does not recognize, so tier: costs a definition nothing; it is kit vocabulary in a harness file, the footing name: and model: already have as the fields the kit reads.

B reds on a non-inherit model: with no tier:, on an unbound class, on a stale model line, and on a stale or missing effort line under a class that binds one, each finding pointing at --emit agent-tiers --write. It calls the arm’s rewrite in process and compares whole-file bytes, so the arm and the gate cannot disagree about what --write produces. A definition stating model: inherit with no tier: passes B, on A’s ground. B is off while the binding is empty, and the clean line names the state: binding off, or the count of definitions holding their class’s bound model. The descriptor declares the definitions its projection, and its knob file reaches the trigger through the derived couples (gate-sdk/SPEC.md §The # graph: manifest), so a binding edit re-runs it at commit.

Counted inertness. A consumer with no such directory, or one holding no definitions, scans zero and reports a clean counted line, the derived-scan-set shape the kit’s other gates use. The scan set is the directory’s contents.

Honest limits. It holds the tracked surface only. A dispatch naming no agent type and no model parameter inherits and leaves no artifact for any gate to read, which D5 reaches at the dispatch where it is switched on (§The delegation model). A definition’s effort: reaches only sessions started after it lands, as its model: does, and a harness environment variable that forces every session’s effort overrides it, the shape §The delegation model refuses for the subagent-model override; --model-verdict’s effort clause is what surfaces either. That a definition’s effort: sets its child’s effort is read from the harness’s documentation and not measured here.

usage-verdict

Emits a trustworthy budget verdict from a usage snapshot file, closing the three failure modes a raw percentage reading leaves open:

  1. Stale reading — now - updated_at beyond DELEGATION_KIT_STALE_AGE → STALE (exit 2): budget-unknown, never blocking delegation — re-read or refresh before trusting the number.
  2. Dead window — resets_at <= now → RESET-OK (exit 0): the percentage is from the dead window and must not be read as a pause signal. The weekly axis carries the same rule per-axis (seven_day_resets_at <= now disarms the weekly pause without forcing the whole verdict to RESET-OK — the axes are judged independently).
  3. Post-login lag — a fresh login starts a new window but the server-fed percentage lags it, and the file-write age check cannot see that. The verdict compares the snapshot’s account with the live identity, the account config’s oauthAccount.accountUuid. A snapshot naming a different account forces the demand-driven refresh past its short-circuit before the read, and one still naming it after that refresh routes a would-be OK to STALE (snapshot predates an account switch; …). The same account trusts a snapshot taken at least DELEGATION_KIT_LOGIN_SETTLE seconds after the credentials file’s mtime, the auth event. Where either identity is missing, the verdict falls back to the blanket reroute: within DELEGATION_KIT_LOGIN_WINDOW of the mtime, a would-be OK routes to STALE. Letting the lag print OK would emit a fresh-looking chimera, the new account’s id beside the dead login’s percentage and resets_at. The two time-keyed arms are self-limiting, since once the settle floor or the window passes they stop firing; the account-switch STALE holds until a producer writes the live account’s snapshot.

    The reroute is asymmetric: it may suppress an OK, never a PAUSE, so it follows the axis compares rather than preceding them. The two directions of a lagging reading have opposite costs. A lagging low percentage printing OK invites a dispatch the budget may not afford, which the reroute exists to prevent. A lagging high percentage suppressed into STALE waves that dispatch through, because STALE never blocks. So the reroute fires on the non-blocking outcome only: on every arm, account-switch, settle floor and blanket window alike, an at-or-over reading still exits 1 on whichever axis fired, and an under-threshold one still exits 2. An account switch swaps both windows, so neither axis escapes the suppression when the outcome is a would-be OK.

    The mtime is a proxy, and the roll witnesses refute it. A credentials file’s mtime cannot tell a login from a routine OAuth token rotation, which rewrites the same file. So every rotation mints a fake auth event and blinds the verdict for the window’s duration, and rotations cluster around long-session resume, exactly when a verdict is asked for. The reroute’s premise is that a low percentage inside the window is a lagging reading. Once the five-hour window has demonstrably rolled, a low percentage is the expected reading and the premise is false. The verdict consults two witnesses of a roll, conjunctively, since either alone is ambiguous:

    • the snapshot’s five_hour_resets_at differs from the resets_at of the newest DELEGATION_KIT_USAGE_HISTORY sample for the snapshot’s account (the boundary moved); a snapshot carrying no account reads the newest sample of all;
    • the snapshot’s updated_at is past that previous boundary (time actually crossed it, rather than the boundary being restamped by an account switch).

    Both firing disarms the two time-keyed arms, the settle floor and the blanket window, and the reading verdicts on its own merits. They never disarm the account-switch STALE: its premise is a reading that speaks for another account’s windows, not a lagging one, and a roll of that account’s window says nothing of the live account’s. The verdict path is therefore a reader of the trend log it appends to, and it reads before this run’s own append, so the witness is always the previous reading. It falls open to the unrefuted reroute whenever the log cannot answer: knob unset, file absent or unreadable, no sample for the snapshot’s account, or a newest sample carrying no numeric resets_at. A first reading after a swap therefore falls open rather than comparing against another account’s boundary.

    Honest limit: where the snapshot carries no account or the account config is unreadable, a rotation with no roll still blinds the blanket window, because no witness contradicts it. A swap that changes neither the credentials file nor the account config is invisible to every arm. A producer stamps account from the account config at its read, not from the token behind its percentage. So a swap that rewrites the account config and leaves that token in place labels the old token’s reading with the new account, and the comparison passes it.

The rule is a compiled arm, bash gate-sdk/bin/run-gates.sh --usage-verdict, an Arm::Run row that cannot be an --emit- member. The emitting family maps every success to 0 and every failure to 2, and this rule’s 1 is the whole blocking signal the budget guard grades. So it takes its own flag spelling and front-end case arm, as --upgrade-smoke does (gate-sdk/SPEC.md §The non-gate arm). It is not a harness-integration arm either: its callers are a session brief, a kit smoke and a gate reaching it in process, the caller set that subsection’s definition excludes. With no binary on the host the front-end exits 2, this rule’s budget-unknown code, so an artifact-less host reads as STALE rather than as a verdict it never took.

Twelve declared knobs, each a row of delegation-kit’s static table: DELEGATION_KIT_USAGE_FILE, _CRED_FILE, _ACCOUNT_CONFIG, _PAUSE_PCT, _PAUSE_PCT_7D, _STALE_AGE, _LOGIN_WINDOW, _LOGIN_SETTLE, _REFRESH_CMD, _REFRESH_MIN_AGE, _USAGE_HISTORY and _FAN_WIDTH (§Layout and configuration). The three paths are read through the reader’s one empty-means-derive fill, which reads all three together, and the verdict opens _ACCOUNT_CONFIG for the live identity. A read of a name the table does not carry is an error rather than an empty value, so the roster and the table are one change and never two.

Two positionals and one refusal. [usage-file [credentials-file]] arrive as the arm’s own argv and override DELEGATION_KIT_USAGE_FILE and DELEGATION_KIT_CRED_FILE. They are arguments the rule consumes rather than redirections of resolved config (gate-sdk/SPEC.md §The non-gate arm’s distinguishing test). A positional beginning with - that names no option is a refusal, usage on stderr and exit 2; -- ends option processing; -h/--help belongs to the front-end (gate-sdk/SPEC.md §The bin/-tool contract). The refusal is not decoration: an absorbed flag would report cannot read --help … -> STALE at exit 2, which the budget guard routes to advise, a non-reading dressed as a reading at the decision point the rule exists to hold.

One rule, two callers. verdict(args) -> (String, i32) returns the line and the status; the arm prints the line and returns the status, and agent-budget-guard calls that same function in process, grading code == 1 as its block and relaying the String verbatim (§The delegation model). A shape refusal returns an empty line with its usage already on stderr, so the arm prints nothing on stdout and the status carries the whole signal. The one program the compiled rule spawns is DELEGATION_KIT_REFRESH_CMD’s argv, spawned directly with no shell, and only when that knob is non-empty, since it is a command seam. The rule’s checks over a parsed snapshot are one function beneath verdict, which the foreign executor reaches in a second order and without this caller’s identity arms and sample (§The keyed verdict).

Check order: parse → RESET-OK → age-STALE → pause axes → account-STALE or login-STALE → OK. The RESET-OK branch is the same decision one step earlier and is ordering precedent for the witnesses: the file already accepts that a demonstrated roll outranks the reroute. It covers only a snapshot from an already-dead window; the witnesses cover a window that rolled between samples, where the re-polled snapshot carries a live resets_at.

Exit codes: 0 OK / RESET-OK, 1 PAUSE, 2 STALE or unreadable. Exit 2 is fail-closed on trusting the number and deliberately fail-open on refusing work: budget-unknown never blocks delegation, so a reading that should refuse an expensive dispatch never lands on it. Fail-closed throughout:

  • missing keys and a non-numeric percentage route to STALE;
  • each threshold compare is a float compare, so a fractional percentage cannot silently skip PAUSE;
  • both pause compares are at-or-over (>=), so a reading exactly at DELEGATION_KIT_PAUSE_PCT or DELEGATION_KIT_PAUSE_PCT_7D pauses.

Demand-driven refresh. The decision point triggers the poll: when DELEGATION_KIT_REFRESH_CMD is non-empty, usage-verdict spawns its argv before reading the snapshot, so every verdict caller reads live data rather than whatever the last statusline render left. That closes the push producer’s blind spot at the point of use, since a lead that delegates stops rendering exactly when it goes static. Empty, the default, keeps the read-only behavior.

The refresh is short-circuited by DELEGATION_KIT_REFRESH_MIN_AGE: the command runs only when the snapshot is missing, unreadable, or at least that old by its updated_at. A snapshot whose account differs from the live identity is re-polled whatever its age, since its reading speaks for another account’s windows. The verdict’s callers fire in bursts rather than on a schedule, once per Agent call from the dispatch guard and once per session from the session-start brief. Without the floor a configured refresh would hammer the source across a fan-out, and with it a stale-enough snapshot still polls at dispatch time.

It is fail-soft: a non-zero refresh exit leaves the snapshot untouched and the verdict proceeds on the cached file, whose staleness the checks above judge, so a dead producer reads STALE rather than a silent green. Refresh diagnostics are suppressed rather than mixed into the verdict line callers relay verbatim; the snapshot’s age is the signal. usage.txt stays the last-known-good cache, the source-agnostic seam and the test seam. A refresh soon after an auth event rewrites updated_at while the server-fed percentage may still lag the login by about a minute. So the reroute keeps those readings STALE for the settle floor, or for the blanket window where the identity is unreadable, save the at-or-over ones it may not suppress.

The verdict-string contract. Every line usage-verdict emits carries three parts: the reading (the measured fields), the epistemic status of that reading (how far to trust the number), and its decision consequence for delegation (what the reading gates). A verdict string stating a status without its consequence is incomplete by contract — the reader completes it by inference, and the inference is unconstrained: a session reading a bare STALE infers “do not trust the number” into “do not dispatch”. That read is wrong (STALE never blocks) and is structurally uncorrectable downstream, because the budget guard fires on dispatch, the act a hesitating session has not yet performed. STALE’s status half is site-specific — each emission site names why it is stale — while its consequence half is uniform, because the consequence is uniform: STALE is budget-unknown at every site and never blocks delegation. The consequence clause appends after the -> <verdict> arrow, leaving it disjoint from the width=<n> invariant below.

The width=<n> field. Every verdict line, the fail-closed STALE diagnostics included, carries width=<n> immediately before the -> <verdict> arrow, read from DELEGATION_KIT_FAN_WIDTH (§Layout and configuration). It is the knob’s mechanical reader, and it reports the bound without enforcing it (§The delegation model). The budget check already runs before every dispatch, and agent-budget-guard relays the line verbatim, so the read-only fan-out bound reaches context at the wave-sizing decision. Verbatim governs the line’s own text, not its frame: the guard prefixes the relayed line and the PAUSE arm appends its corrective, but no arm restates the payload, and the one advise arm adds nothing. The field is a config constant, not derived from the snapshot, so it is present even on an unreadable-snapshot STALE.

Two pause axes. The five-hour window is the always-on axis. The weekly (7-day) window is a second, armed only when both seven_day_used_pct and seven_day_resets_at are present, so a three-line snapshot is judged on the 5h axis alone. The weekly limit can deplete while the 5h window sits comfortable, and a weekly PAUSE costs days rather than hours. So it gates delegation, the discretionary spend and the first to stop near the weekly ceiling, keeping the rest of the week with the supervisor. The axes are judged independently against DELEGATION_KIT_PAUSE_PCT and DELEGATION_KIT_PAUSE_PCT_7D, and either firing is a PAUSE (exit 1). The message names the axis that fired, PAUSE (7-day window) or PAUSE (5h window), the weekly when both fire, because the remediation differs by days.

Usage-history sampling. When DELEGATION_KIT_USAGE_HISTORY is non-empty, usage-verdict appends one sample line (§The usage.txt contract) to that log after every parsed snapshot, whatever the verdict. The line carries the raw harness-reported values. Write-time smoothing or correction is forbidden, since a later corrective push is evidence about the earlier sample and only the reader has both. A STALE from an unreadable or unparseable snapshot appends nothing: a sample the verdict would not trust is not history. --emit-usage-trend (§Trend reporter) reads the log, and so does the verdict, for a roll witness. That read is strictly fall-open, so the log stays an advisory trend surface rather than a correctness dependency, and sampling off changes no verdict.

The usage.txt contract

The snapshot is the wire contract between any producer and the gate — three key=value lines:

five_hour_used_pct=<float>
five_hour_resets_at=<epoch-seconds>
updated_at=<epoch-seconds>

Beyond the three mandatory lines a producer may write optional keys its source exposes. usage-verdict reads the ones it interprets and passes the rest through:

  • seven_day_used_pct / seven_day_resets_at, the weekly window, which arms the second pause axis;
  • account, the logged-in account identity: login_at detects a switch and account says to whom, so the trend reporter groups a multi-account operator’s segments per account and the verdict compares it with the live identity (§usage-verdict);
  • tier, the subscription tier, the denominator behind the percentages;
  • tokens_in / tokens_out, cumulative token counts, the axis that binds an API-billed consumer, to whom no subscription percentage applies.

An optional key is omitted when its source has no value, never written empty.

The contract also serves a foreign adapter’s snapshot, where the axes are read by length: the mandatory triple is the vendor’s shorter window and the weekly pair its longer one (§The keyed verdict).

Omit-don’t-empty binds the reader as much as the producer. A reader of these lines tests a key’s presence, defaults an absent one rather than failing on it, and never treats an omitted optional as a malformed line (§Trend reporter enumerates its reader’s defaults). A reader demanding every key would red on a line this contract permits.

The kit ships two reference producers, one push and one poll, and any producer honoring the contract works beside them. The --statusline arm is the push producer: a statusline hook that parses the harness’s rate-limit JSON and atomically writes the snapshot (tmp + mv) to <config-home>/usage.txt. It also produces the weekly pair from the payload and account / tier from the local account config (.credentials.json / ~/.claude.json, overridable via DELEGATION_KIT_CRED_FILE / DELEGATION_KIT_ACCOUNT_CONFIG). It ships no tokens_in / tokens_out producer: this harness’s payload carries no cumulative token count, so under the dead-producer rule the keys stay defined here for third-party producers but no dead producer is shipped. Beyond the snapshot write it renders a status bar (§The statusline arm). The snapshot write is the contract; the bar is reference UX a consumer may restyle or discard. The source is pluggable (DELEGATION_KIT_USAGE_FILE), so no single-operator CLAUDE_CONFIG_DIR assumption is baked in.

The --usage-poll arm is the poll producer, closing the push producer’s blind spot. The statusline fires on the supervising session’s own message flow, so a lead that delegates stops producing exactly when it goes static, and a delegated build can run for hours with every budget verdict reading a stale file. The poller runs one poll cycle per invocation:

  1. read the harness OAuth token from DELEGATION_KIT_CRED_FILE; the token goes into the request header and nowhere else, never logged, echoed or written to the snapshot;
  2. query the account usage source;
  3. map the payload onto the contract, the three mandatory lines plus whichever optional keys the source exposes, with no new key;
  4. atomically rewrite the snapshot (tmp + mv).

No daemon, no loop: scheduling belongs to the consumer, and two modes are sanctioned. Demand-driven (§usage-verdict): point DELEGATION_KIT_REFRESH_CMD at the poller, and it runs at verdict time with the freshest reading at the decision point. Timer-driven: a cron line such as */5 * * * * bash gate-sdk/bin/run-gates.sh --usage-poll, or a systemd timer, for continuous trend density independent of verdict calls. Either entry is the enabling config; with neither, the producer is dead.

The usage endpoint is harness-account plumbing, not a published contract, so the poller is fail-soft. A missing or unreadable credentials file, a fetch failure or an unparseable payload exits non-zero with a help: line without touching the snapshot. A stale snapshot is already detected downstream, where usage-verdict’s staleness check turns it into STALE rather than a silent green, and the non-zero exit lands in the invoking timer’s logging and nowhere else. The endpoint URL is the DELEGATION_KIT_USAGE_ENDPOINT knob, both the test seam (the smoke points it at a local file:// stub) and the valve when the source moves.

Both producers write the whole snapshot atomically from the same account source, and last-writer-wins is correct: the freshest write is the truest, and updated_at arbitrates downstream. Nothing serializes them and nothing needs to. The poller writes the snapshot only, and usage-verdict stays the trend log’s single append author.

The shipped statusline arm calls usage-verdict nowhere, and its declared reads omit DELEGATION_KIT_USAGE_HISTORY for that reason (§The statusline arm). So the sampling that knob buys is whatever the verdict’s own per-session and per-dispatch callers produce. The statusline fires far more often, so a consumer wanting denser history calls usage-verdict from its own render path rather than appending the log itself. The kit ships the knob and the append author, not the render-path call.

The sample line. With sampling enabled (§usage-verdict), usage-verdict appends one line per parsed snapshot, the trend log’s wire contract with --emit-usage-trend:

updated_at=<epoch> pct=<float> resets_at=<epoch> verdict=<word> login_at=<epoch>[ account=<word>][ tier=<word>][ pct_7d=<float> resets_7d=<epoch>][ tokens_in=<n> tokens_out=<n>]

Space-separated key=value, order-insensitive; optional groups are omitted (never written empty) when the snapshot lacks them. login_at is the credentials-file mtime the post-login-lag check already reads, stamped per sample so an account switch becomes data; pct / resets_at are the 5h values verbatim and pct_7d / resets_7d the weekly ones. The log is append-only (the tmp-prune / boundary-truncate conventions own cleanup), and carries operator-local account identifiers, so it lives under the gitignored measurement dir and never reaches a tracked file.

The statusline arm

The --statusline arm renders model/effort, a context gauge, the 5h and 7d windows with reset countdowns, an iteration@stage readout, a queue counter group, and any configured inbox counters. Self-contained ANSI, no external asset (§Out of scope).

The iteration@stage readout’s two halves come from two surfaces: the iteration from the queue header, the stage from the lifecycle evidence file’s last data line. Both are repo-root-relative reads in the arm’s existing hardcode style — the arm adds no knob. With no cursor to read (no evidence file, or one truncated to its preamble at an iteration boundary) the readout degrades to the iteration alone rather than printing a dangling separator or a partial parse.

The counter group is a compact N12 T3 D48 I7 tally of the queue’s task sections, appended last. The arm does not know how many sections exist, what they are called, or which tiers the set contains. It calls queue-kit’s counter rendering and renders whatever <section-name><TAB><count> lines come back, labelling each with the initial of its name (queue-kit/SPEC.md §The queue-counts arm), so the section vocabulary stays inside the kit that owns it. As soon as two returned names share an initial, every label widens to two characters, so a colliding pair stays distinguishable and the group stays aligned rather than only the collision changing width.

The inbox counters are a second group, appended after the queue counter group, one <label><n> token per DELEGATION_KIT_STATUSLINE_INBOXES element whose file holds at least one bullet. The count is the file’s lines opening - , the one grammar both lifecycle-kit inboxes share. A zero count, an absent file and an unreadable one render nothing, and an empty knob renders no group: a zero on every bar is noise, and the session brief says which inbox is empty. The kit names no path, since delegation-kit may not name a lifecycle-kit one: which inboxes a consumer counts, and under which label, is that consumer’s. The knob carries labels beside paths so one knob serves every inbox, and it defaults empty because the counter is optional, unlike the iteration@stage readout every bar shows.

The counter rendering is called in process. It resolves its knobs through the crate’s knob reads, which return Result and cannot exit. A malformed queue config becomes an Err this arm maps to an empty counter group, and the model, context and rate-limit gauges still render.

An unresolvable queue file, a malformed config and empty output each drop the group and change nothing else about the bar, beside the arm’s two other degradations: an absent queue file drops the iteration, and an absent evidence file drops the @stage suffix. There is no existence guard: the rendering ships in the same binary, so a profile that vendored this kit without queue-kit has no missing script to guard against.

The arm’s declared reads include the counter rendering’s four (QUEUE_KIT_QUEUE_FILE and the three section knobs), since an in-process reader resolves them itself. queue-kit/SPEC.md §The queue-index arm’s configured-arm paragraph settles a caller reaching an arm as an arm, and an in-process call from a hook module is neither the front end nor a direct binary invoke, so where the four resolve is stated here:

  • They resolve in process from queue-kit’s static table. The four are static knobs, so the binary reads them from the kit’s defaults and the consumer’s queue-config.knobs wherever it runs (gate-sdk/SPEC.md §The knob file). The gate-sdk queue file that QUEUE_KIT_QUEUE_FILE’s default derives from resolves from gate-sdk’s own table the same way. Declaring the four is what lets the knob-file derivation, check-reads-couples and check-gate-substrate-parity see those reads.
  • The silent-override-ignored failure is structurally unavailable. No path resolves the four but the kit table and the knob files, so no invocation can substitute a platform default for a consumer’s value.
  • So the residual failure is total and visible, never partial and wrong. A malformed queue config, which the table validator refuses, drops the whole counter group and changes nothing else about the bar — one of the degradations above. A consumer’s configured tier silently missing from a rendered tally has no spelling on this path.

The render cost is one in-process scan per statusline fire, which is the kit’s most frequent trigger by a wide margin (§The usage.txt contract). A full scan of a ~3,600-line queue measures in single-digit milliseconds, so the group takes no cache.

model-verdict

Emits one verdict line naming the model the session runs on and, with --expect <class>, whether that model meets the class (§The tier binding): bash gate-sdk/bin/run-gates.sh --model-verdict [--expect <class>] [<transcript.jsonl | session8>]. Without it every tiering rule is unverifiable from inside the session it binds: a stage session dispatched on the mechanical class cannot tell whether it got it, and a pin cannot be seen holding or failing.

Which transcript. A .jsonl operand is read as given. An eight-character operand names a transcript through the shared inverse lookup, whose normalization strips a leading agent- from each candidate (drift-kit/SPEC.md §The stage-economics meter). Bare, the arm takes the delegation-aware derivation’s pick (lifecycle-kit/SPEC.md §bin/session-id.sh): a top-level session’s own transcript, and for a dispatched session the newest transcript under its lead’s subagents/. The sessions dir is DELEGATION_KIT_SESSIONS_DIR, empty meaning derived, as drift-kit’s knob is. Honest limit: a sibling session writing at the same moment can out-date a dispatched caller’s own transcript, so the line prints the key it read, and a caller holding its own id passes it.

Which model. The newest assistant record whose usage is not all zero gives message.model. The record is read by the meter’s own per-record reader, so the two agree on what a record’s model and usage are. All-zero is the test --price-coverage applies to an id’s summed counts, here applied to one record, since the harness’s synthetic records carry zero usage. The newest record exists while a tool call runs, so a session asking about itself finds its own current turn. A model switched mid-session is read as the model now running. The same record’s top-level effort field gives the effort the turn ran at, which the harness writes on every assistant record.

The verdict. The id’s classes are the bound classes whose value it matches (§The tier binding). Without --expect, the line is a reading and exits 0. With it, the id meets class E when it matches E or a class ranked above it. The ranking is the roster’s order, so a check at a floor passes a stronger model. The model alone decides the verdict and its exit.

verdict when exit
READ no --expect, and a model id was read 0
OK the id meets the expected class 0
BELOW the id matches only classes ranked under the expected one 1
UNBOUND the id matches no bound value 1
UNKNOWN no transcript, no counting record, the binding empty under --expect, or the expected class unbound 2

UNBOUND is a mismatch rather than an unknown, since the model is known and the binding does not name it. Under a pinned class it is the pin bypassed. Under aliases it is a model outside the consumer’s tiers, a forced subagent model for one.

The line is model-verdict: id=<id> effort=<effort|-> session=<key> class=<classes|none> expect=<class|-> -> <VERDICT>, followed by its consequence, on §usage-verdict’s verdict-string contract: reading, epistemic status, consequence. effort is - where the record carries none.

  • READ carries no consequence clause, and neither does OK, with one exception: where the expected class binds an effort, an effort was read and the two differ, it carries — running effort <e> is not the class's bound <b>. It stays OK at exit 0, since the kit holds no order over efforts and cannot call a different one lower.
  • BELOW and UNBOUND carry — the session is not on its expected tier; stop before work that needs it and have it re-dispatched at that tier.
  • UNKNOWN carries its cause in parentheses, then — tier unverified, not refused; the caller's rule says whether to proceed.

Exit 2 is fail-soft on refusing work, as usage-verdict’s STALE is. A binding or a transcript that cannot be read is no evidence of the wrong tier, so no caller refuses on it alone.

The arm is an Arm::Run row: its 1 is the signal its callers grade, so it cannot be an --emit- member (§usage-verdict states the same forcing). It spawns nothing and writes nothing. Its declared knobs are DELEGATION_KIT_TIER_MODEL, DELEGATION_KIT_TIER_CLASSES and DELEGATION_KIT_SESSIONS_DIR, and the harness’s session variables arrive as inputs, as they do for the meter. A dash-led operand naming no option, an --expect naming no class of the roster, and a second operand are refusals at exit 2, and -- ends option processing.

Callers. The consult skill at its entry (lifecycle-kit/templates/consult.md), a stage session whose dispatch names its class, and any session asked what it runs on.

Why the transcript. The top-level interface already renders the model through this kit’s --statusline arm, which reads the harness’s statusline payload. SessionStart’s payload carries a model field only optionally, and no other hook event carries one. So the running model reaches a session only through its transcript, and a hook-side route would reach top-level sessions alone, where the statusline already shows it.

Two shapes were weighed and refused. Stamping the model into --enter-stage’s record would change the stamp grammar of lifecycle-kit’s state machine for a fact a stage session can ask for at the moment it needs it. It would also leave a consultation, which stamps nothing, uncovered. Refusing the delegated case, as the overhead meter does: the meter must never measure a sibling’s transcript, because its output is a logged cost. A verdict prints the transcript key it read, and its caller can check that key against its own stamp id or pass its id as the operand, so a verdict can take the newest transcript where a meter cannot.

Trend reporter

bash gate-sdk/bin/run-gates.sh --emit usage-trend [--] [history-file] reads the history log and reports how the footprint evolves. It is advisory tooling, never a gate: exit 0 when a report is emitted, 2 when the knob is unset or the history missing or unreadable, never 1, since it renders no verdict and the verdict stays the sole pause authority. The source signal is known-noisy: a rolling-window reading spikes and reverts when the next push corrects a harness over-report. So the reporter separates signal from noise by the window’s one physical constraint: within a segment, true usage never decreases.

  1. Segment samples per axis by that axis’s reset epoch, login_at, account and tier. The 5h axis keys on resets_at and the weekly axis on resets_7d, since the windows roll independently and a weekly segment spans many 5h ones. A timer reset, a /login, or an account or tier change each starts a segment. Only within-segment comparisons are meaningful, so an account switch is a boundary, not a flagged anomaly.
  2. Flag any sample whose pct is below an earlier one in the same segment as a monotonicity violation. The downward correction indicts the elevated samples before it as reader noise, and both sides are excluded from rate math, never averaged in. Median-of-3 smoothing resolves single-sample spikes, and segment endpoints keep their own value.
  3. Report per segment and axis: first and last smoothed pct, pct-per-hour rate, token deltas when token keys ride, tier, sample count and suspect-sample count. A high suspect ratio means the producer is unreliable, and no number from that segment is trusted. The weekly axis also reports headroom against DELEGATION_KIT_PAUSE_PCT_7D at the current rate, the planning number for how much delegation the week still affords. When account is present, segments group under an account heading, so a rotating operator reads one weekly trajectory per account.
  4. Combine the accounts after the per-segment report, where the log carries two or more distinct account values: one line per account, its newest weekly segment’s last smoothed pct and its headroom against DELEGATION_KIT_PAUSE_PCT_7D, then accounts: <n>, at or over the weekly ceiling: <k>. The view is advisory and sums nothing: accounts may differ in tier, so a sum would add different denominators, and the pause decision stays per account. An account with no weekly segment prints - for both values. The newest segment is the one whose last sample is latest, samples carrying no account join no line, and the count is at-or-over, as the pause compare is. A log with one account, or none, prints no block. No line of the block begins with a segment line’s prefix, two spaces and then [5h] or [7d], on which §Testing anchors its segment counts.

The family is Arm::Emit, because the contract declares no 1. The emitting family maps success onto 0 and failure onto 2 and can never return 1 (gate-sdk/SPEC.md §The non-gate arm). §usage-verdict is an Arm::Run because its 1 is the blocking signal a hook grades; here the collapse costs nothing. The front-end composes --emit-usage-trend from its own --emit <name> operand, so the arm takes no front-end case arm, and a member spelled otherwise would be reachable by no shipped front-end.

Its usage lives at its shape refusal. The front-end’s --help gives the --emit- family one generic line and enumerates no member, so this arm’s usage is not owed there. --emit usage-trend --help prints the usage block at exit 2, which discharges the discoverability half of the bin/-tool contract (gate-sdk/SPEC.md §The bin/-tool contract).

The argv. The [history-file] positional is an argument the rule consumes rather than a selector of configuration, and it overrides the configured path for test injection. It resolves lazily: the knob is read only when no positional rides. A positional beginning with - that is not a recognized option is a refusal (usage on stderr, exit 2), with -h/--help its named instance, and -- ends option processing so a dash-led path stays reachable. A second or later positional is ignored rather than refused, so no accepted invocation turns into a refusal.

The declared knobs are DELEGATION_KIT_USAGE_HISTORY and DELEGATION_KIT_PAUSE_PCT_7D, both rows of delegation-kit’s static table (§Layout and configuration), so no reader-side default can drift from a consumer’s override. They differ in how absence reads. The history knob’s default is the empty string, so an unset knob and a configured-empty one are one reading, which takes the arm’s own “unset — no history to report” diagnostic. The ceiling’s default is a real value, so a failed read of it is a resolution failure and surfaces as one.

The wire shape is a contract. --usage-verdict appends the sample line (§The usage.txt contract) with optional keys omitted rather than emitted empty. So the reader defaults a missing account or tier to -, a missing login_at to 0, and a missing verdict, tokens_in or tokens_out to -. Each default is the reader’s half of that rule (§The usage.txt contract). The two-axis emission is the same contract: a 5h record per sample, and a weekly one only when both weekly keys are present, so a log without them yields 5h segments alone rather than an error.

The segment order is a contract the reader reproduces, and its tie rule is a byte comparison of the whole record rather than input order.

  • Records sort by account, axis and tier bytewise, LC_ALL=C’s effect, since a locale-aware comparator would diverge silently on a non-ASCII account name.
  • Then by login_at, the axis reset and updated_at numerically, a value carrying no numeric prefix reading as zero.
  • The segmenter keys on that tuple as text and flushes on change. A different order yields different segments rather than a differently ordered report, and reading a number out of the tuple would merge a boundary two spellings of one epoch keep apart.
  • The last resort, the seventh comparison, is the whole line, as GNU sort falls back to absent -s. A merely stable sort keeps input order instead, and on a full-key tie the two choose different segment endpoints and so print different token deltas.

bin/wait-probe

bash gate-sdk/bin/run-gates.sh --wait-probe <subcommand> [args] is the instrument behind §Operative residency’s wait-primitive measurement. It stands a known-duration producer up, exercises candidate wait forms against it, and records one line per trial. It is hand-invoked, with no trigger, and wired into no tier: it launches processes and sleeps for its declared durations, so a gate tier would put a multi-second sleep on every commit for a measurement bought once. It writes nothing tracked. Producers, markers and launch records go to GATE_SDK_TMP_DIR, trials go to the capture-tier evidence file below, and it mints no knob of its own for either (§Layout and configuration owns why).

Six subcommands, the word an operand rather than part of the flag: produce, waiter, arm-local, record, report, sweep. That is --hook’s shape: the front-end hands the binary an arm’s own argv unchanged, so a subcommand-bearing arm is a solved registration (gate-sdk/SPEC.md §The non-gate arm).

It is an Arm::Run arm-table row with its own front-end case arm, never an --emit- member, on two independent grounds. The emitting family maps success to 0 and failure to 2, so it can never return the 1 below. And four of the six subcommands emit no document: they stand processes up, write launch records and append a trial line.

Two declared knobs, GATE_SDK_TMP_DIR and GATE_SDK_WORKFLOW_DIR, both resolved in process from gate-sdk’s table. The arm’s status survives the front-end’s exec verbatim, since exec replaces the shell’s process image, so the front-end’s own unavailable status is reachable on a dispatch failure alone.

The exit contract is three-state: 0 on a completed subcommand, 1 for report with no trials recorded, 2 on misuse. The 1 is contract: no trials recorded is not misuse, since the invocation is well formed and the tool ran. Folding it into the misuse code would make an honest empty reading indistinguishable from a typo, on the one tool whose value is telling a reading from a non-reading (gate-sdk/SPEC.md §The bin/-tool contract records that harm). That contract’s split binds here: the argv-shape refusal and the -- escape cross to the arm, and the -h/--help arm retires to the front-end. A one-line front-end summary cannot carry a six-entry roster, so the arm prints its roster on the misuse path, stderr at exit 2, which is not the help arm.

Three parts of the instrument stay shell, each on its own ground: gate-sdk/SPEC.md §The port-candidate criteria’s the program is the rule clause, where the program decides a measurement rather than an assertion.

  • The wait body. The until [ -f <marker> ] / until kill -0 <pid> loop is the artifact under measurement. It arms the marker form templates/agent-execution.md prescribes and the inverted liveness form that template names as the trap, which guard-kit’s loop-polarity rules are shaped to. A compiled loop would measure a form no session runs. The loop and its four exit traps are one constant the arm execs into, so every form arms the same wait body.
  • The arming of the local form. arm-local arms the waiter as a detached shell child, the harness-uninvolved control, and a crate-armed child would make that form’s name false. The arming stays nohup … & inside a shell literal, with the launched PID read back and written to the <key>.run record.
  • The producer’s launch, a hazard rather than a definition. A shell’s backgrounded child is reaped, so kill -0 on its recorded PID fails once it exits. A compiled child whose parent outlives it leaves a zombie, and kill -0 on a zombie succeeds. That would invert producer_alive_at_exit on every trial, the field cause (i)’s tell reads, while the instrument reported clean.

The arm execs into the wait body rather than spawning it, for measurement validity. Backgrounding the wait puts one process between the harness and the loop, and a spawning arm would put two. The reaping cause the instrument detects is a property of the process the harness signals, so exec keeps the depth and the signal disposition a session runs.

The retained shell dependencies are declared: the arm spawns bash, and through it nohup, sleep and date. bash is on GATE_SDK_PROGRAM_FLOOR and nohup and sleep are not, a stated cost: a known-duration producer is a sleep, and a detached shell child is what nohup … & produces, so they are the rule’s semantic content. A consumer lacking either gets the failure it already got.

One trial launches a producer that sleeps a declared duration and then writes a completion marker. It records the producer’s PID at launch in a <key>.run file as templates/agent-execution.md mandates, so the probe’s own producers are visible to check-producer-liveness and to guard-kit rule git_mutation_under_producer. It arms the wait form under test and records whether the waiter exited before the marker appeared. That relation is the whole measurement: a waiter that outlives its condition is working, and one that exits early is the failure.

The <key>.run record’s shape is a contract, coupled at run time rather than in any scan. check-producer-liveness and guard-kit rule git_mutation_under_producer read the two-field pid=<n> run=<key> line the instrument leaves in the scratch directory, never its source. A form writing another shape would leave its producers invisible to the align-entry refusal and the tracked-tree mutation guard, with every battery green.

The producer’s duration is swept, not fixed, since one duration cannot distinguish the candidate causes. The probe reads no harness internals and asserts no ceiling; it varies the input and reports where the behaviour changes. The duration sweep is a constant, never a knob: the durations are the instrument’s calibration, and varying them varies the experiment.

Two axes, and the second earns the instrument its keep. The form axis varies how the wait is armed, and the predicate axis varies what the loop tests, a completion marker or the producer’s liveness. A form-only instrument can report that a form failed but never why, and the observed failures sat on the predicate axis. Both axes are swept in one run on identical producers, so no cell rests on a comparison with a differently shaped trial.

The per-trial line. Every field has a named reader, and the field set is closed:

  • form — the wait form the trial armed. Read at the comparison; the only field that distinguishes the candidates.
  • predicate — what the loop tested. Read at the classification; it separates a failing form from a failing condition.
  • producer_ms — the producer’s declared duration. Read at the sweep analysis, where a ceiling shows as a threshold.
  • waiter_exit — the waiter’s exit status. Read at the classification, to separate a signal status from a clean zero with the marker absent.
  • marker_at_ms, waiter_at_ms — elapsed wall-clock at the marker’s appearance and at the waiter’s exit. Read only together: their order is the measurement.
  • producer_alive_at_exit — whether the recorded PID still answered kill -0 when the waiter exited, on the recorded PID and never a process-table pattern.
  • class — ok on a clean trial, otherwise the cause from the closed list below.

The producer’s PID is not logged: the <key>.run record holds it as long as the wait needs it.

The candidate causes are enumerated before the run, so a run returns a cause rather than an anecdote:

  • (i) reaped — the harness or the session boundary killed the backgrounded shell. Tell: a signal exit status with the producer still alive.
  • (ii) ceiling — a wall-clock deadline the form carries. Tell: early exits cluster above one duration, and short trials never fail.
  • (iii) predicate — the condition went true, or could not go false, for a reason unrelated to the producer. Tell: a clean zero exit with the marker absent, reproducing on a form the harness never touched.
  • (iv) unexplained — recorded and reported as such. A closed list that cannot say I do not know would force a wrong attribution.

Honest limits, a division of labour. Cause (ii)’s tell is a threshold across the sweep, which no single line carries. record classifies from the tells one trial shows, and report, the only reader that sees the sweep whole, names (ii); a line’s class is not the run’s verdict. The probe arms the forms a shell can arm, and the session arms the harness’s own, calling record for each. The tool cannot invoke a harness tool parameter, so a run measuring the harness forms is a session’s, and sweep is the harness-uninvolved reproducer a second machine executes.

The instrument around the subject is compiled. record’s trial-line assembly and four-arm cause attribution are text and arithmetic over files the subject wrote. report’s verdict block carries the per-cell tallies, the ceiling tell and the predicate-shaped conclusion; it enforces the limit above, and it is the one verdict no trial line carries. sweep’s orchestration keeps the recorded-PID liveness predicate and its while polarity, since an until there would reproduce the defect the instrument found. produce, waiter and arm-local compile their bookkeeping only (the t0 stamp, the marker and heartbeat paths, the <key>.run write, the PID read-back) and keep their shell literals. The shell parts are the artifact under test, not an unfinished port.

The trials land in .workflow/wait-primitive-evidence.txt: capture-tier, gitignored and advisory (gate-sdk/SPEC.md §The workflow directory), declared on the close-surface roster (lifecycle-kit/SPEC.md §The close-surface roster) with its rotation as the reclaim path:

close-surface: .workflow/wait-primitive-evidence.txt advisory reclaim=bash gate-sdk/bin/run-gates.sh –emit capture-drain .workflow/wait-primitive-evidence.txt

advisory for the reason §The turn-end liveness hook gives its log. The finding, the branch the trials select and its grounds, lands in this SPEC and never in the log: the log is evidence, not the record.

The foreign-vendor run

A read-only audit or a mechanical sweep may run on another vendor’s coding agent instead of a dispatch, at the tier class the run names (§The tier binding). An audit-mode run may also carry a stage’s contract under lifecycle-kit’s host protocol (lifecycle-kit/SPEC.md §The host protocol), a reading that carries its stage’s class, or a project critique a consultation commissions (lifecycle-kit/SPEC.md §templates/consult.md). Every dispatch otherwise rides the master harness’s own Agent tool, so all delegated spend lands on one vendor’s window. bash gate-sdk/bin/run-gates.sh --foreign-run <adapter> <prompt-file> [--mode audit|sweep] [--class <class>] [--key <key>] runs one such unit. The mode defaults to audit. The key defaults to the prompt file’s basename without its extension, must be one path component, and names the run’s directory, <GATE_SDK_TMP_DIR>/foreign/<key>/.

The executor is kit mechanism and each adapter is consumer config. An adapter is the program and its argv, carrying its vendor’s non-interactive mode, output format and sandbox mode, and its model and effort as literal words or as the two class tokens below. It names a vendor, so it is never a kit literal (gate-sdk/SPEC.md §The provenance seam). An adapter speaks its vendor’s machine plane, never its interactive screen: a screen relay yields rendered frames rather than turn events and bets on the vendor’s least-stable surface.

A resumable session is one process per turn, its conversation held by the vendor. A vendor’s headless mode exits at its turn end and keeps the conversation in its own session store, which its resume form continues. So the kit holds no conversation, only the binding beside the clone. A permission request travels as an escalation. The adapter pins a policy that never prompts, an action it refuses reaches the agent as a failure, and the agent reports it at its turn end like any open question. A widening is a new run under an adapter configured wider, never a flag a resume adds. The report is the escalation channel, and the kit classifies none. Whether a turn ended on a result or a question is the dispatcher’s reading. The dispatcher answers a question with --foreign-resume, and the agent continues in the same clone and the same vendor session.

The executor resolves a run’s class into the adapter’s argv. An adapter takes a class when DELEGATION_KIT_FOREIGN_HARNESS names it. A word of its forms may then carry @MODEL@ and @EFFORT@, substituted inside the word, as @PROMPT_FILE@ is, with the pair its harness binds to the run’s --class (§The tier binding), so a vendor’s key=value option takes one.

  • On a class-taking adapter --class is required. A missing one, and a class the roster lacks, are shape refusals. A class the harness leaves unbound, or @EFFORT@ in the open or the resume form under a row with no effort, is FAILED (class: <why>) with budget=-, among the pre-spawn checks that precede the budget read, so nothing is cloned.
  • On any other adapter --class is accepted and applies nothing, whatever it names, and the line says so. A host therefore always passes its reading’s class and needs no second read to learn whether the adapter takes one.
  • A session keeps the pair it opened with. The open records it and a resume substitutes the recorded values, so a binding edited while a session is open reaches the next open. Re-resolving at each turn is refused, since it would change a kept conversation’s model with no line saying so. Refusing the resume on a changed binding is refused too, since it strands a session over an edit that need not concern it.
  1. The tree. A shared, no-checkout clone of the toplevel, checked out detached at the committed HEAD, under the run’s directory as tree/, with its origin remote removed. A clone rather than a linked worktree, on §upgrade-smoke’s ground (gate-sdk/SPEC.md): a killed run leaves scratch and no registration. A clone’s refs are its own, and with no remote nothing in it can push here, so a foreign commit is unreachable from this repository’s refs by construction rather than by a check that could be skipped. The foreign agent sees committed state only, as an isolated child does (§The delegation model, isolation’s untracked-blindness cost). A clone an earlier refusal kept under the same key is evidence awaiting inspection, so the run fails rather than overwrite it.
  2. The spawn. The adapter’s argv from DELEGATION_KIT_FOREIGN_ADAPTERS, spawned directly with no shell, with its working directory the clone and its standard input the prompt file. The GIT_* location variables are removed from its environment, so a hook context cannot point it back at this repository. Standard output goes to report.txt and standard error to stderr.txt, both in the run’s directory, bounded by DELEGATION_KIT_FOREIGN_TIMEOUT, whose expiry kills the adapter’s own process.
  3. The shape. Checked after every spawn that started, a timeout or a non-zero exit included, and a refusal outranks a failure. A clone whose HEAD or any ref moved is refused: the foreign agent committed, and its commits are never taken. In audit mode, a clone whose status is not clean is refused: an audit wrote. In sweep mode, after an adapter exit of 0, the clone’s whole change, untracked files included, is written as a binary-safe change.patch in the run’s directory against the checked-out commit. An empty change writes no patch.
  4. The cleanup, or the session. On a one-shot adapter the clone is removed, except on a refusal, where it stays for inspection and the line names it. On a resumable adapter, one with a resume form in DELEGATION_KIT_FOREIGN_RESUME, an OK turn opens a session. The clone stays, and session.txt in the run’s directory records adapter=<adapter> mode=<mode> class=<class|-> model=<model|-> effort=<effort|-> base=<commit> turn=1 id=<id|->, the three class fields - on an adapter that took none, and read as - from a line an earlier build wrote without them. The refs the shape check compared are kept beside it as refs.txt, for the next turn’s check. A resume form needing an id where no marker produced one opens no session: the clone is removed and the line says why.

The budget read precedes the tree. After the arm’s pre-spawn checks and before anything is written, ahead of the run directory’s creation and the clear of a closed session’s rotated reports, the executor reads the adapter’s keyed verdict (§The keyed verdict). A PAUSE clones nothing and spawns nothing, so a paused run leaves no scratch behind and holds no key. Every other verdict proceeds.

The verdict line is foreign-run: adapter=<adapter> mode=<mode> class=<class|-> key=<key> budget=<OK|RESET-OK|STALE|OFF|PAUSE|-> exit=<status> report=<path|none> patch=<path|none> -> <VERDICT>. class is the class whose pair was substituted, and - where none was. Its reader is the dispatcher, and the commit message that records a foreign reading (lifecycle-kit/SPEC.md §The host protocol). budget is the keyed verdict’s word, and - on a line printed before the read happened: an unknown adapter, an unresolved class, an unreadable prompt file and an occupied key took no reading. Its reader is the dispatcher at the next dispatch, to whom STALE or OFF beside an OK run says the turn was spent unbudgeted. exit is the adapter’s status, timeout when the bound expired, and - when nothing was spawned, where report reads none. An OK that opened a session carries the consequence clause — resumable: --foreign-resume <key> <prompt-file>, and one that could not open a session carries — not resumable (<why>), §usage-verdict’s verdict-string contract of reading, status and consequence. The exits:

  • 0 OK — the adapter exited 0 and the shape held.
  • 1 REFUSED (<why>) — the shape broke: committed, or audit wrote, followed by — clone kept at <path>.
  • 2 FAILED (<why>) — an unknown adapter, a class that does not resolve, an unreadable prompt file, a kept clone or an open session under the key, a clone or spawn failure, a timeout, or a non-zero adapter exit. The report is kept, and the line names the adapter’s status. A paused window fails here too, as FAILED (budget: <the keyed line from its used= field on>) with exit=- and report=none. It is the FAILED class because a paused window is retried after it resets, the reading §Resuming a session gives a turn a vendor’s window refused.

A malformed argv (an operand count other than two, a dash-led token naming no option, a mode other than the two, a key that is not one path component) is a shape refusal at exit 2, with the usage on standard error. So is a class-taking adapter’s missing or unrostered class. -- ends option processing. The --budget form’s own shape is §The keyed verdict’s.

What returns, and how it lands.

  • report.txt is the unit’s return value, and a durable one: it outlives the calling session, so a successor finds it. The adapter selects the vendor mode whose standard output is the report, and the kit reads bytes: parsing each vendor’s event stream would put a vendor schema in a kit literal.
  • A sweep’s change.patch is applied by the dispatching session, which verifies it as it verifies any agent commit (§Verify after every agent commit) and commits it under its own attribution. Foreign work thus carries no vendor trailer, and the contributor list does not grow per vendor. A consumer’s README states that method once. The foreign agent committing on the shared tree is refused, since it would race the shared git index.
  • An adapter’s model and effort come from the class its run names, through the row its harness binds to that class (§The tier binding), or sit in its argv as literal words where the adapter takes no class. Either way the value is an alias or a pinned id on that section’s follow-or-pin reading. The dispatcher picks an adapter by name and a class for the unit, and a stage’s adapter may be bound in lifecycle-kit’s executor knob (lifecycle-kit/SPEC.md §The host protocol).

Honest limits.

  • Confinement. The clone is the agent’s working directory, not a sandbox. A foreign program can write any path it can reach, so an adapter selects its vendor’s read-only or workspace-bounded sandbox mode, and that mode is the only confinement there is. This is the residue §The delegation model records for a program an isolated child runs.
  • No guard fires. The executor is reached through a shell call, and the budget guard and the dispatch guard fire on the master harness’s Agent tool, so neither fires on it. Gating it behind the budget guard is refused: the guard reads the master harness’s window, and a foreign run spends a different vendor’s, which is the point. What budgets a foreign vendor’s window is the executor’s own read (§The keyed verdict).
  • An adapter with no snapshot is unbudgeted, and its line says OFF.
  • The oracle is as fresh as the consumer’s producer. Across a gap a by-product feed keeps a pause and loses an OK (§The keyed verdict). A reading whose short window died in that gap is RESET-OK only where its long window is under threshold, absent or itself reset.
  • A foreign reading runs no oracle that needs a built artifact. The clone is committed state and the gate binary is not tracked, so a stage’s host runs the gate itself.
  • Nothing verifies the reader’s model or effort. The verdict line’s class= is what the executor passed. The tier verdict reads the host’s transcript, and the kit parses no vendor stream.
  • The kill. The timeout kills the adapter’s own process. A program the adapter started may outlive it, and the clone is removed beneath it.
  • Resume is the vendor’s. A session resumes only as far as its vendor’s store keeps it: an ephemeral open persists nothing to resume, and a vendor that expires a session fails the next turn. A resume form that does not restate the open form’s confinement runs the next turn under the vendor’s default policy.
  • A turn outlives a foreground call. The timeout’s default exceeds a foreground call’s ceiling, so a long turn is launched backgrounded with its liveness record (templates/agent-execution.md, Background + notification, never poll). While that record names a live turn, the guard blocks every tracked-tree mutation in the checkout (guard-kit/SPEC.md §The generic ruleset, rule git_mutation_under_producer), so the dispatcher commits nothing until the turn ends.
  • The report’s own claims. A report’s claims carry the tier a dispatcher gives any child’s report: checked before they are built on.

It is an Arm::Run row that spawns a consumer’s program which may reach the network, so it takes a bare-flag spelling and stays out of the fence-safe set (gate-sdk/SPEC.md §The non-gate arm). Its declared knobs are DELEGATION_KIT_FOREIGN_ADAPTERS, DELEGATION_KIT_FOREIGN_RESUME, DELEGATION_KIT_FOREIGN_SESSION_MARKER, DELEGATION_KIT_FOREIGN_HARNESS, DELEGATION_KIT_FOREIGN_TIER_MODEL, DELEGATION_KIT_TIER_CLASSES, DELEGATION_KIT_FOREIGN_TIMEOUT, DELEGATION_KIT_FOREIGN_USAGE, DELEGATION_KIT_FOREIGN_USAGE_CMD, DELEGATION_KIT_FOREIGN_PAUSE_PCT, DELEGATION_KIT_FOREIGN_PAUSE_PCT_LONG, DELEGATION_KIT_STALE_AGE and GATE_SDK_TMP_DIR.

Resuming a session

bash gate-sdk/bin/run-gates.sh --foreign-resume <key> <prompt-file> runs the next turn of the session under <GATE_SDK_TMP_DIR>/foreign/<key>/. It reads session.txt, spawns the adapter’s resume form from DELEGATION_KIT_FOREIGN_RESUME as The spawn does an open, in the kept clone with the prompt file on standard input, and bounds it by DELEGATION_KIT_FOREIGN_TIMEOUT. A class token in the form takes the session’s recorded model or effort, and a form asking for one the session recorded as - fails before the spawn as FAILED (class: …). The adapter’s keyed verdict is read first, as an open reads it (§The keyed verdict). Then, before the spawn, the previous turn’s report.txt and stderr.txt move to report.<n>.txt and stderr.<n>.txt, <n> the turn they came from, so every turn’s return survives. A new --foreign-run under a free key clears the rotated reports a closed session left.

The shape check runs against refs.txt from the open and, in sweep mode, regenerates change.patch against the session’s base after every spawned turn, a FAILED one included, so the patch is always the session’s whole change. Then:

  • OK — the turn counter advances, and the line carries the resumable clause again.
  • REFUSED — the session ends. session.txt is removed, so nothing can resume it, and the clone stays as a refusal’s evidence.
  • FAILED — the turn counter advances, and the session stays open: a vendor window that refused the turn may be retried after it resets. A missing session.txt, an unknown key, an unreadable prompt file or an adapter the knobs do not configure fails without a spawn. So does a paused window, as FAILED (budget: …), and there the turn counter does not advance and the previous turn’s report is not rotated, as for every failure that precedes the spawn.

The line is foreign-resume: adapter=<adapter> mode=<mode> class=<class|-> key=<key> turn=<n> budget=<word|-> exit=<status> report=<path|none> patch=<path|none> -> <VERDICT>, on the run’s exit codes, class being the session’s recorded one and budget the run line’s field. A field the arm could not read before the spawn reads -.

--foreign-resume <key> --close ends a session the dispatcher is done with. It removes the clone, session.txt and refs.txt, keeps every report and the patch, and prints foreign-resume: key=<key> -> CLOSED at exit 0, with no budget field, since it spawns nothing. A key with no open session is FAILED at exit 2. A session never closed is reclaimed with the scratch dir at the consumer’s work-unit boundary. A malformed argv is a shape refusal at exit 2, and -- ends option processing. It is an Arm::Run row with --foreign-run’s grounds and declared knobs.

Honest limit: the dispatcher is a key’s one writer. Nothing serializes commands sharing a key, so two resumes, or a resume and --close, running together can rotate one report, write one turn or remove a running turn’s clone. The dispatcher runs one command per key at a time; the kit builds no lock.

The keyed verdict

A foreign run spends a window the account-keyed verdict cannot see, so each adapter may name a usage snapshot of its own and a command that writes it, and the executor reads §usage-verdict’s rule over that snapshot before it spawns. DELEGATION_KIT_FOREIGN_USAGE binds an adapter to its snapshot and DELEGATION_KIT_FOREIGN_USAGE_CMD to its producer (§Layout and configuration).

The kit parses no vendor feed and ships no producer. A vendor’s token-usage events are that vendor’s schema, and a kit literal carrying one crosses the provenance seam (gate-sdk/SPEC.md §The provenance seam), the ground §The foreign-vendor run gives for reading a report as bytes. The snapshot is §The usage.txt contract’s, unchanged, so the existing wire contract is the whole seam and the mapping from a vendor’s feed is the consumer’s command.

One rule, a third caller. The keyed verdict reads one adapter’s snapshot through §usage-verdict’s parse, its fail-closed readings and its checks, which are one function taking the order as a parameter, with the differences below and no others.

  • The pause axes precede age-STALE, and the long one precedes RESET-OK, for the keyed rule only. The keyed order is parse → long-axis pause → RESET-OK → short-axis pause → age-STALE → OK. An at-or-over reading of a window that has not reset pauses at any age: usage inside one window only rises until its reset, so an old at-or-over reading is still at-or-over. RESET-OK takes the short window that died, and that death says nothing about a long window still running, so the long axis is read first. It fires on a well-formed long pair at or over DELEGATION_KIT_FOREIGN_PAUSE_PCT_LONG whose own reset has not passed, a passed one being disarmed as in §usage-verdict. Age therefore suppresses an OK and never a PAUSE, the asymmetry §usage-verdict’s reroute holds. The ground is the feed: a vendor with no usage endpoint reports its window only as a by-product of a turn, so under the account-keyed order most readings after a gap would be budget-unknown and a paused window would be rediscovered by a failed turn. The account-keyed verdict keeps its own order, its weekly axis behind RESET-OK and age-STALE, which a demand-driven refresh makes safe there.
  • No identity arm. The post-login reroute, its settle floor and the account-switch STALE read the master harness’s credentials file and account config, which say nothing of another vendor’s account.
  • No sample. The keyed verdict appends nothing to DELEGATION_KIT_USAGE_HISTORY. That log’s readers group by the master harness’s account and read the newest sample as a roll witness, so a foreign sample would be read as the wrong window’s boundary.
  • No width= field. It reports the read-only Agent fan-out bound, which a foreign run is not counted under.
  • The refresh is the adapter’s own command. Where DELEGATION_KIT_FOREIGN_USAGE_CMD configures one, it runs before every keyed read, spawned directly with no shell from the toplevel, its output discarded. It is fail-soft on §usage-verdict’s terms: a non-zero exit, a spawn failure or the bound’s expiry leaves the snapshot untouched and the read proceeds on it. Its bound is DELEGATION_KIT_FOREIGN_TIMEOUT, the bound on everything the two arms spawn, so a consumer tunes one number and no second bound is minted. DELEGATION_KIT_REFRESH_CMD and DELEGATION_KIT_REFRESH_MIN_AGE are not read: the keyed read fires once per foreign command, never in a guard’s burst.

The axes are read by length. A producer maps its vendor’s shorter window onto the three mandatory lines and its longer window, where the vendor has one, onto the weekly pair. updated_at is the time the vendor reported the reading, never the time the producer ran: a producer that restamps an old reading turns budget-unknown into a false OK. A producer writes the snapshot atomically and omits what its source lacks, as the contract binds every producer.

Two thresholds, each defaulting to the account-keyed one: DELEGATION_KIT_FOREIGN_PAUSE_PCT for the short window and DELEGATION_KIT_FOREIGN_PAUSE_PCT_LONG for the long one. A consumer who holds a foreign window only to spend it raises them; one who shares it with interactive use keeps headroom. DELEGATION_KIT_STALE_AGE is read as it stands.

Sizing a consumer should know. A vendor with no usage endpoint reports its window only as a by-product of a turn, and only of a turn whose session its store keeps: an adapter run in a vendor’s non-persisting mode feeds nothing. The snapshot is then as old as the last kept turn on that account, so across a gap the oracle keeps a pause and loses an OK, by the first difference above.

The line is foreign-budget: adapter=<adapter> used=<pct>% age=<n>s resets_in=<n>s -> <VERDICT> (<status>; <consequence>), with (long <pct>%) after the short percentage where the snapshot carries a well-formed weekly pair. It honors §usage-verdict’s verdict-string contract: reading, status and consequence on every line, an OK included. A PAUSE names its axis as the short window or the long window, the long one when both fire, and says when that window resets. A long-window PAUSE beside a dead short window carries that window’s percentage, so its status clause says the short window rolled over. A line with no parsed snapshot carries no reading fields: it names the adapter and why.

The verdicts and exits are §usage-verdict’s, with one more non-reading.

  • 0 OK / RESET-OK.
  • 1 PAUSE.
  • 2 STALE, an unreadable or unparseable snapshot, and OFF: the adapter has no DELEGATION_KIT_FOREIGN_USAGE element. OFF says nothing gates this adapter and is not a fault. It takes the budget-unknown code because it is one, and its own word because its remedy is configuration rather than a refresh. Budget-unknown never blocks a foreign run, as it never blocks delegation.

Two producers of the verdict. The executor reads it in process before each spawn, grading exit 1 as its refusal, the grading agent-budget-guard makes of the account-keyed rule. And bash gate-sdk/bin/run-gates.sh --foreign-run <adapter> --budget prints it and spawns no adapter: it takes exactly one operand, runs that adapter’s producer where one is configured, prints the keyed line on stdout and returns the verdict’s exit, making no clone. It is a second form of the existing arm, as --close is of --foreign-resume, so it adds no arm-table row and no front-end case arm. An adapter the knobs do not configure, a second operand, or --budget beside --mode, --class or --key is a shape refusal at exit 2 with the usage on standard error. Its reader is the dispatcher choosing an adapter: the adapter is picked by name for the unit’s class (§The foreign-vendor run), and headroom is the second input to that pick.

Why the executor and not a hook. The budget guard and the dispatch guard fire on the master harness’s Agent tool, and a foreign run is a shell call. A hook on that call would have to parse a command line for an adapter name. The executor already holds the adapter, so the read costs it one in-process call, and no dispatch shape reaches a vendor without passing it.

Layout and configuration

delegation-kit/
  usage-tests/cases.tsv           # expected-verdict <TAB> scenario knobs; read by the crate test that replaced its shell driver
  usage-tests/dispatch-guard-cases.tsv  # expected-outcome <TAB> scenario knobs; read by the crate test that replaced its shell driver
  usage-tests/trend-history.log   # fixture history for the trend assertions, read by that same crate module
  checks/check-gate-tamper.gate  # binary-dispatched; live arm reads the index through git
  checks/check-agent-tier-explicit.gate  # hermetic, binary-dispatched: every agent definition declares an explicit model:
  gate-tests/check-gate-tamper/{good,bad}/
  gate-tests/check-gate-tamper.test.sh  # the live arm, over throwaway git repos
  gate-tests/check-agent-tier-explicit/{good,bad}/
  templates/agent-execution.md            # full protocol, bound as a skill shim
  templates/dispatch-checklists.md        # deletion/rename/audit pre-flight, reached by a pointer
  templates/delegation-config.knobs       # comment-only knob file: set only what you override
  smoke/install.sh
  smoke/violation.sh

Config is a knob file: copy templates/delegation-config.knobs into the gates dir as delegation-config.knobs (or point DELEGATION_KIT_KNOB_FILE elsewhere) and set any knob below; defaults fill what the file leaves unset. delegation-kit’s knobs are static: the binary resolves them in process from its own defaults table and the consumer’s knob file; bash gate-sdk/bin/run-gates.sh --emit knob-roster prints each one with its shape and rendered default. A gitignored delegation-config.local.knobs in the gates dir is the home for a private value a tracked file cannot carry. The grammar, that .local overlay, the environment-over-file precedence for a scalar, and the refusals — a set DELEGATION_KIT_KNOB_FILE that does not exist, a left-behind delegation-config.sh or delegation-config.local.sh, a non-empty file named by the retired DELEGATION_KIT_CONFIG_FILE — are gate-sdk/SPEC.md §The knob file’s. A file value for an indexed knob replaces its default whole. The kit’s table validator refuses a malformed config at exit 2 with every finding — a broken machine gates nothing. A derived default below names the knob it reads as ${NAME}, or as ${NAME:-<default>} so the roster’s rendered literal reads as agreement. Knobs (this repo’s layout as defaults):

  • DELEGATION_KIT_USAGE_FILE — default empty, and empty means derive it: the reader fills <config-home>/usage.txt, the config home being CLAUDE_CONFIG_DIR when set non-empty and ~/.claude otherwise — the crate’s one config-home derivation (lifecycle-kit/SPEC.md §bin/session-id.sh), which DRIFT_KIT_SESSIONS_DIR shares. The derivation sits at the reader rather than in the table because the harness home is read from the environment and is no knob a derived row could declare as its input. The statusline, the poller and --usage-verdict read this knob, DELEGATION_KIT_CRED_FILE and DELEGATION_KIT_ACCOUNT_CONFIG through that one fill and never directly. Positional $1 overrides (test injection).
  • DELEGATION_KIT_CRED_FILE — derived from ${DELEGATION_KIT_USAGE_FILE}: .credentials.json in that file’s directory when the usage file is set non-empty, and empty otherwise, which the reader fills as .credentials.json beside the derived usage file. Positional $2 overrides.
  • DELEGATION_KIT_ACCOUNT_CONFIG — the harness config the account uuid is read out of, for the snapshot’s optional account field; default empty, and empty means derive it: the reader fills ~/.claude.json, ~ read as that section states. Both usage producers and the verdict read it, and none requires it: an unreadable file leaves the field unwritten rather than failing the cycle.
  • DELEGATION_KIT_USAGE_ENDPOINT — the poll producer’s usage source; default https://api.anthropic.com/api/oauth/usage. The test seam (a file:// stub) and the stability valve when the unpublished source moves (§The usage.txt contract).
  • DELEGATION_KIT_PAUSE_PCT — default 80.
  • DELEGATION_KIT_PAUSE_PCT_7D — weekly-axis pause threshold; default 95, deliberately looser than the 5h axis. A weekly PAUSE is remediated in days, not hours, so pausing at the 5h conservatism would strand a fifth of the week’s budget, and only the true red zone stops delegation on this axis. Read by --usage-verdict and declared by --emit-usage-trend, which prints it back verbatim as the headroom line’s ceiling (§Trend reporter).
  • DELEGATION_KIT_STALE_AGE — default 600 (seconds).
  • DELEGATION_KIT_LOGIN_WINDOW — default 600 (seconds); the blanket reroute, which the account-keyed settle floor replaces wherever both identities are readable (§usage-verdict).
  • DELEGATION_KIT_LOGIN_SETTLE — the account-keyed settle floor (§usage-verdict); default 90 (seconds), validated a non-negative integer by the table validator. The server-fed percentage lags a login by about a minute, so the default leaves that lag a margin. DELEGATION_KIT_LOGIN_WINDOW stays the fallback for a snapshot or host whose identity cannot be read.
  • DELEGATION_KIT_REFRESH_CMD — the command usage-verdict runs before reading the snapshot (§usage-verdict); default empty, which keeps the read-only behavior. It is a command knob: an indexed argv, one DELEGATION_KIT_REFRESH_CMD[] = <element> line per word, spawned directly with no shell, so it takes no environment override and a command relying on shell syntax names bash, -c and its command string as three elements. This repo points it at the poll producer through the front-end.
  • DELEGATION_KIT_REFRESH_MIN_AGE — the refresh short-circuit (§usage-verdict, which owns its grounds); default 60 (seconds), validated a non-negative integer by the table validator, which bounds source traffic to roughly one poll a minute across a burst of verdict calls.
  • DELEGATION_KIT_USAGE_HISTORY — sample-log path; default empty (sampling off). A consumer points it into its metric directory, a gitignored persistent measurement trend (drift-kit/SPEC.md §Layout and configuration owns the metric-dir retention contract). Written by --usage-verdict and declared by --emit-usage-trend, whose [history-file] positional overrides it; because the default is empty, an unset knob and a configured-empty one are one reading (§Trend reporter).
  • DELEGATION_KIT_FAN_WIDTH — read-only-fan-out width bound; default 2, validated a positive integer by the table validator. It bounds read-only fan-outs only: a committing fan-out serializes or takes its own worktree regardless (the template’s Serialize on shared files; ≤DELEGATION_KIT_FAN_WIDTH-wide otherwise rule), so this knob never licenses wider concurrent committers. The default is the loss-bounding invariant, bounding the in-flight loss to what the window can absorb when the wall fires mid-flight, at a Pro-class subscription window. A Max-class window absorbs more, and an API-billed operator has no mid-flight wall at all, so there spend rate and the provider’s rate limits replace the rationale. A consumer retunes from the invariant, not the number. Supervision ceiling: operator attention over N concurrent reports does not scale with the budget window, so a bigger window raises the affordable width and not the reviewable one, and budget is never the sole input to widening. usage-verdict surfaces the live value as the width= field (§usage-verdict), the knob’s mechanical reader, which reports the bound and enforces nothing (§The delegation model).
  • DELEGATION_KIT_AGENT_DIR — the directory check-agent-tier-explicit walks for agent definitions; default .claude/agents, this harness’s conventional agent-definition location, validated non-empty by the table validator. Positional $1 overrides (fixture injection). A consumer whose harness keeps definitions elsewhere repoints it; one with no such directory leaves it and the gate reports its counted-inert clean line (§check-agent-tier-explicit).
  • DELEGATION_KIT_GATE_FILES — globs naming gate files for tamper assertion A; default ("${GATE_SDK_GATES_DIR}/check-*.sh" "${GATE_SDK_GATES_DIR}/check-*.gate"). Both declaration spellings are on the default, because a gate’s declaration path is <name>.sh or <name>.gate (gate-sdk/SPEC.md §The # graph: manifest), as the check-gate-tamper row of gate-sdk/SPEC.md §Meta-gate conservation for the binary substrate mandates. The knob names the gate files beyond what the reader derives: check-gate-tamper unions every registered member’s resolved declaration and the gate-sdk library into the resolved value (§Verify after every agent commit). A file value replaces the default whole, and a registered gate must stay covered under a consumer’s own value. A glob still covers a gate file not yet registered, such as a declaration staged in the same commit that adds it to gates.list.
  • DELEGATION_KIT_META_PATHS — prefixes counted as meta-layer for assertion A; default ("${GATE_SDK_GATES_DIR}/" "${GATE_SDK_WORKFLOW_DIR:-.workflow}/" ".claude/"); root-level *.md is always meta. The reader, check-gate-tamper, unions in every kit root the binary derives from GATE_SDK_ROOT and GATE_SDK_KIT_DIRS, as a root-relative <root>/ prefix the value does not already hold, and declares GATE_SDK_KIT_DIRS for it. A vendored kit’s edits are meta-layer by definition, so the consumer’s config need not name kit dirs. The union sits at the reader, as DELEGATION_KIT_GATE_FILES’ does, because a file value replaces the default whole and a kit root must stay meta under a consumer’s own value; the install manifest joins it on the same ground (§Verify after every agent commit, assertion A). Both unions are additive, never a filter, so a declared value cannot be lost.
  • DELEGATION_KIT_STOP_LOG — the turn-end hook’s log (§The turn-end liveness hook); default ${GATE_SDK_WORKFLOW_DIR:-.workflow}/subagent-stop-liveness.log, the deferral guard-kit’s two logs take. No scratch-dir knob sits beside it: the launch record’s home is ${GATE_SDK_TMP_DIR:-.tmp}, which every kit reaching that directory resolves, and a second name for it would be duplication rather than config. The wait-primitive probe (§bin/wait-probe) writes its producers, markers and <key>.run records there too. It mints no knob for its evidence log either, on a narrower ground: a knob needs a reader, and this one has a second process, the consumer-side hook, which must be told where to write. The probe writes and reads its own log in one arm, and the close stage reaches that log through the roster’s literal.
  • DELEGATION_KIT_LIVENESS_CMD — the liveness reader the probe invokes in set mode. It is a command knob: an indexed argv, one DELEGATION_KIT_LIVENESS_CMD[] = <element> line per word, spawned directly with no shell and the scratch dir appended after its last element, so it takes no environment override and may name its own interpreter. Its first element must resolve to an executable program, a path carrying a separator carrying the execute bit and a bare name resolving on PATH, or the override resolves to no reader. It is an override over a working default: empty means the binary’s own compiled check-producer-liveness, spawned with the gate name and the scratch dir (§The turn-end liveness hook owns why it is spawned rather than called in process). A knob rather than a literal because a consumer’s reader may not be this gate at all. The section’s honest-degradation case, every firing logging verdict=unavailable decision=allow, is reached only by an override that resolves to nothing, never by leaving the knob alone.
  • DELEGATION_KIT_READONLY_TYPES — agent-type names the consumer dispatches for read-only work; D2’s only trigger (§The delegation model). Default empty, in which case D2 is inert by construction. Every entry is the consumer’s own agent roster, never the kit’s (gate-sdk/SPEC.md §The provenance seam), on the reasoning that ships GUARD_KIT_BREADTH_PROBES with no default probes. Deliberately not knobbed beside it:
    • a fork-type-name knob and an accepted-isolation-values knob, since fork and worktree are the harness’s dispatch vocabulary, which a guard carries as literals as the wakeup-guard member matches ScheduleWakeup/CronCreate, and knobbing them would imply a portability the mechanism lacks;
    • a warn-only or per-rule opt-out, since a doctrine downgradable per session is the honour system these rules exist to end, so the valve stays the whole guard’s registration;
    • isolation: remote satisfying D2: plausible, since a remote agent cannot touch the local index, but unverified here and gated, so it is excluded rather than admitted on plausibility.
  • DELEGATION_KIT_MUTATING_TYPES — agent-type names the consumer declares mutating, the only types D4 lets dispatch unisolated (§The delegation model). Default empty, in which case D4 is inert by construction; the entries are the consumer’s own roster on DELEGATION_KIT_READONLY_TYPES’ provenance ground. isolation: remote does not satisfy D4, on the same recorded exclusion as D2’s.
  • DELEGATION_KIT_REQUIRE_TIER — D5’s switch, on or off; default off, in which case D5 is inert. The table validator refuses any other value. A switch rather than a roster of model names, because the kit ships no model name and the tier each type rides is its definition’s model: field (§check-agent-tier-explicit) or the dispatch’s own model.
  • DELEGATION_KIT_AGENT_DIR is also D5’s lookup root: a type counts as tiered when a definition under it declares that type and states model:.
  • DELEGATION_KIT_TIER_CLASSES — the class roster (§The tier binding): indexed, highest first, default judgment, routing, mechanical. The table validator refuses a name outside [a-z0-9-] and a name given twice. Being indexed, it has no environment spelling, on the binding’s own ground.
  • DELEGATION_KIT_TIER_MODEL — the tier binding (§The tier binding): indexed, one <class>=<model>[,<effort>] element per class, the class one the roster holds; default empty, which is off. The table validator refuses a malformed element, a class the roster lacks, a class bound twice, an empty model, a model carrying whitespace, an inherit model, an all-digit model and a model equal to the leading token of a different bound one. It refuses an effort that is empty, carries whitespace or carries a second comma. Being indexed, it has no environment spelling: a freshness gate reads it, and a per-session override would red that gate against an unchanged tree.
    • The effort’s shape. An effort is an ASCII letter followed by ASCII letters, digits, ., _ or -, and any other is refused. --emit agent-tiers --write puts it bare after a definition’s effort: key (§check-agent-tier-explicit), where a value opening on # reads as a comment and the definition holds no effort. Honest limit: the shape is not a YAML reading, so an effort a YAML reader types as other than a string, such as null, is not refused.
    • The lone dash. A model or an effort spelled as one - is refused. A foreign session’s record writes that for an absent field and reads it back as absent (§The foreign-vendor run), so a session opened on such a row would lose its pair at its first resume.
  • DELEGATION_KIT_FOREIGN_TIER_MODEL — a foreign harness’s rows (§The tier binding): indexed, each element <harness>/<class>=<model>[,<effort>], harness and class each a name in [a-z0-9-]; default empty. The validator refuses what the binding’s does, with a harness and class bound twice in place of a class, less the all-digit and leading-token refusals: no reader matches a running model against a foreign row. A row whose harness no adapter names is not refused, since the rows may be tracked while the adapters sit in an overlay a fresh clone lacks. The kit ships no row, since a row names a vendor’s model.
  • DELEGATION_KIT_FOREIGN_HARNESS — the harness of each foreign adapter that takes a class (§The foreign-vendor run): indexed, at most one <adapter>=<harness> element per adapter; default empty. The validator refuses a malformed element, a harness outside [a-z0-9-], an adapter DELEGATION_KIT_FOREIGN_ADAPTERS does not configure and a second element for one adapter. It holds two facts together with the adapter forms: an adapter it names carries @MODEL@ in its DELEGATION_KIT_FOREIGN_ADAPTERS form, @EFFORT@ being optional, and an adapter it does not name carries neither token in that form or its resume form.
  • DELEGATION_KIT_SESSIONS_DIR — the transcript directory --model-verdict reads (§model-verdict); default empty, which derives <config-home>/projects/<cwd-slug> through the crate’s one derivation (lifecycle-kit/SPEC.md §bin/session-id.sh), the same emptiness drift-kit’s DRIFT_KIT_SESSIONS_DIR takes. A kit resolves its own knob and hands the answer to the shared module. So this knob, drift-kit’s and lifecycle-kit’s environment-only LIFECYCLE_KIT_SESSIONS_DIR are three names for one default.
  • DELEGATION_KIT_STATUSLINE_INBOXES — array of <label>=<path> elements, each a repo-root-relative bullet file the status line counts (§The statusline arm); default empty, which renders no inbox group. The table validator refuses an element with no =, an empty label or an empty path, at exit 2.
  • DELEGATION_KIT_FOREIGN_ADAPTERS — the foreign adapters --foreign-run spawns (§The foreign-vendor run): indexed, each element <adapter>=<word>, an adapter’s argv being its words in element order. Default empty, which configures no adapter. The table validator refuses an element with no =, an adapter name outside [a-z0-9-] or an empty word. A word containing @PROMPT_FILE@ has the prompt file’s absolute path substituted, and on a class-taking adapter @MODEL@ and @EFFORT@ the pair its run’s class resolves to. The kit ships no adapter, since every one names a vendor’s program, so a consumer’s adapters usually live in the gitignored .local overlay.
  • DELEGATION_KIT_FOREIGN_TIMEOUT — the wall-clock bound on one foreign run, in seconds; default 1800, validated a positive integer by the table validator. A run outliving it is killed and reads as failed.
  • DELEGATION_KIT_FOREIGN_RESUME — each foreign adapter’s resume form (§The foreign-vendor run): indexed, each element <adapter>=<word>, the form’s argv being its words in element order, as DELEGATION_KIT_FOREIGN_ADAPTERS’ are. A word containing @SESSION_ID@ has the session’s recorded id substituted, @PROMPT_FILE@ the turn’s prompt file, and @MODEL@ and @EFFORT@ the session’s recorded pair. Default empty, which leaves every adapter one-shot. The table validator refuses a malformed element and an adapter that DELEGATION_KIT_FOREIGN_ADAPTERS does not configure. A resume form restates its confinement, since a vendor’s resume form may spell its sandbox differently from its open form.
  • DELEGATION_KIT_FOREIGN_SESSION_MARKER — the literal after which a turn’s output carries the vendor’s session id: indexed, at most one <adapter>=<marker> element per adapter. The id is the run of [A-Za-z0-9._:-] after the marker’s first occurrence, past any blanks, in the turn’s standard output, else its standard error; the blanks are skipped because a knob value cannot end in one. Default empty. An adapter with no marker records no id, so its resume form must not carry @SESSION_ID@, and the validator refuses one that does. Such a form resumes through a vendor’s “most recent session in this directory” form instead, which the clone’s own working directory scopes.
  • DELEGATION_KIT_FOREIGN_USAGE — the snapshot each foreign adapter’s window is read from (§The keyed verdict): indexed, each element <adapter>=<path>, at most one per adapter, the path repo-root-relative or absolute. Default empty, which gives no adapter an oracle. Adapters spending one vendor account name one path, so the path is the key and no second vocabulary is minted. A malformed element, a second element for one adapter and an adapter DELEGATION_KIT_FOREIGN_ADAPTERS does not configure are each refused at validation.
  • DELEGATION_KIT_FOREIGN_USAGE_CMD — the producer that writes an adapter’s snapshot: indexed <adapter>=<word> elements read as the adapter table’s are, one argv per adapter. A word containing @USAGE_FILE@ has that adapter’s snapshot path substituted, absolute. Default empty, which leaves the snapshot to whatever else writes it. An adapter with no DELEGATION_KIT_FOREIGN_USAGE element is refused at validation. It is a command knob on DELEGATION_KIT_REFRESH_CMD’s terms, run from the toplevel. The kit ships no producer, as it ships no adapter, so the values usually live in the gitignored .local overlay beside the adapters.
  • DELEGATION_KIT_FOREIGN_PAUSE_PCT — the keyed verdict’s short-window pause threshold; derived default ${DELEGATION_KIT_PAUSE_PCT}, validated numeric as that knob is.
  • DELEGATION_KIT_FOREIGN_PAUSE_PCT_LONG — its long-window threshold; derived default ${DELEGATION_KIT_PAUSE_PCT_7D}, validated numeric as that knob is.

check-gate-tamper registers in the consumer’s gates.list (tier: precommit).

agent-budget-guard is a hook, not a gate, so it registers under PreToolUse matcher Agent in the consumer’s harness settings file (beside the guard-kit Bash guard) rather than in gates.list. Wire the binary GATE_SDK_NATIVE_BIN names under <project-dir>, as command, with --hook agent-budget-guard as args (the anchor and the exec form: guard-kit/SPEC.md §The shell guard). The verdict rule is compiled into the same binary, so the guard calls it in process and resolves no path; its declared knob slice is --usage-verdict’s twelve (§usage-verdict). Registration is the whole opt-in: unwired, the guard is inert. A consumer’s session brief may also print the verdict line at SessionStart; context-kit’s template stays uncoupled from delegation-kit.

subagent-stop-liveness is a third hook, and the only one on a non-PreToolUse event. It registers under SubagentStop in the consumer’s harness settings file — an event that takes no matcher, so the entry carries a hooks array alone and is not tool-scoped, the shape a SessionStart entry already has. Wire the same binary as command, with --hook subagent-stop-liveness as args; it sources no kit lib at all (§The turn-end liveness hook owns why) and reads its own compiled check-producer-liveness unless DELEGATION_KIT_LIVENESS_CMD overrides it, so wiring alone is enough and no reader path is owed. Registration is again the whole opt-in, and here the opt-in is also the consent: the wiring is a permission-surface write, so it is the consumer’s own act and never an agent session’s.

agent-dispatch-guard is a second hook on that same matcher, registered the same way and independently. The harness fires every hook a matcher carries and any exit 2 blocks, so neither guard knows about the other and a consumer may wire either alone (§The delegation model owns why they are two hook members). It reads the consumer’s delegation config for D2’s and D4’s rosters, D5’s switch, the agent-definition directory D5 looks a type up in, and D6’s tier binding, and nothing else. Registration is again the whole opt-in: unwired, it is inert. An unwired dispatch guard is the exact failure shape it exists to catch, so a consumer that delegates wires it.

An OK verdict is a floor, not a recommendation. The guard answers one question, whether there is headroom in the window right now, and blocks only on PAUSE. Everything else a dispatch decision turns on sits outside its reach:

  • how long the dispatched work runs;
  • what a fan-out beneath the checkpoint costs in total, since the guard re-arms on those children but prices each alone, covered at every depth and aggregated at none (§The delegation model);
  • whether anything forces the dispatch now rather than after the window resets.

OK is therefore one input to that decision and never the decision itself; a dispatcher reading OK as “go” has substituted the guard’s question for its own. The asymmetry is deliberate: a wrong PAUSE costs a delayed dispatch, while an OK that is right about headroom and silent about duration costs the work in flight.

Testing

The kit’s gates speak the full gate contract (<GATE>: clean (…) / findings + help: lines / exit 0-1-2). Each ships a good/+bad/ fixture pair driven by gate-sdk’s --run-gate-tests arm:

  • check-gate-tamper, through --fixture. The bad case reaches both assertions: it carries a co-staged non-meta path and a newly added path exemption matching one of its own staged files, beside a bare token assertion B must pass over.
  • check-agent-tier-explicit, over a fixture-local agent directory passed as its one positional argument, with a case knob file binding two classes, one of them with an effort. The good/ side carries the passing shapes: a definition naming a cheaper class outright, with its class’s bound model, one holding both halves of its class’s pair, and one declaring inherit. So the pair proves the gate discriminates omission from choice rather than merely finding a model: string. The bad/ side omits the field (A), and carries a stale model line, a stale effort line and a definition naming an unbound class (B). The arm’s report, its --write and its refusal are its own module’s unit tests, and the binding’s refusals and matcher are crate tests beside the knob table and the matcher.

The tamper pair cannot reach the live arm, a property of the injection rather than of the cases. --fixture supplies both lists directly, so the function that derives them runs in no fixture case. That function reads each staged gate file’s bytes out of the object store and diffs its exemption set against HEAD’s. gate-tests/check-gate-tamper.test.sh exercises it: each case builds a throwaway git repo, stages a commit shape into a real index, and runs the gate inside it. It pins the four verdicts the pair cannot:

  • a HEAD-resident exemption is not “added”, even when a co-staged file matches it;
  • a newly appended one is;
  • an added one matching nothing staged stays silent;
  • a staged deletion of a gate file reads as an empty exemption set rather than a failure to read.

The usage-verdict decision table. usage-verdict renders a three-state verdict, not a clean/violation pair, so it takes a decision table rather than the gate contract. usage-tests/cases.tsv pairs an expected verdict token (OK/PAUSE/STALE/RESET-OK) and exit code with scenario knobs: percentage, snapshot age offset, reset offset, credential age, the snapshot’s account and the live identity. The crate test module native/src/usage_tests.rs reads that table off disk. It materializes each case as a generated snapshot file and asserts verdict and exit code. Timestamps are computed relative to now, because static fixtures would age into permanent STALE.

  • The subject is reached in process, one call to the same verdict(args) -> (String, i32) the budget guard makes (§usage-verdict).
  • The clock is read once for the whole run, and every case’s timestamps are computed against that one reading. A per-case read introduces cross-case skew that flakes exactly the at-or-over boundary rows the table exists to pin.
  • Each case runs hermetic to the host repo. Its throwaway sandbox is also the gates directory, so the only knob file on the lookup path is the one the case writes. The ambient DELEGATION_KIT_* environment is stripped before each invocation. The table encodes the kit defaults, and a host override (a raised pause threshold, say) must not reshape it. DELEGATION_KIT_ACCOUNT_CONFIG always names a sandbox fixture, because its empty default derives the host’s own account config and the host’s identity would reshape the account rows.

Three properties carry that hermetic contract, and each is a place a reimplementation would quietly differ:

  • The strip is the whole DELEGATION_KIT_* namespace, derived at run time from the running process’s own variables. A hardcoded list of knob names would re-create precisely the failure the poison exists to catch. The prefix is the namespace because a static scalar knob’s environment override is its own name (gate-sdk/SPEC.md §The knob file), the spelling the subject reads. The stripped pairs are held and restored at the end. An indexed knob takes no environment override, so a case’s DELEGATION_KIT_REFRESH_CMD argv is written into the sandbox’s knob file, one element per line, rather than exported.
  • The poison stays a real value in the running process: DELEGATION_KIT_PAUSE_PCT, at a threshold that PAUSEs every under-threshold row. It is re-armed before every case, so the strip is proved at each one. The seeding order is what makes it load-bearing. The case’s scalar overrides are applied first, and each kit default is then seeded only where the name is unset, so a poison the strip failed to remove survives and the table reddens. A seed that wrote unconditionally would pass every assertion while proving nothing. A negative control beside the table skips the strip once and asserts the surviving poison PAUSEs. The write goes through knobenv’s serializing guard, the crate’s sole writer of the process-global environment (gate-sdk/SPEC.md §lib/gate.sh), because the subject shares that environment with the test.
  • The per-case expectations stay literal expectations of the kit defaults. The seeded defaults are the test’s own literals rather than reads of the table, so the table does not prove the defaults it encodes are the ones delegation-kit’s defaults table ships. Two holders carry that link: check-knob-default-coupling holds every literal default against the owning SPEC, and smoke/install.sh drives a real front-end invocation under its hermetic prelude. Spawning the front-end from the crate test is refused, since it would buy a bash spawn for what those two already cover.

touch -d "@<epoch>" sets the credentials mtime, the whole login-window input. It stays a spawn because File::set_modified and FileTimes stabilised in Rust 1.75 and native/Cargo.toml pins rust-version = "1.71". The MSRV holds this open, not a shell subject: native/src/emit/session_id.rs spawns the identical touch in its tests with no shell subject anywhere.

The columns are verdict exit pct age_off reset_off cred_age pct_7d reset7d_off account live append axis desc. The offsets are seconds from now, and pct_7d - omits the weekly keys. account is the snapshot’s key and live the oauthAccount.accountUuid the case writes into the fixture account config, - omitting each. append is the expected sample-line count, counted as grep -c '' counts, a final unterminated line included; a line iterator’s count is silently one short of it. axis asserts which window a PAUSE names.

Every verdict branch and both pause axes carry a firing and a non-firing case, the fixture-pair discipline transplanted. The table covers:

  • a weekly PAUSE while 5h is comfortable, with the axis named in the output;
  • absent keys disarming the weekly axis, and a dead weekly window not pausing;
  • each axis’s at-or-over boundary: a reading exactly at the threshold pauses, and just under does not;
  • both directions of the login reroute. An under-threshold percentage with a fresh login is STALE, not OK, and an at-or-over one on either axis is still PAUSE, not STALE. The asymmetry keeps the reroute from waving an unaffordable dispatch through;
  • the account-keyed arms. A mismatched account inside and outside the login window is STALE naming the account switch, and at or over threshold still PAUSE. A matched account past the settle floor inside the old window is OK, and within the floor STALE. A snapshot without account keeps the blanket window. Every row asserts the account-switch wording fires on its arm alone;
  • the sample-append discipline: a parsed snapshot appends one line whatever the verdict, a non-numeric snapshot appends none, and pct_7d shows present or omitted in the passed-through line.

Every case also asserts the fan-width field on the verdict line, matched with its trailing space: without it width=20 matches width=2 and the assertion stops discriminating. One case beside the table proves the field tracks DELEGATION_KIT_FAN_WIDTH rather than a literal. The default width the per-case match pins, and the refresh floor a diagnostic below names, stay literal expectations of the kit defaults. Reading either from the library would make the table agree with whatever the library says rather than assert what it should say.

The demand-driven refresh needs a command seam the table’s columns do not carry. It is asserted beside the table through a stub DELEGATION_KIT_REFRESH_CMD, since a real poll would need the network:

  • armed: a stale snapshot invokes the command, and the verdict reads the values the refresh wrote, not the cached ones;
  • fail-soft: a non-zero stub leaves the snapshot byte-identical, and the verdict proceeds to judge the cached reading STALE. No refresh diagnostic leaks into the relayed verdict line. That is a negative conjunct over the merged stream, because callers relay the line verbatim and a stdout captured apart from stderr passes it vacuously;
  • short-circuit: a snapshot under DELEGATION_KIT_REFRESH_MIN_AGE never invokes the command;
  • account switch: a snapshot under that floor naming another account than the live one still invokes the command, and the verdict reads the live account’s re-polled percentage; a failing stub over the same snapshot leaves the account-switch STALE.

Armed and short-circuit are the firing/non-firing pair over the same stub, so the absence proves the floor rather than a broken stub.

The roll witnesses need a pre-seeded history tail the table’s columns do not carry, so they are asserted beside it too. The fixed shape is a low percentage with a credentials mtime inside the login window, which without a witness is STALE. Both witnesses present verdicts OK. Each of the four ways the log can fail to answer holds the STALE: the boundary unmoved, the previous boundary uncrossed, a tail with a non-numeric resets_at, and no history at all. The two single-witness cases are the discriminating pair, proving the conjunction is load-bearing rather than either witness carrying it alone. A sixth case asserts the read is not self-witnessing: a run with an empty log still STALEs and leaves exactly its own sample, so the witness can only ever be a previous reading. The witness’s account partition is a unit test in the verdict’s own module, over a history whose last line is another account’s: the snapshot’s account reads its own newest sample, an absent account the newest of all, and an account with no sample falls open.

The usage-trend assertion set. --emit-usage-trend renders no clean/violation verdict, so it ships an assertion set over a static fixture history, usage-tests/trend-history.log. Static epochs are safe because the reporter measures within-segment deltas, never against now. The same crate module carries it under the same sandbox and strip: the ambient namespace is held aside and reseeded from the kit defaults, so the arm reads the empty history knob and the unset arm fires. It carries no poison, because a static knob has one spelling and so no wrong reader for a poison to catch. It asserts:

  • per-axis segmentation at a reset boundary, a login_at change and an account change;
  • per-account grouping reuniting a weekly trajectory across a switch-back;
  • a spike-then-correction flagged and excluded from the rate rather than averaged, and a segment ending in one keeping its last reading;
  • token deltas and weekly headroom on the report;
  • the combine block’s golden lines over the fixture’s two accounts, and its absence from a one-account log the test writes;
  • the fail-closed refusals (knob unset, history missing), each read off the arm’s own Err;
  • the zero-segment reading, which stays a document at exit 0 rather than collapsing into the misuse code;
  • the -- escape reaching a dash-led history path. The shape refusal and its --help instance are the arm’s own test module’s (gate-sdk/SPEC.md §The non-gate arm).

Its needles are exact golden strings, and its segment counts are anchored on the reporter’s own two-space indentation. Loosening a needle into a pattern weakens the assertion, and that indentation is load-bearing rather than cosmetic.

No fixture pair for the non-gate members. Neither runner is a gate, so no fixture pair is owed. Criterion 2’s discharge for a non-gate member is the both-substrates comparison bought once at port time (gate-sdk/SPEC.md §The port-candidate criteria). Nothing machine-held keeps the two substrates agreeing afterwards, so the shell originals are deleted rather than left running beside the crate tests. Where a member’s output is a measurement, as --wait-probe’s is (§bin/wait-probe), the comparison takes this shape:

  • a record file the member writes, such as the <key>.run record, is a first-class subject, since nothing static couples a gate to its shape;
  • the exit status is compared per subcommand, with an empty-evidence report run in both substrates so the three-state contract’s 1 is compared and not assumed;
  • a wall-clock field is compared by the relation it measures (waiter_at >= marker_at) and the class that relation produces, never for equality. Byte equality there fails a correct port and passes nothing;
  • each row is also held against its own expected value, so two empty outputs cannot pass as agreement.

A member with no live tree corpus, whose input is a crafted snapshot or processes it stands up itself, has its case set for its corpus, so the comparison over it is complete rather than demoted.

The model verdict. native/src/hook/model_verdict.rs’s test module drives the arm’s library function over transcripts it writes, each verdict row both reached and missed. The binding and the sessions dir are written into each case’s sandbox knob file under the budget verdict’s hermetic contract, with the DELEGATION_KIT_* namespace held aside. It covers:

  • READ, a bare read of an unbound id;
  • OK by an exact id, by an alias token, and by a stronger class under a floor;
  • BELOW, a mechanical-only match against --expect judgment;
  • UNBOUND, an id matching nothing;
  • UNKNOWN for an empty transcript, a transcript whose only assistant record has all-zero usage, the binding empty under --expect, and an unbound expected class;
  • both operand forms, a path and an eight-character key naming an agent- transcript;
  • the newest-record rule, a transcript switching model mid-file reading the later model;
  • the effort= field, its - on a record carrying none, and the clause on an OK whose effort is not the expected class’s, absent on a bare read and under a class binding none;
  • a consumer’s own roster ranking its classes, and an --expect its roster lacks refused;
  • the shape refusals and the -- escape.

The foreign-vendor run. native/src/emit/foreign_run.rs’s test module drives the arm’s library function against a throwaway source repository. Its stub adapters are programs the crate’s tests already spawn: git first (git hash-object reads the prompt, git config --file writes a file, git commit --allow-empty moves HEAD), and bash for a sweep’s two writes and for the timeout row’s exec sleep under a one-second bound. It covers:

  • OK in both modes: an audit whose adapter prints and writes nothing returns its output as the report, and a sweep writing a tracked and an untracked file yields a patch that git apply --check accepts in the source repository, while an empty change yields none;
  • REFUSED: a committing adapter in both modes as committed, and an audit that wrote as audit wrote, each keeping its clone, and a kept clone failing the next run under its key;
  • FAILED: an unknown adapter, an unreadable prompt file, a non-zero adapter exit with its report kept, and a timeout;
  • @PROMPT_FILE@ substitution, standard input carrying the prompt, the clone removed on every non-refusal exit of a one-shot adapter, the clone carrying no remote, the argv shape, and an adapter’s words assembled in element order.

The session rows run stub resume forms the same way:

  • Open. A resumable adapter’s OK keeps the clone and writes session.txt and refs.txt. A marker in standard output, and one only in standard error, each record the id. A resume form carrying @SESSION_ID@ with no id captured opens no session and removes the clone, and a form without it opens one recording -.
  • Resume. @SESSION_ID@ and @PROMPT_FILE@ are substituted. The previous turn’s report is rotated, and the turn counter advances. A sweep’s patch covers the session’s whole change across two turns, a failed turn’s included.
  • The resume verdicts. A resume whose stub commits is REFUSED and removes session.txt. A non-zero resume is FAILED and keeps the session. A resume of an unknown key fails without a spawn.
  • Close and collision. --close keeps every report, a second --close fails, and a new open clears the rotated reports. A new --foreign-run under an open session’s key fails.
  • The resume argv shape, and session.txt reading back what it wrote, a line without the class fields included.

The class rows run a stub adapter that echoes its argv, on a harness the case’s rows bind:

  • both tokens substituted inside one word, the line and session.txt naming the class and its pair;
  • a class-taking adapter without --class, and with a class the roster lacks, each a shape refusal;
  • an unbound class, and an effortless row under @EFFORT@, each failing before the budget read with nothing cloned;
  • --class on a plain adapter, whatever it names, printing class=-;
  • a resumed turn carrying the opened pair after the binding changed, the next open taking the edited row, and a resume form asking for a pair its session lacks failing before the spawn.

The keyed-verdict rows bind a snapshot file and a stub producer, bash again, and run no vendor’s program:

  • A pause spawns nothing. An at-or-over reading fails the run as FAILED (budget: …) with budget=PAUSE, and no run directory exists afterwards. The same reading past the stale age still pauses, beside an under-threshold one of that age that reads STALE and proceeds.
  • The long axis ahead of RESET-OK. A dead short window beside a live at-or-over long one pauses naming the long window and spawns nothing. Beside an under-threshold, absent or reset long window it is RESET-OK and the run proceeds.
  • Budget-unknown proceeds. A STALE reading and an adapter with no snapshot, OFF, both run, and the line carries the word. A line printed before the read carries budget=-.
  • The producer. A stub writing through @USAGE_FILE@ feeds the read that follows it. A producer exiting non-zero, one outliving a one-second bound and one that cannot spawn each leave the snapshot read as it was.
  • A paused resume. The turn counter holds, the previous report is not rotated, and the next resume under a reading with headroom runs turn 2.
  • The --budget form. The keyed line and its three exits, an OFF among them, no clone made, an unconfigured adapter refused, and the form’s argv shape.

native/src/hook/verdict.rs holds the rule’s two orders against one snapshot set: an old at-or-over reading is STALE on the account-keyed order and a pause on the keyed one, and a dead weekly window arms no axis. A dead short window beside a live at-or-over long one pauses on the keyed order and is RESET-OK on the account-keyed one. One foreign-run row hands the contract composed for each stage that admits the host protocol to an audit-mode run under the stub adapter, which reads it whole (lifecycle-kit/SPEC.md §The stage-contract arm).

The adapter table’s refusals, the resume and marker tables’ refusals (a resume element for an unconfigured adapter, a second marker for one adapter, @SESSION_ID@ with no marker) and the timeout’s shape are crate tests beside the knob table. So are the snapshot and producer tables’ refusals (an unconfigured adapter, a second snapshot for one adapter, a producer with no snapshot) and the keyed thresholds’ numeric shape, and the harness table’s refusals with the two facts it holds against the adapter forms. The roster’s and both class tables’ refusals are crate tests beside the row parser.

The dispatch and budget guards. agent-budget-guard and agent-dispatch-guard are hooks rather than gates, so each speaks exit-2 + hook JSON rather than the gate output contract. Their cases run as crate #[test]s reaching the subject directly. A runner lives in the crate when its cases can be driven from there, the subject reached in process or spawned. No crate test spawns a kit bin/ tool as a test subject. Crate tests do source gate-sdk/lib/gate.sh to read the shell library’s own answer: a pre-binary accessor, the gates-directory default or the source stamp. That is deliberate, because a Rust literal would restore the second source of truth those tests exist to hold, and that library stays shell on its own grounds (gate-sdk/SPEC.md §lib/gate.sh).

The crate cases run under check-crate-arms at every commit and under the native_crate validate suite. The whole case set costs under a second of wall clock against an arm that already runs at every commit.

usage-tests/dispatch-guard-cases.tsv is kit test data read by the crate test. It pairs an expected outcome (block/advise/fallthrough) with subagent_type isolation nested model desc, and fixes one roster for its whole run (DELEGATION_KIT_READONLY_TYPES=(ro-type)). The type column carries four sentinels:

  • a noroster: prefix runs the row against an empty roster, proving D2 inert on the roster’s absence rather than on a type missing from a populated one;
  • an armed: prefix runs it with D4’s roster (mut-type), D5 on and one definition stating model: (for tiered-type), so D4 and D5 are driven without a directory, and with the tier binding empty, so D6 stays inert there;
  • a bound: prefix runs it with the tier binding judgment=big-model and mechanical=small, so D6 is driven;
  • UNPARSEABLE is the degraded row that makes the fail-open-but-loud posture testable rather than merely stated.

The model column’s - is an absent model and any other value a named one, the kit naming none. Moving the table into Rust literals would trade a reviewable table for a recompile. The budget guard keeps no table: its guard-side assertion is a two-branch routing, exit 1 blocks and every other status advises, which the member’s own crate test covers directly.

Every rule still carries a firing and a non-firing case:

  • D1: a fork blocks, and a typed dispatch under an empty roster does not.
  • D2: a rostered type without isolation blocks. The same type with isolation: worktree does not, nor does an unrostered type under a populated roster, nor the empty-roster case.
  • D4: an undeclared type unisolated blocks, and so does an absent type. The same type isolated, a declared mutating type unisolated and any type under an empty mutating roster do not.
  • D5: a dispatch naming no model, of a type no definition tiers, blocks. The same dispatch naming its model, a type whose definition states model:, and any dispatch with the switch off do not.
  • D6: a named model outside the binding blocks. A named bound value, a model id carrying one as a token after its first, a dispatch naming no model, and any dispatch under an empty binding do not.
  • D3: a payload carrying agent_id advises, and one without it falls through.

The definition lookup itself, a declared name: before the file stem and a definition stating no model not counting, is a crate unit test beside the table, and so is D6’s refusal under an unreadable door.

The consumer smoke. smoke/install.sh copies the templates and bin/ tools into the scratch consumer and registers the tamper gate. It drives one crafted snapshot through the --usage-verdict arm and asserts a verdict. It is the self-verifying install, and the holder that keeps a real front-end run and a real knob resolution in this kit’s coverage.

It opens with one hermetic env prelude covering the whole file. The prelude strips the whole DELEGATION_KIT_* namespace, then sets the knobs with no per-call home: sampling off, and both pause thresholds at the defaults the 95% reading is judged against. Pinning per instance misses a knob by construction, and the miss is silent. An unpinned history path makes every smoke verdict append a synthetic sample into whatever trend log the ambient config names, indistinguishable in shape from a real operator reading. Stripping the whole namespace cannot miss a knob.

The credentials pin stays at each invocation, because gate-sdk/SPEC.md §check-test-hermetic reads it there: a line-local pin is evidence a reader and a gate can both see without tracing file-level state. The poller’s own snapshot, credentials and endpoint paths stay inline too, as genuine per-call arguments.

  • The 95% assertion is code-specific. It captures the exit status and compares it to the PAUSE code, reporting the status it observed, so a STALE regression cannot pass under a “did not PAUSE” message (gate-sdk/SPEC.md §check-assertion-strength).
  • The poll producer is driven through its file:// stub seam. The happy path writes a contract-valid snapshot, asserted by driving it through usage-verdict. A fetch failure exits non-zero and leaves a pre-seeded snapshot byte-identical, the fail-soft contract end to end.

smoke/violation.sh stages a gate edit co-staged with a product file in the scratch consumer and asserts the battery reds (assertion A). The violation is craftable, so the file is mandatory (gate-sdk/SPEC.md §Consumer smoke).

Both scripts stay on the shell substrate permanently and carry # no-port: saying so. The disposition is the class ruling at gate-sdk/SPEC.md §Consumer smoke, The port disposition. It reaches them by its ground rather than by its scope: its stated-contract cut covers the recipes answering to §Consumer smoke, and these two answer here, but its legs 2 and 3 hold of them identically.

Out of scope

The split is protocol and mechanisms here, and a consumer’s rosters, batteries and bespoke UX in its own copies.

  • A consumer’s validate battery (its compile, lint and test set and rename-corruption sweeps), its shared-file roster, and its width and burn anecdotes are rule content, referenced only as marked-section examples.
  • A task-output tailer is not shipped: it hardcodes local harness paths and exists to break the template’s Background + notification, never poll rule.
  • The usage.txt write contract and a reference producer rendering the gauge bars and iteration@stage readout ship, its ANSI self-contained and pulling no external asset. A consumer’s particular statusline styling, and any asset its own gauges pull, stay with the consumer.