lifecycle-kit — an evidence-stamped iteration lifecycle for stateless agent sessions
An iteration is a self-contained work cycle driven through a configurable sequence of stages (default: scope → [align] → build → validate → close). The problem the kit solves: a stateless agent session cannot be trusted to remember, or even re-read, a process document — so the process state lives in two governed files a gate can read, and every stage transition leaves machine-checkable evidence.
The kit carries the generic state machine only; a consumer’s stage names, exit conditions, and ritual content are config and skill-template fill-ins. Requires gate-sdk (the gates follow its four contracts and resolve through its registry).
The state machine
Two governed surfaces, carrying one axis each:
-
The header line, at the top of the consumer’s queue file (default
TASK-QUEUE.md), carrying the slow axis — the iteration name, and nothing else:## Iteration: <name> -
The evidence file (default
.workflow/WORKFLOW-STATE.txt): free prose, then a---separator, then one data line per stage-skill invocation:<iteration> <stage> <session-id> <YYYY-MM-DD> <head>It carries the fast axis too: the last data line’s
<stage>token is the cursor — the single source for “which stage is this iteration in”.<head>is the abbreviated commit--enter-stageread when it wrote the stamp, or the literalnonewhere it found no git work tree and no commit. It is required, andnoneis a value rather than an omission: an optional field would be a permanent disarm switch for the provenance assertion it serves (§check-stage-evidence). It is appended last, so every positional reader of fields one to four is unmoved.The iteration-start commit is the first data line’s
<head>. The boundary truncation leaves the boundary stamp as the first data line, and its<head>is the commit the iteration opened on. Anything measured “since the iteration started” is measured from it. The unnamed-iteration sentinel and--enter-stage --renameleave it alone, since both touch column 1 only, and §check-stage-evidence’s stamp-provenance assertion holds it true. There is no iteration-start commit when the file is absent, when it has no data line (the no-cursor window), when that<head>isnone, or when this clone cannot resolve it. Each reader states what it does then.The previous-close commit is the
<head>of the last stamp naming the last configured stage, in the state file as it stood at the iteration-start commit. Read it withgit show <iteration-start>:<state-file>, the path repo-relative, and take the<head>of the last data line whose<stage>isLIFECYCLE_KIT_STAGES’ last member. The boundary truncation has not yet run at that commit, so the blob still holds the previous iteration’s stamps.It closes a hole in the iteration-start range: everything the previous close committed after its own audit review, and any interstitial commit, lies before the iteration-start commit. A reader that must see those commits measures from here instead. The range then re-covers the previous close’s commits up to its review, and that overlap is taken, since a re-read costs reading time and a hole costs an unswept edit. There is no previous-close commit where there is no iteration-start commit, where the blob carries no last-stage stamp (a first iteration, or a tree adopting the kit mid-history), or where that
<head>isnoneor does not resolve. Each reader states what it does then. It is a prose derivation rather than a stage-machine adapter (§The stage-machine adapters): its one reader is a closing session, and an adapter earns its place only when two readers must agree.The five-field grammar is a breaking change to a shipped file format. A consumer vendoring it mid-iteration reds until it rewrites its own stamps. Each stamp’s
<head>is the first parent of the commit that introduced that line, andnonewhere that commit cannot be identified, because a guessed provenance value is indistinguishable from a real one. That rewrite is a rewrite and not an introduction, which the migration clause of §check-stage-evidence’s newly-introduced test exists to say. A consumer upgrading at an iteration boundary pays nothing, since the first stage’s reset truncates this file.
The header carries no stage field; the cursor is derived from the stamps. Every header reader strips an optional trailing bracketed field, so a residual [stage:] from an older header is inert, and the next iteration-boundary reset rewrites the header without it.
Both surfaces are single-writer and branch-scoped: an iteration owns exactly one branch (its home branch) and every stamp lands there, so concurrency between operators is git branch topology, not a multi-writer state file. The integration branch is the degenerate single-operator home; a second concurrent operator cuts a branch at their scope entry. The merge semantics that make this safe are §Multi-operator semantics.
The default motion is the linear stage walk; the gate-legal shapes for leaving it — abandon, split, reopen — are specified in §Deviation transitions, not improvised.
An iteration carrying an observation-predicate entry places its first push early. Where a promoted entry carries [observed-by: <producer>] (queue-kit/SPEC.md §The tag algebra), its completion predicate is an observation of a remote run rather than a tree state, so landing the work does not complete the entry:
An iteration whose promoted set carries any
[observed-by:]entry places its first push at or before the stage that lands that entry’s work — never at the closing stage.
The rule re-places a push rather than buying one: a finished run is read free, and the commits recording the reading ride the closing push. So the observation arrives between the iteration’s two pushes, in time to be read, committed and drained before the drain gate. Without it the entry cannot drain in the iteration that bought it. §check-stage-entry assertion B refuses a drain-stage entry on a non-empty active queue, and [drain-exempt:] reaches the drain stage’s own entry but never its successors’.
The seam is held deliberately. How many pushes an iteration may spend is consumer content, and this rule constrains only where the first one falls. The budget’s readers are sessions — scope stating a unit set’s push need against it, and each pushing session — so it is close’s push-budget slot and not a knob (§templates/stages/). No arm would read a knob, and a default would ship one consumer’s policy as the kit’s.
The actor is the stage session whose next act reads the run the push produces. For an [observed-by:] entry that is the session landing the entry’s work. It pushes after its landing commit, under the push line scope landed on the entry, waits in-turn for the run, and reads it before the Done move. A stage using a remote run as its oracle pushes for itself, so no fixed stage pushes. The lead never pushes, since a push publishes lifecycle state it never writes. Every push, at any stage, runs the per-push identity precondition close’s push step names.
The rule is not enforceable. A push’s timing and actor are no tree state, so no arm observes that a push was or was not spent, or by whom. It is a session obligation carried by prompts, and the machine contributes a refusal that names it (§check-stage-entry).
Where <producer> is a release run — tag-triggered, so no mid-iteration push can fire it — the placement is unsatisfiable. The scope stage splits such an entry at promotion into a produce half and an observe half rather than promoting it whole. The clause lives in scope’s template (§templates/stages/), because scope is the one moment the unit set is bounded and the one session that may write the queue.
A stage owes a resume journal, and the obligation is the stage’s rather than the dispatch’s. delegation-kit owns the journal contract — what a session writes into it, when, and who may delete it (delegation-kit/SPEC.md §Resume journal — agent writes, scratch reset sweeps). This section owns the path, because only the stage machine knows the stage. LIFECYCLE_KIT_STAGE_JOURNAL_PATTERN carries a <stage> placeholder, and expanding it is the one derivation the stage entry and the journal arm read. The entry tool opens the journal at the stamp and reports the path it wrote (§bin/enter-stage.sh), and the journal arm appends there from the caller’s stamp (§The journal arm). The tools compute the derivation, so no surface gains a second spelling of it.
The obligation is instructed where the owing session reads it. The stage template’s last step names the journal as the stage’s exit artifact, mirroring the stamp’s first step (§templates/stages/): one instruction, on the one surface every stage session loads on every dispatch path. An assertion at the next stage’s entry fires after the owing session is typically gone, and a supervisor’s agent definition reaches only a dispatched session, where this kit also sanctions a stage run with no supervisor. Three other shapes are refused:
- An assertion at the session’s own turn end, wired to a harness stop event — not mechanizable as stated. A stage run with no supervisor is the top-level session, whose turn end delegation-kit deliberately leaves unregistered (delegation-kit/SPEC.md §The turn-end liveness hook).
- The returning report asserts the path it wrote — unenforceable by construction. A return lives in the parent’s context and leaves no artifact (delegation-kit/SPEC.md §Resume journal — agent writes, scratch reset sweeps), so no scanner over a repository reaches it. It also points the supervisor at hand-derived completeness, which §templates/lead.md forbids.
- Accept the escape and trust the successor — the escape is bought after the reasoning is gone, by the one session structurally unable to reconstruct it.
The entry report is the one source of the path. A path invented per dispatch leaves no record on disk, so no gate can name the file a stage owes, and two dispatchers can disagree about where it lives. The supervisor names no path in a dispatch prompt, because a restated path is a second source the dispatched session prefers. Entering is therefore how any session of a stage learns its journal, which binds the per-session stamp the same-stage re-entry paragraph below rules. The honest limit: no oracle detects a session that did not enter, because no tracked artifact names a session but the stamp itself.
One journal per stage, appended by every session of that stage. The path is a function of the stage, so each session appends to the file its predecessors wrote, under a heading naming itself. A discriminator such as a batch name belongs in that heading, never in the filename, or the successor’s assertion has no file to name. The DONE-as-last-line rule reads unchanged: an earlier session’s marker mid-file is no lie about that session, and the last line answers did the session that wrote last finish. The entry assertion is satisfied by any session of the predecessor stage having written, since the successor needs the predecessor’s reasoning, not one session’s. The honest limit: two sessions of one stage running concurrently append to one file with no coordination beyond append atomicity, which is untested and filed rather than claimed safe.
The stamp protocol
The arriving stage’s skill stamps the evidence file as its first step, and that stamp is the whole transition, because the last stamp is the cursor. The departing session writes nothing, so no uncommitted stage line crosses the session boundary. The entry commit stages the evidence file, so every state-coupled gate re-fires on it: the prior stage’s machine-expressible exit is re-verified at the entry (check-stage-evidence), and check-stage-entry extends that one hop back. The kit has no self-asserted “stage complete” marker, which would prove a claim, not completion.
What the entry re-fires, and what it does not. The gates coupled to the evidence file — check-stage-entry, check-stage-evidence, check-evidence-manifest, check-trajectory-fresh, and any gate globbing the workflow dir — re-fire at every entry. Queue-only-coupled gates do not, since the queue is not written at an entry and each of them couples the input it actually gates. What is given up is an incidental periodic sweep that could catch drift introduced out of band (a --no-verify commit, an edit outside every coupled glob). The full battery at the validate stage is the surviving sweep and the stronger one — an accepted, costed loss.
Mid-iteration the queue file is written only for real work-state transitions: promotion and naming (the first stage), the Done move riding each amendment-merge commit, and the closing dispositions. Stage motion never touches it.
A stage session landing a direction writes its class and date in the same commit as its content. An answer that nothing changes is landed too, because a question recorded as open reads as open until something says otherwise. It lands on the surface that recorded the question, stating the answer, its class and its grounds. A question held in the gap inbox takes a new bullet through --emit file-gap naming the bullet it resolves, since the inbox is append-only (§The committed gap inbox), and the drain dispositions the two together.
The form is inline beside the content (queue-kit/SPEC.md §The tag algebra), and the classes are §The steering vocabulary’s. This section owns when the session writes it, because the session’s commit is the audit artifact and the relay is transport, never a store. Content landed without its class has lost the only party who could attest it. Where the relaying party stated no class, the session asks rather than defaulting upward: reading a direction into a silence invents one, and inflating a relaying party’s decision to the operator’s freezes a revisable call. The relay states the class (templates/lead.md) and the landing session records it here, and neither half discharges the other’s.
The deterministic half of that first step — read the iteration from the header, read the id from the --emit-session-id arm, append the stamp — is bash gate-sdk/bin/run-gates.sh --enter-stage <stage>, the writer/asserter split of --emit graph ↔ check-graph. The skill invokes it and judgment stays in the skill (what the stage means, its exit condition, when to enter it at all), while the stage gates stay the independent verifier. The tool takes no --force flag. An operator who intends to override writes the stamp by hand, and that override is never a session’s, since the workflow-state guard blocks a session’s hand write (§check-stage-evidence). So a pre-flight refusal’s help: names the session’s remedies alone, resolving the finding at its source or escalating it, and names the hand stamp only as the operator’s.
Committing the stamp is the skill’s business. The commit that adds a stamp carries the stamped stage as its subject scope (<type>(<stage>): …): --enter-stage prints that subject and check-stamp-subject holds it, so a recovery keyed on the stage name (git log --grep, a pickaxe) finds the entry. A stage session’s other commits keep their component scope. The stamp commits on its own, with one exception: an entry the one-shot pre-flight valve admitted rewrites the valve ledger in the same motion, so the two commit together, and the purity assertion exempts exactly that path (§bin/enter-stage.sh, §check-stage-evidence). It never commits with --no-verify. --enter-stage refuses to write while check-stage-entry is red, so a bypass skips exactly the battery that would confirm the stamp.
The first stage is the iteration boundary: --enter-stage truncates the evidence file back to its header (git history is the permanent audit trail; the gates only read the current iteration). It stamps under the unnamed-iteration sentinel —, rewritten to the real name when the stage names the iteration. Later stages only append.
The no-cursor window. The cursor has an empty state, in two shapes. The boundary truncation leaves the evidence file holding its preamble and --- with no data line until the boundary stamp lands, and an unvendored or pre-upgrade consumer has no file at all. Every reader of the cursor, in this kit and in every consuming kit that derives it, states its own behavior for that window in its own section. The shared derivation stages::current_stage reports it as empty with a success status, because “no cursor” is a legitimate state and not an error.
Honest limit: a stamp proves the stage skill was invoked, not that its work was done faithfully. The <session-id> field is read, not hand-picked: the --emit-session-id arm prints the canonical id by a fixed derivation order (§bin/session-id.sh), which rotates per session, including across a context clear. check-stage-evidence’s invocation floor keys on <iteration> <stage>. It also reads the session id to enforce cross-stage distinctness under the default stage posture, which the iteration posture of LIFECYCLE_KIT_SESSION_BOUNDARY relaxes (§check-stage-evidence).
Same-stage re-entry (N sibling sessions per stage). Entering the currently-stamped stage from a new session is legal and appends a fresh stamp. --enter-stage’s idempotence guard keys on the full (iteration, stage, session-id, head) tuple, so only the same session re-entering at the same commit is a no-op. A re-entry after HEAD moved appends a stamp carrying the new head, which makes re-running the tool the remedy for a stale recorded head (§check-stage-evidence). check-stage-entry assertion A keys on the predecessor stamp, which the stage’s first entry satisfied for every sibling. So N sessions may enter one stage — a multi-session build, or a lead’s intra-stage batch split (§templates/lead.md) — serialized by the shared index and HEAD, each leaving its own stamp. A sibling’s stamp leaves the cursor where it was.
The optional lead never becomes a second state source. An iteration may run with a live lead session (§templates/lead.md) that dispatches its stage sessions and answers their escalations, so a blocked stage resumes in place. Every stamp originates in a stage session through --enter-stage, so the stamp protocol stays the only iteration state and a lead crash costs nothing the tracked surfaces do not hold. The lead is a boundary skill, not a stage: it stamps nothing and joins no stage set, so the coverage gate never reads it.
A lead stamp is ruled out, and the gate agrees. Under LIFECYCLE_KIT_SESSION_BOUNDARY’s stage posture a live lead holds one session id across every stage it touches, so a lead-written stamp is precisely the cross-stage duplicate §check-stage-evidence reads as a self-reported skip. The template states the ban and not this reasoning.
Honest limit on the lead’s dispatch precondition. A lead dispatches stage N+1 on stage N’s agent completion notification (§templates/lead.md). It never dispatches on an artifact — a commit, a stamp, a clean tree, a green battery, a cleared --simulate — nor on the hand-back report, which can arrive ahead of the notification. That precondition is prose-only and human-enforced, for a structural reason. The signal’s producer is the harness, emitting it at the dispatched session’s turn end outside every governed tree, so no gate can read it. Its consumer is the lead’s own in-turn wait. Its truthfulness is not the harness’s to guarantee: a session that hands back over still-running work draws a notification for a report that does not hold that work’s result. One harness build worded such a notification as possibly interim and sent a second at the session’s later stop (delegation-kit/SPEC.md §The turn-end liveness hook), a wording no contract covers. The turn end itself is what delegation-kit/SPEC.md §Operative residency exists to prevent. The limit is stated so a later session does not build the impossible gate.
Its delivery has a limit too, and the template’s remedy for it is unprobed. A hand-back message can wake the lead while the harness queues the completion notification a moment later, during the lead’s reply. A reply that made no tool call and ended its turn to await that notification has twice slept through it. The harness wrote the queued notification into the transcript after the stop hook and started no turn, so the lead idled until the operator woke it. The template’s fallback for a notification that never arrives presumes a lead awake to apply it, so the template has the lead make one tool call before a turn that ends to await a notification. No probe has tested that a tool call drains the queue; one lead turn that made tool calls received a notification inside it, consistent with the remedy without testing it.
One misreading of that precondition is named in the template, and it is kit-tier. An approval prompt gates a command starting, so an operator’s note about having just answered one timestamps a beginning, never a completion. Any lead on any prompting harness can make the misreading, and it reads as good news exactly when a lead wants good news. Generality and salience together are the test any candidate trap passes before the template carries it.
The negative is assertable from the artifact side. A producer-liveness gate wired into LIFECYCLE_KIT_ENTRY_PREFLIGHT (evidence-kit/SPEC.md §check-producer-liveness) answers is the producer still running? independently of the signal, which covers the case where the signal is wrong. The template’s no-sibling-dispatch clause is prose for the same reason.
Honest limit on the lead’s post-dispatch check routing. The lead verifies the evidence-producing stage by reading the committed evidence rather than re-running the producer (§templates/lead.md, applying delegation-kit/SPEC.md §Verify after every agent commit). That rule is also prose-only and human-enforced, since a choice of command leaves no tracked artifact. The nearest proxy, check-producer-liveness on the entry-preflight hook, answers is a producer running now. So it covers the concurrent case, a producer still live when the next entry is stamped, and not a sequential re-run after the producer cleanly exited, which holds no lock and reds nothing.
Honest limit on the lead’s open authorization. Opening an iteration is the operator’s decision, obtained explicitly through the channel the consumer binds. The grant carries a cardinality and is spent by the open it pays for, so the lead stops and reports at the boundary rather than opening the next one (§templates/lead.md). This is prose-only and human-enforced because the fact is not encodable. An authorization is a fact about a conversation, and every encoding of it — a flag on --enter-stage, a committed line naming an authority, a ledger arming — is written by the session it binds. The channel and the cardinality are unencodable for the same reason: a number recorded anywhere is recorded by the reader it constrains.
Two mechanisms were weighed. The one-shot entry valve (§bin/enter-stage.sh) is ruled out by its own contract: it reaches no iteration-boundary refusal, and its ledger matches the header’s iteration name while the boundary entry stamps the unnamed placeholder. LIFECYCLE_KIT_BOUNDARY_REQUIRE is the shape that would fit, firing only on the first stage, reading a committed file and failing closed when it is absent. It carries two costs. It is guarded on the closing iteration having a name, so a consumer’s first iteration skips it, and its refusal text serves the disposition semantics, so a second semantic needs a second knob or a generalized message. Naming the shape is not proposing it.
Deviation transitions
The stages walk scope → align → build → validate → close in order by default; the gate-legal shapes for leaving that walk are specified here, not improvised. Each composes mechanism the kit already owns — --enter-stage’s boundary reset, canon-kit’s amendment pairing, queue-kit’s tag algebra — so no new tooling, state, stamp grammar, or tag is introduced, and a harness-less consumer keeps every shape. check-stage-entry and the stamp protocol bar an ad-hoc abandon.
The demote ritual is the shared step the other shapes compose. To take a promoted entry out of a live iteration, move it back to the deferred queue section restoring its board tags, and delete its amendment file in the same commit. Git history preserves the design, so a later scope re-promotes by resurrecting the file. canon-kit’s check-amendment-queue reds a deferred entry that still carries a spec ref, or an orphaned amendment left on disk. A validate-baseline scenario keyed to the demoted entry is re-scoped or removed in the same commit — a coverage-honesty obligation, since check-evidence-baseline’s slug-liveness passes on a demoted entry. For an [observed-by:] entry the ritual is the fallback: §The state machine’s push placement makes the observation arrive in-iteration, so the grammar’s lack of a landed-but-unobservable mark is not paid on the normal path.
Abandon ends an iteration without a close. Disposition every active entry explicitly: demote it, or carry it active with its amendment for the next iteration to adopt. Sink or delete every Lessons entry under the existing disposition rules, which the first-stage entry already forces. The next --enter-stage scope is the abandon: scope has no mandatory predecessor, so the entry is gate-legal from any stage, and the boundary reset drops the dead iteration’s stamps as it drops a closed one’s. The abandon commit’s subject names the abandoned iteration.
Split mid-flight narrows a live iteration. The iteration name never changes once set: a rename-in-place would orphan every stamp already written, since check-stage-evidence’s staleness assertion reds every stamp whose iteration is not the header’s. So splitting is demotion. Demote the split-out subset via the ritual and drive the rest through the remaining stages; the subset re-promotes at a later scope under its own iteration.
Reopen after close is barred as an in-place edit. Stamps are append-only within an iteration and scope is the only reset, so no gate-legal shape continues a closed iteration’s evidence file, and no history rewrite is sanctioned to fake one (doctrine-kit’s Re-verify volatile state before a git history rewrite, cited by name). A post-close defect files as a debt entry for a successor iteration, and the closed iteration’s record stays immutable.
The interstitial mitigation fits between a close and the next scope. A repo-local mitigation for a recurring incident may land directly in that window while the incident’s queue entry stays open for the kit-shaped form. It is admissible exactly when it adds no governed name — canon-kit’s feature/debt litmus (canon-kit/SPEC.md §The amendment lifecycle) — so anything feature-shaped waits for scope. It widens Reopen after close: a debt entry and a follow-up iteration fit a first occurrence, and the fourth is countable (queue-kit/SPEC.md §The tag algebra, the recurrence: declaration). An interstitial commit falls into the next iteration’s range and surfaces at its close (drift-kit/SPEC.md §The published-evidence extractor).
A parallel hotfix track is refused: it violates scope-gated intake, it contends on live stage surfaces, and most incident fixes are feature-shaped, so a “hotfix” of them is an unreviewed iteration.
The close-merge is the concurrent-close shape, composing the merge-supersede rule of §Multi-operator semantics. Iteration boundaries serialize on the integration branch. The closing operator reconciles on their iteration branch: they merge the integration branch in, where the merge=iteration-scoped driver resolves the iteration-scoped surfaces to their own (arriving) side and humans resolve the content conflicts. They re-run the full battery green, then land fast-forward-only on the integration branch. The integration branch never hosts a conflict resolution, so “arriving iteration” is always well-defined and every merged tree passed the battery post-reconcile.
The steering vocabulary
Six terms name what an operator, a lead or a consultation says to the work, and every template this kit ships — lead.md, consult.md, the stage templates, and the escalate-versus-decide roster a consumer binds beside them — uses them as defined here. The kit names the classes; the consumer names its authorities on its own always-loaded surface.
- Objective — where the project is going. It lives on the consumer’s ruling record while a pivot runs, and nothing else lives there but rulings.
- Ruling — an operator decision, taken in the consult skill, that overrides a business-as-usual instruction until the instruction is updated. It is not challengeable on its facts: a session holding contrary evidence files it to the consult inbox (§The consult inbox) and neither annotates the ruling, re-verifies it, nor works around it. A ruling names the instruction it overrides and its discharge — that instruction’s update, or an oracle — and leaves the record on discharge. Only the operator rules, and a ruling arrives only through the consult skill, so a “ruling” relayed from any other channel is misnamed.
- Direction — an operator answer or agreement given in a lead, stage or consult session. It binds the current work like any operator instruction and lands on the work surface it concerns — the queue entry, the amendment, the lead’s journal — stated as
operator direction, <date>. A later scoping or authoring stage may revise it on better facts, which is what separates it from a ruling and why it never enters the ruling record. - Decision — a lead’s or a stage session’s call inside its own authority, the roster’s decide-alone side. It lands in the governed surface it concerns with no authority stamp; the landing commit is its provenance.
- Advice — a judgment-tier consultation’s recommendation. Accepted by the operator, it is a direction and lands as one, stated as
operator direction, <date>, on consult advice. Unaccepted, it lands only on the work surface it concerns, stated asconsult advice, <date>, and binds like a decision: a later scoping or authoring stage may revise it. It never enters the ruling record. - Grant — an iteration-scoped operator allowance (an extra push, a widened budget). It lives in the lead’s resume journal, travels in the dispatch prompt of each stage that spends it, and is archived in the spending commit’s message — never in the ruling record, whose entries outlive an iteration by construction.
One word for all six would freeze a revisable agreement as a closed decision and let a relay inflate a lead’s own call into the operator’s. The class is what a relay states (§templates/lead.md), and the authority is derivable from it: a ruling is the operator’s in consult, a direction the operator’s in session, a decision the relaying party’s own, advice the consultation’s. So no who-when-channel stamp is written beside content. A ruling is not precedent but an explicit, temporary override of the owner doc, discharged by the owner doc’s update, so spec-over-precedent is untouched.
An operator answer relayed through a lead into a dispatched consultation is a direction. A ruling is the class nothing challenges, so only a consultation the operator runs takes one. An item needing an entry on the ruling record stays in the consult inbox for that consultation.
Layout and configuration
The kit is vendored beside gate-sdk (conventionally at lifecycle-kit/); its gates are registered in the consumer’s gates.list by name and resolve through gate-sdk’s multi-kit path (consumer gates dir first, then each kit’s checks/). Its markdown templates are laid out on the stage/boundary axis this SPEC classifies them by: templates/*.md is the boundary skills (lead.md, release-sweep.md, upgrade.md, consult.md) and host-protocol.md. That one file is no skill and carries no slot: it holds the steps a stage template points at (§The host protocol). templates/stages/*.md is exactly the stage-class templates. templates/frames/*.md is a third class, the frames: a frame is no skill any session invokes and carries no slot. It is a prompt for a foreign reader: stage-contract.md, which its arm prints (§The stage-contract arm), and project-critique.md, which a consultation copies into its prompt file (§templates/consult.md). A stage skill adopts its template in one of two modes — copied into the consumer’s agent-skill directory with each named slot overwritten, or a thin binding shim that references it (§templates/stages/).
The stage machine itself is config with this repo’s lifecycle as the default. Config is a knob file: copy templates/lifecycle-config.knobs into the gates dir as lifecycle-config.knobs (or point LIFECYCLE_KIT_KNOB_FILE elsewhere) and set only what you override. This roster owns every knob and its default, and the template carries no second copy. The knobs are static: the binary resolves them in process from its own defaults table and the consumer’s knob file, and bash gate-sdk/bin/run-gates.sh --emit knob-roster prints each with its shape and rendered default. The grammar, the .local overlay, the environment-over-file precedence for a scalar, and the refusals — a set LIFECYCLE_KIT_KNOB_FILE that does not exist, a left-behind lifecycle-config.sh, a non-empty file named by the retired LIFECYCLE_KIT_CONFIG_FILE — are gate-sdk/SPEC.md §The knob file’s.
The kit’s table validator checks the machine: unknown stages in the map, a waiver token colliding with a stage name, a non-integer n-gram width, a malformed preflight entry, a malformed executor binding, a critique adapter outside the adapter-name shape. It exits 2 on a malformed config, since a broken machine must not gate anything (§The stage-machine adapters). A derived default below names the knob it reads as ${NAME}, or as ${NAME:-<default>} so the roster’s rendered literal reads as agreement.
No knob carries the ruling-authority vocabulary. §The state machine obliges a stage session to record the class of a direction it lands, and the authorities a project recognises are its own governance roles, named on the consumer’s always-loaded surface. Nothing in this kit branches on them, so a knob holding them would have check-knob-citation as its only reader. The grammar is queue-kit’s (§The tag algebra owns the slot); this kit owns only the timing.
Knob-rename compat. A rename carries two obligations, each with its own threshold:
- Declaration — owed from the first release tag, unconditionally. A tagged release is a distribution, so from the first tag a rename owes a Knob changes declaration on the release declaration surface, and so in the note.
- Compat shim and deprecation window — owed from the project’s declared general-availability posture onward, never from a tag. While a project declares itself pre-general-availability, a knob rename carries no read-the-old-name shim, no deprecation window and no queue-bound deprecation marker. The declaration is still owed, so an early consumer is told what moved.
The observable is a declared posture rather than an adoption count, because the obligation is normative: a migration path is owed because stability was promised, not because users were counted. “The first observed external install” is rejected, since it cannot be falsified from the tree and turns on a fact outside the project’s control. A tag measures distribution where the obligation tracks promise, so it is no proxy either. Fail-safe direction: a project declaring no stability posture is treated as past the threshold, so silence grants no permanent exemption. Only an explicit pre-GA declaration opens the window.
The clause is knob-scoped, on three grounds:
- The mechanism it points at is knob-shaped. The queue-bound deprecation markers and the release note’s
Knob changessection, with itsold → new/old → ∅grammar, are specific to config names. - No class is left without a home. A gate, file or directory rename fits the release note’s
Behavior changessection, whose bullet lead is the changed surface’s name (script, knob, template, or file). No note grammar sentence yet routes non-knob renames there, so this is a structural inference rather than an established convention. - Widening it would restate a neighbour. A general “any governed name” rule is doctrine-tier, and a kit SPEC settling it would duplicate whatever lands there.
No gate reads the clause. “This commit renames a knob” is not decidable from a diff without a knob-identity model, and an approximation would fire on additions and removals alike. The clause’s reader is the build-stage session performing the rename, deciding whether it owes a shim and a deprecation marker. A consumer points the general-availability criterion at whatever stability declaration it maintains; the kit states the criterion and never the instance, and no knob reads it.
LIFECYCLE_KIT_STAGES— the stage roster, in order; default(scope align build validate close).LIFECYCLE_KIT_PREDECESSOR— associative map stage → the predecessor whose stampcheck-stage-entryrequires; default([align]=scope [build]=scope [validate]=build [close]=validate)(buildkeys toscopebecause the audit stage is trigger-gated; §check-stage-entry).LIFECYCLE_KIT_FIRST_STAGE— the stage whose entry is the iteration boundary (§bin/enter-stage.sh truncation); defaultscope.LIFECYCLE_KIT_DRAIN_STAGE— the stage whose entry requires the active queue sections empty; defaultvalidate; empty disables the drain assertion. Must not be terminal: at least oneLIFECYCLE_KIT_PREDECESSORentry names it, or config load fails (the drain-exempt backstop; §check-stage-entry).LIFECYCLE_KIT_ACTIVE_SECTIONS— the queue sections the drain assertion reads; default("New Features" "Technical Debt"). Independent of canon-kit’sCANON_KIT_ACTIVE_SECTIONS(read bycheck-amendment-queue’s misfiled-spec-ready clause) though their defaults coincide. A consumer retargeting one alone splits the two views with no gate to notice — an accepted coupling, since kit independence outranks unifying the knob.LIFECYCLE_KIT_AUDIT_STAGE— the trigger-gated audit stage assertion C looks for; defaultalign; empty disables the audit machinery entirely.LIFECYCLE_KIT_AUDIT_ENTRY_STAGE— the stage whose entry assertion C blocks on a cross-component signal with no audit stamp; defaultbuildwhen an audit stage is set, else empty.LIFECYCLE_KIT_WAIVER_TOKEN— the stamp token recording the user’s explicit audit waiver; defaultalign-waived— the audit stage followed by-waived, and empty when no audit stage is set; must not collide with a stage name. Its one writer is the entry consuming a--dispatch <stage> --waivedeclaration (§bin/enter-stage.sh), and an empty token refuses that form.LIFECYCLE_KIT_AMENDMENT_GLOB/LIFECYCLE_KIT_ROSTER_BASENAME— what makes a file an amendment, and the canonical-spec basename, for assertion C’s scan (template dirs pruned); defaultsSPEC-*.md/SPEC.md. A glob with no/matches a file’s basename. One carrying/matches its repo-relative path component by component,*inside one component and**across any number, for a layout where a file’s place, not its name, marks it as in flight.LIFECYCLE_KIT_CONTRACT_TOKENS— the amendment-body substrings assertion C reads as a cross-component contract signal; default("SPEC.md" "proto/").LIFECYCLE_KIT_MIRROR_ROOT— the directory a consumer’s generated mirrors of roster files sit under, for assertion C’s fold (§check-stage-entry); default empty, which folds nothing. A trailing/is ignored. The fold reads the token, never the tree, so a mistyped value matches no token, keeps the over-demand and hides no component. Independent of canon-kit’sCANON_KIT_MIRROR_ROOT, where that kit’s generator writes, on the groundLIFECYCLE_KIT_ACTIVE_SECTIONSstates.LIFECYCLE_KIT_SKILLS_DIR— the agent-skill directorycheck-stage-skill-coveragescans; default.claude/commands.LIFECYCLE_KIT_STAGE_CONTRACT_FRAME— the frame--emit stage-contractprints ahead of a stage’s skill (§The stage-contract arm), a repo-root-relative path; defaultlifecycle-kit/templates/frames/stage-contract.md. A consumer whose foreign vendor needs different wording points it at its own file.LIFECYCLE_KIT_STAGE_EXECUTOR— the per-stage executor binding: indexed, one<stage>=<adapter>element per stage whose reading runs foreign (§The host protocol). Default empty, the all-master-harness posture: a stage no element names runs on the master harness, so the shipped calibration is off and each consumer binds its own.- The adapter is a name from
DELEGATION_KIT_FOREIGN_ADAPTERS. Its model and effort are delegation-kit’s to resolve, from the class the reading names (delegation-kit/SPEC.md §The foreign-vendor run). So this knob holds no model and no vendor. - This kit owns it, not delegation-kit’s tier binding. A stage is this kit’s vocabulary, and a consumer vendoring delegation-kit alone has no stage to bind. This knob picks the adapter and the binding gives that adapter’s class its model, so the two meet at one reader, the executor a host runs.
- The table validator refuses, at exit 2 with every finding: an element with no
=, a stage outsideLIFECYCLE_KIT_STAGES, a stage bound twice, an empty adapter, and an adapter name outside[a-z0-9-], the shape delegation-kit’s validator holds an adapter name to. It does not readDELEGATION_KIT_FOREIGN_ADAPTERS: a kit’s validator reads its own table, and an adapter no knob configures is already--foreign-run’sFAILEDat exit 2 before anything is cloned. Being indexed, the knob has no environment spelling. - A binding usually lives where its adapters do. The kit ships no adapter, so a consumer’s adapters sit in delegation-kit’s gitignored overlay, and a tracked binding would name an adapter a fresh clone does not configure. The gitignored
lifecycle-config.local.knobsoverlay (gate-sdk/SPEC.md §The knob file) is its usual home. - Its readers are the validator,
check-stage-skill-coverage’s fourth direction (§check-stage-skill-coverage) and the lead’s dispatch-time read (§templates/lead.md).
- The adapter is a name from
LIFECYCLE_KIT_CRITIQUE_ADAPTER— the adapter a consultation’s outside critique runs on (§templates/consult.md): a scalar holding one name fromDELEGATION_KIT_FOREIGN_ADAPTERS. Default empty, which is off: the consultation offers no critique, and an operator may still name an adapter in the session. It holds a name and no model, onLIFECYCLE_KIT_STAGE_EXECUTOR’s ground, and the table validator refuses a value outside[a-z0-9-]at exit 2 without reading delegation-kit’s table, on that knob’s ground too. Its readers are the validator and the consultation, through--emit knob-values. Like that binding it usually lives in the gitignored overlay beside the adapters it names.LIFECYCLE_KIT_CONSULT_CLASS— the tier class a consultation runs on (§templates/consult.md): a scalar, defaultjudgment. Its readers are the consult template’s first step, which passes it to--model-verdict --expect, and the lead’s consultation dispatch (§templates/lead.md), each through--emit knob-values, and the validator. The table validator refuses an empty value and one outside[a-z0-9-], and does not read delegation-kit’s class roster, onLIFECYCLE_KIT_STAGE_EXECUTOR’s ground for an adapter name: a class the roster lacks is already--model-verdict’s refusal at exit 2, which the template reads as unverified and states. Honest limit: a consumer’s dispatched-consultation definition declares its owntier:, and nothing holds it equal to this knob, so a consumer that moves one moves the other.LIFECYCLE_KIT_AGENT_FILE— the always-loaded agent file the--install-lifecyclearm writes the registration block into andcheck-lifecycle-registrationreads it back from; defaultCLAUDE.md(theDOCTRINE_KIT_AGENT_FILEsibling).LIFECYCLE_KIT_QUEUE_FILE/LIFECYCLE_KIT_STATE_FILE— the governed header and stamp files; defaults${GATE_SDK_QUEUE_FILE:-TASK-QUEUE.md}/${GATE_SDK_WORKFLOW_DIR:-.workflow}/WORKFLOW-STATE.txt.LIFECYCLE_KIT_SESSION_ID— the harness-neutral stamp-id override, source 1 of the derivation order (§bin/session-id.sh); default unset. Read from the process environment and never a row of the kit’s table, so no knob file can set it (§bin/session-id.sh).LIFECYCLE_KIT_SESSION_BOUNDARY—stageoriteration; defaultstage. The consumer’s session-boundary posture: atstage, distinct stages of one iteration may not share a session id (§check-stage-evidence); atiteration, that cross-stage distinctness check alone is skipped, and every other assertion holds. It lives on the session-span axis only and takes no role values: manual-versus-lead is the driver axis and rides context-kit’s session-role signal. The table validator refuses any other value at exit 2.--enter-stagedoes not read it, since stamping is posture-independent;templates/lead.mdconsumes it as the inline-run posture prose.LIFECYCLE_KIT_LESSON_EVIDENCE_FILE— the kit-owned lesson-disposition stamp file; default${GATE_SDK_WORKFLOW_DIR:-.workflow}/lesson-evidence.txt, read bycheck-lesson-dispositionand the boundary-reset built-in.LIFECYCLE_KIT_GAP_INBOX_FILE— the committed append-only gap inbox (§The committed gap inbox); default${GATE_SDK_WORKFLOW_DIR:-.workflow}/gap-inbox.md, written by the--emit-file-gaparm, itsmerge=unionattribute verified bycheck-merge-attrs, drained by the close skill and read for emptiness by--enter-stage’s boundary refusal.LIFECYCLE_KIT_CONSULT_INBOX_FILE— the committed consult inbox (§The consult inbox); default${GATE_SDK_WORKFLOW_DIR:-.workflow}/consult-items.md, written by the--emit-file-consultarm, itsmerge=unionattribute verified bycheck-merge-attrs, drained by the consult skill. A kit default rather than empty on theLIFECYCLE_KIT_GAP_INBOX_FILEposture: the kit’s own arm mints the artifact.LIFECYCLE_KIT_SURVEY_RECORD_FILE— the committed per-iteration survey record (§The survey record); default${GATE_SDK_WORKFLOW_DIR:-.workflow}/survey-record.md. It is written by the--emit file-surveyarm and asserted bycheck-survey-record, and itsmerge=iteration-scopedattribute is verified bycheck-merge-attrs.--enter-stageprints its headings and truncates its body as a kit-owned boundary built-in, so it does not rideLIFECYCLE_KIT_BOUNDARY_TRUNCATE. The grammar, the witness commands and the absence of an opt-out are contract, so no second knob carries them.LIFECYCLE_KIT_DISPOSED_FILE— the committed record of discarded gap findings (§The disposed-findings record); default${GATE_SDK_WORKFLOW_DIR:-.workflow}/disposed-findings.txt, written by hand at a drain’s →discard, read by the--emit-file-gaparm’s advisory and asserted bycheck-disposed-findings.LIFECYCLE_KIT_RECURRENCE_THRESHOLD— positive integer, default2; the recorded re-filing count at which a deferred entry enters the scope stage’s proposed unit set regardless of the standing directive’s theme (the pre-emption rule, §templates/stages/). Two recorded re-filings is a third incidence of the same finding. A stated policy rather than a derived number, so a knob, onQUEUE_KIT_ENTRY_CAP’s posture. The count is the element count of the entry’s[recurrence:]array (queue-kit/SPEC.md §The tag algebra). That rule is its only reader: queue-kit owns the tag and reads no threshold, and drift-kit reports the count and applies no verdict.LIFECYCLE_KIT_CLOSE_SURFACE_GLOBS— the consumer’sclose-surface:declaration surfaces beyond the resolved kit roots (§The close-surface roster); default*/SPEC.md. It does not default toCANON_KIT_MANIFEST_FILES, since reading another kit’s knob would make lifecycle-kit depend on canon-kit’s configuration for a value the consumer already owns. The roster is derived, never a kit literal, because a kit shipping the names of a consumer’s inbound surfaces would publish that consumer’s private workflow.LIFECYCLE_KIT_RULING_RECORD— the consumer’s ruling-record path (§The ruling-staleness probe); default empty, so the whole ruling-staleness machinery is inert for a consumer that keeps no such record. A kit default filename would assert that every adopter keeps this artifact, under this name.LIFECYCLE_KIT_RULING_CITERS— array of path globs the citing-side report sweeps for a declared ruling name (§The ruling-staleness probe); default empty, since which surfaces argue about rulings is a fact about the consumer’s tree.LIFECYCLE_KIT_RULING_ORACLE_TIMEOUT— positive integer seconds bounding a declared discharge oracle’s dispatch (§The ruling-staleness probe); default10. A hung command cannot wedge the arm that dispatches it: an expired bound kills it with every process it started (installer/SPEC.md §The update notice), and a timeout reports as a dispatch failure rather than as a verdict. No knob carries a condition or authority vocabulary, on the ruling-authority ground above.LIFECYCLE_KIT_AUDIT_ROSTER_FILE— the consumer’s audit roster (§The audit roster); default empty, onLIFECYCLE_KIT_RULING_RECORD’s ground. Empty makes close’s review step andcheck-audit-rosterinert.LIFECYCLE_KIT_AUDIT_ROSTER_LINE_CAP— positive integer bytes bounding every roster line (§check-audit-roster), oroff; default1500. A stated policy onQUEUE_KIT_ENTRY_CAP’s posture, sized so ascopeline holds a class’s standing readings as a paragraph while a dozen-class roster at the cap still reads whole in one pass.offdrops assertion B and leaves A, C and D. The table validator refuses any other value at exit 2.LIFECYCLE_KIT_PERMANENT_SURFACE_GLOBS— array of path globs for the surfaces held to the no-retrieval-pointer rule (§check-scratch-citation); default the queue file alone (${GATE_SDK_QUEUE_FILE:-TASK-QUEUE.md}, throughLIFECYCLE_KIT_QUEUE_FILE). The queue is the one permanent surface this kit owns, so the default is non-vacuous in every consumer and over-reaches in none. The forbidden targets derive from the consumer’s own truncate configuration.LIFECYCLE_KIT_BOUNDARY_TRUNCATE— extra files reset to their header at the iteration boundary; default empty. Adding a member also widenscheck-scratch-citation’s forbidden-target set, because both readstages::supersede_set.LIFECYCLE_KIT_BOUNDARY_PRESERVE— keep-list of scratch-root names, each an immediate child of the scratch dir, that the iteration-boundary wipe spares whole (§bin/enter-stage.sh); an entry containing/spares nothing and the boundary report names it. Default empty. Boundary-only, and paired with the scratch dir it reads (GATE_SDK_TMP_DIR) rather than a directory knob of its own. The tier split matters: the.gitkeepexemption and the lead journal below are kit invariants this knob cannot unset, and this knob layers the consumer keep-list on top of them.LIFECYCLE_KIT_LEAD_JOURNAL_FILE— the lead’s own resume journal; defaultlead-journal.md, a scratch-root name resolved inside the scratch dir thatGATE_SDK_TMP_DIRnames. The boundary wipe spares it as a kit invariant beside.gitkeep, and the boundary entry reads it for the disposition mark (§bin/enter-stage.sh). A scalar, since an assignment overrides one value where a defaulted array is replaced wholesale. The kit may name this basename becausetemplates/lead.mdmints the artifact and is its only writer, theLIFECYCLE_KIT_GAP_INBOX_FILEposture.LIFECYCLE_KIT_RULING_RECORDdefaults empty because a ruling record is a consumer’s artifact.LIFECYCLE_KIT_DISPATCH_MARKER_FILE— the dispatch marker (§bin/enter-stage.sh); defaultstage-dispatch.txt, a scratch-root name resolved inside the scratch dirGATE_SDK_TMP_DIRnames. Written by--dispatchand by every stamp, read bycheck-dispatch-entry, and read by the entry for a waiver-bearing line, the dispatcher’s declared audit waiver. A scalar on the lead journal’s ground, and not spared by the boundary wipe, since the boundary stamp that would spare it removes its line.LIFECYCLE_KIT_STAGE_SESSION_TYPES— the agent types a lead dispatches stage sessions as, read by the workflow-state guard’s stamp-before-write and superseded-stage rules (§check-dispatch-entry). The default is empty, which makes both rules inert. A type is the harness’s name for an agent definition, and the kit ships none.LIFECYCLE_KIT_BOUNDARY_REQUIRE— array of repo-relative files each of which must carry a data line naming the closing iteration before the iteration boundary may be crossed (§bin/enter-stage.sh); a missing member is a fail-closed refusal; default empty.LIFECYCLE_KIT_BOUNDARY_WORKTREE_CHECK—0or1; default1. At1the iteration boundary refuses while any linked worktree stands (§bin/enter-stage.sh). A consumer that never dispatches an isolated agent has an emptygit worktree list, so the check is vacuous there; a consumer with a standing long-lived worktree turns it off here rather than teaching the kit its paths.LIFECYCLE_KIT_WORKTREE_LOCK_PID_RE— a POSIX ERE with exactly one capture group standing in the top-level concatenation (the admitted shape: gate-sdk/SPEC.md §The POSIX ERE matcher), matched against a linked worktree’s git lock reason, the group being the holder’s pid (§bin/enter-stage.sh); default empty. A lock reason is one harness’s vocabulary, which a kit literal would publish, and empty means no classification is configured, so the refusal stays unclassified. Setting it buys a dependency on evidence-kit’s liveness predicate; a pattern outside that shape is a fail-closed config refusal (§The stage-machine adapters).LIFECYCLE_KIT_ENTRY_PREFLIGHT— per-stage<stage>=<command>entries run alongside the built-in pre-flight (§bin/enter-stage.sh); default empty.LIFECYCLE_KIT_PREFLIGHT_VALVE_FILE— the committed one-shot valve ledger whosearmedline admits a single entry past a refusingLIFECYCLE_KIT_ENTRY_PREFLIGHTcommand (§bin/enter-stage.sh); default empty, meaning no valve and a final refusal. A ledger path is one consumer’s workflow-directory layout, the seamLIFECYCLE_KIT_WORKTREE_LOCK_PID_REtakes. Which stages may be valved is whatever an arming line names, bounded by the configured stage roster. A consumer setting it owes the ledger membership inLIFECYCLE_KIT_BOUNDARY_TRUNCATE, which bounds the valve count at the iteration, and aclose-surface:declaration (§The close-surface roster), without which a tracked ledger reaches no derived roster.LIFECYCLE_KIT_STAGE_JOURNAL_PATTERN— the resume journal’s path as a function of the stage, carrying a<stage>placeholder (§The state machine); default${GATE_SDK_TMP_DIR:-.tmp}/<stage>-journal.md. A pattern carrying no placeholder is a fail-closed config refusal (§The stage-machine adapters).LIFECYCLE_KIT_STAGE_JOURNAL_REQUIRE—0or1; default0. At1the entry tool opens the entering stage’s journal at that derived path, and a stage entry refuses when the cursor’s stage left no written journal there (§bin/enter-stage.sh). One knob gates both, so at the default the opener is inert along with the assertion. Defaulted off because the assertion reads the predecessor, so switching it on mid-iteration asserts against sessions dispatched before the rule existed; a consumer throws it at an iteration boundary. The opener andcheck-stage-skill-coverage’s third direction add no further knob (§check-stage-skill-coverage).LIFECYCLE_KIT_SHIM_NGRAM— the shared-n-gram widthcheck-shim-restatementtrips at (positive integer; §check-shim-restatement); default9. It takes nooff: the width is that gate’s one red condition, sooffwould be unregistering the gate.LIFECYCLE_KIT_SHIM_DEDUP_CORPUS— that gate’s corpus file list; default empty for the computedCLAUDE.md-plus-kit-templates default.
.gitattributes (repo root) is a consumer surface the kit writes but adds no knob for. The merge=iteration-scoped supersede set derives from the existing boundary-truncate knobs (LIFECYCLE_KIT_STATE_FILE, LIFECYCLE_KIT_LESSON_EVIDENCE_FILE, and each LIFECYCLE_KIT_BOUNDARY_TRUNCATE member), so a reshaped truncate set flows into the attribute block by construction — §Multi-operator semantics, §bin/install-lifecycle.sh.
Multi-operator semantics
The contributor altitude of the coordination map: a second operator running their own concurrent iteration on the same repo. The two narrower altitudes are ruled elsewhere — sub-agents within one session (delegation-kit’s serialize-or-worktree rules) and sessions within one iteration (§templates/lead.md: one live iteration, stages serialized through the stamp protocol). Fork contributors are out of scope: an outside PR never stamps state and only passes the battery in CI.
The topology ruling — state surfaces stay single-writer; concurrency is git topology. The header line, the evidence file, and every boundary-truncated surface are iteration-scoped: an iteration owns exactly one branch (its home branch), and every stamp lands there. One live iteration per branch: the second concurrent operator cuts a branch at their scope entry, and the integration branch is the degenerate single-operator home. Branch naming is prose guidance (name the branch after the iteration), not mechanism. Two shapes are ruled out, since git already composes better. Per-operator state files or stamp-attribution fields are multi-writer surfaces and a new grammar, where the git author on every stamp commit already attributes. A lock or lease on the integration branch is state the kit refuses to own.
The merge-supersede rule. At any branch merge, the iteration-scoped surfaces resolve wholesale to the arriving (checked-out) iteration’s version; the other side’s content is per-iteration scratch the boundary doctrine already declares dead. The supersede set is derived, never maintained: it is exactly what --enter-stage truncates at the iteration boundary — LIFECYCLE_KIT_STATE_FILE, LIFECYCLE_KIT_LESSON_EVIDENCE_FILE, LIFECYCLE_KIT_SURVEY_RECORD_FILE, and the LIFECYCLE_KIT_BOUNDARY_TRUNCATE members — rendered by stages::supersede_set (§The stage-machine adapters). The survey record is in the set because a carried survey from the other side describes a tree state the arriving iteration never had (§The survey record).
The queue file is not in the set. Its body (backlog sections, lessons) is shared content that merges like any prose, and only its header line is iteration-scoped. That line is resolved by hand to the arriving iteration, and a wrong resolution reds at the next commit: check-stage-evidence requires the header’s name to agree with every stamp’s, and the state file already took the arriving side by driver. Stage motion writes no queue, so the file changes only on real work-state transitions (promotion and naming, a Done move, the closing dispositions). Held-constant baselines and append-across-iterations evidence keep normal merge semantics, since their conflicts are real disagreements.
The union surfaces. The kit owns two union-driver surfaces, the committed gap inbox and the consult inbox (§The consult inbox), whose append-only bullets must survive a concurrent merge rather than supersede. They are rendered into the attribute block beside the supersede set and verified by the same gate. A consumer with its own tracked shared append log points it at git’s built-in union driver the same way. Gitignored per-checkout scratch never merges and needs no rule.
.gitattributes — the rule mechanized. Each supersede-set path carries merge=iteration-scoped. The driver definition (git config merge.iteration-scoped.driver true — keep ours) is per-clone config installed by the --install-lifecycle arm beside its registration block (the --install-hooks opt-in class). Honest limit: on a clone without the driver the attribute is inert and the file conflicts normally, so the rule above governs the hand resolution and the uninstalled path degrades to judgment, never to silence. Writer/asserter split: the arm emits the marker-bounded attribute block and check-merge-attrs verifies it, the --emit graph ↔ check-graph precedent.
Who may stamp, at this altitude. The arriving stage session stamps, and only on its own iteration’s home branch. A session never stamps a branch whose iteration it is not driving; cross-iteration discoveries (a lesson, a deferred filing) land on the discoverer’s own branch and reconcile at merge.
Causal-completeness core: no new state surface. No stamp grammar, queue tag or evidence file is added, and every existing producer/consumer pair (--enter-stage, the stage gates, the drift report) works per-branch unmodified. The merge=iteration-scoped lines are read by git’s merge machinery at a merge and by check-merge-attrs at pre-commit. The driver config is read by git’s merge machinery when an attributed path needs a three-way merge; it is per-clone state no pre-commit gate reads. The close-merge (§Deviation transitions) is produced by the closing operator’s close session and consumed by the integration branch’s battery, whose reconcile commit re-fires every queue- and state-coupled gate and so enforces the header hand-resolution.
The committed gap inbox
Mid-iteration work-state writes race the stage session holding the shared git index: a gap surfaced mid-stage has no committed place to land except the queue file that session is already contending on. So mid-iteration gap filing gets a committed, append-only channel of its own. It is distinct from the knowledge-friction log, which stays the narrow sensor for a fact re-derived because no doc owns it (drift-kit/SPEC.md §The knowledge-friction loop). A work-shaped finding (a gap, a task, a defect) is backlog and routes here, and so does an operator question nobody could ask live (§The consult inbox), which this channel carries to the next intake. The route costs one always-loaded line in the consumer’s instructions file, because the mid-iteration session that finds a gap loads no trigger for this section. It earns that cost by the inbox filling with gaps that would otherwise have been a racing queue edit, or nothing at all.
The surface. .workflow/gap-inbox.md (knob LIFECYCLE_KIT_GAP_INBOX_FILE, §Layout and configuration) is a committed, append-only capture buffer. Grammar: a # contract: prose header, then one - <YYYY-MM-DD> — <gap prose> bullet per gap — one shape for every filing, with no structured slot between the date and the prose. A slug named inside the prose takes queue-kit’s in-body citation form (queue-kit/SPEC.md §The tag algebra), single-backticked; it is a citation and never a field, the distinction §check-gap-inbox-neutrality holds. The inbox is committed, not gitignored, since a per-clone buffer fragments the backlog across operators. Append-only is a merge property owned by §Multi-operator semantics: this is one of the kit’s two union-driver surfaces.
The affordance. run-gates.sh --emit file-gap [--] "<gap prose>" appends one dated bullet, on the --emit-kfric pattern: repo-root anchor, config-via-env, exit 2 on an empty argument, and exit 2 in a linked worktree. It seeds the contract header, byte-identical to the line close’s drain truncates back to, when the inbox does not yet exist. It is the --emit-file-gap arm (gate-sdk/SPEC.md §The non-gate arm), whose declared roster is six rows of the kit’s static table: LIFECYCLE_KIT_GAP_INBOX_FILE, LIFECYCLE_KIT_QUEUE_FILE, LIFECYCLE_KIT_STATE_FILE, LIFECYCLE_KIT_STAGES, LIFECYCLE_KIT_FIRST_STAGE and LIFECYCLE_KIT_DISPOSED_FILE. The arm family is forced: the tool reads knobs, and a hardcoded top-level flag has no table row to declare them on.
Its one positional is free text, so it validates that argument’s shape to gate-sdk/SPEC.md §The bin/-tool contract: an unrecognized leading - is refused at exit 2, and -- ends option processing. The hazard belongs to the argument, a flag captured into a committed surface, so the rule holds on any substrate. Usage prints at the shape refusal, so --emit file-gap --help is a refusal printing the usage block rather than a capture. Stdout is the filed bullet and nothing else; the four advisories below ride stderr, so a pasted line never carries a question or a warning. It is advisory tooling, not a gate, so no fixture pair is owed, and the raw append stays a legal fallback, the grammar being the surface’s contract, not the writer’s.
The filing session commits its own bullet, at the first moment the git index is free of stage-session work. A filing session supervising live stage sessions also writes it then, holding the capture in its own journal meanwhile, because a tracked write under a live session is dirt that session’s clean-tree preconditions refuse and its git add can sweep. Committing is not disposition; the drain below gives each bullet one. It is what makes the surface’s name true, since an uncommitted bullet reaches the next iteration carried by whichever session happens to stage next.
Leaving the commit to the next session is refused, since it fails silently whenever no dispatch instruction names it. The affordance committing its own bullet is unruled: a capture arm cannot judge index freeness, so the timing test sits on the session. That timing test also answers the index-contention objection to committing at all. Where the filer is a supervising lead, the obligation is restated at §templates/lead.md, because the write-authority invariant there enumerates stamps, queue writes and evidence files, and a literal reader takes the omission of commits for a prohibition. Holding rather than exempting the inbox from clean-tree preconditions is the ruling: an exemption covers one capture channel of two, leaves the sweep open, and fixes one dirty path, the shape the installer’s own predicate refused (installer/SPEC.md §The packer).
An isolated child files nothing; it hands the finding back. The arm refuses in a linked worktree, telling the filer to report the finding to its dispatcher, who files it from the main checkout. Routing the write to the main checkout, as gitignored capture is routed (gate-sdk/SPEC.md §The workflow directory), is refused: it is the main-checkout tracked write isolation exists to prevent (delegation-kit/SPEC.md §The delegation model), and no session would own its commit. The arm tests for a linked worktree and cannot see an isolated child, so a session working and committing in a linked worktree is refused too and appends by hand. A worktree of a bare repository is not a linked worktree by that test. The run from a real linked worktree lives in gate-tests/capture-linked-worktree.test.sh, since the crate adds no linked worktree of its own.
This section’s contract is wholly in-crate. Its three implementations are the capture affordance above, --enter-stage’s iteration-boundary gap-inbox check with its close-skipped/post-close discriminator (§bin/enter-stage.sh), and this surface’s union-merge membership (§Multi-operator semantics), which native/src/stages.rs holds.
The live-slug advisory. The arm resolves the prose against the live slug set at capture and, on a match, raises a stderr advisory that asks the filer: the prose names live entry <slug>. If this bullet re-files that finding, say so in the prose and say why; if it merely cites, corrects or argues against that entry, say it is distinct and why, because the closing stage’s drain judges the recurrence from what was written. The resolution buys a prompt and nothing else: it writes no marker, and the bullet keeps the one shape. The live set is every ### <slug> entry heading in the queue file, which is exactly active, deferred, and a configured icebox, so it needs no section knob. The done section and Lessons hold bullets outside the entry grammar (queue-kit/SPEC.md §The queue format), and a sub-task’s deeper heading is outside it too. A finding that recurs after its fix landed is a new defect and files as one.
The scan is lifecycle-kit’s own over the queue, the shape check-stage-entry assertion B takes, never queue-kit’s queue_live_slugs, whose use would close a cross-kit cycle. queue-kit/SPEC.md §The queue format states that general rule, and drift-kit’s kpi-deferred-age records the same accepted residual. Longest match wins, so the advisory names the most specific live entry the prose reaches, and the match is word-bounded on [a-z0-9-] in both directions, so a slug embedded in a longer hyphenated token raises nothing.
The crate’s queue::live_slugs, queue-kit’s predicate compiled, is section-scoped through QUEUE_KIT_ACTIVE_SECTIONS and the deferred and icebox section knobs, where this predicate is grammar-scoped. A consumer with an unconfigured icebox, or an active-section roster differing from its heading set, gets a different live set from each, so collapsing them would change a verdict. The anti-cycle premise describes a shell source dependency, and inside one binary no vendoring decision separates the two predicates. Whether the refusal survives on an independent ownership ground is unsettled.
The matcher prompts rather than decides. A recurrence is a claim about what a finding is, not what a string contains, and no syntactic tell separates “this recurred” from “this is about that”. So the predicate is demoted, not sharpened: its recall was never the defect, its authority was. It survives because the prompt reaches the one party who can answer cheaply, a mid-build filer who routinely has not read the queue. A citation of a ruling takes the same demotion, since a surface may name a retired ruling as evidence about the past or restate it as a live rule. The citing-side reader reports and quotes the sites and never says a citation is wrong (§The ruling-staleness probe). Honest limit: a bullet describing a recurrence without spelling the slug raises no advisory. That leaves no hole, because the drain reads every bullet whether or not the matcher spoke.
Three alternatives are refused. A required recurrence-or-new argument turns capture into an interrogation of a party who has not read the queue, and a refused capture pushes the finding back into session context — the deferred-capture antipattern this inbox exists to prevent. An optional --recurrence <slug> flag invites the drain to trust a structured claim, reinstating the defect one layer up. Narrowing the matcher — anchoring the slug, exempting a bullet containing a negation — institutionalizes the evasion, since the next filer does not know to phrase around it.
Deriving post-close-ness from git is refused, meaning asking “has the inbox been truncated since the close stamp’s commit?” in place of reading the cursor:
- It is wrong on the case that matters. The drain’s truncation lands in a later commit than the close stamp, never the same one. So the predicate answers close-skipped for a close that stamped and then skipped its drain, naming a stage that is gone.
- It cannot answer the other case. Where close was genuinely skipped there is no close-stamp commit to anchor on, so it degenerates into the cursor read.
- It would be
--enter-stage’s firstgitinvocation, making the state machine’s only writer non-hermetic and its fixture a constructed history rather than three files. - Its virtue does not discriminate. The cursor read also needs no marker field, so §check-gap-inbox-neutrality’s two-field bound selects neither.
The tool writes no queue file, the load-bearing constraint on the whole channel: a capture affordance stamping a [recurrence:] date onto a queue entry would make the contended queue write the inbox exists to prevent. The queue write belongs to the closing stage’s drain, which writes the queue anyway.
A queue entry cites this inbox as provenance, never as a locator. The inbox is drained every close. A sentence naming it as a fact’s current home — “it is in the gap inbox as of” a date, or a parenthetical “(gap inbox,” plus a date) — breaks at the next drain. A sentence stating that a finding was filed there and drained on a date is history and stays true, though the promoting commit carries it and the entry need not (queue-kit/SPEC.md §The queue format). The test is whether the sentence still resolves after the next drain. A fact a bullet carries is restated on the entry or cited to the surface the drain moved it to. The same rule binds citations of the survey record (§The survey record), which the iteration boundary truncates. Review holds it rather than a gate, since a present-tense scan over queue prose over-refuses at a cost the measured locator rate does not buy.
A dated attestation freezes the claim, never the locator. The test generalises to every dated claim in queue prose. A dated measurement or judgment — verified YYYY-MM-DD: the three sites are … — is history: true on its date, never refreshed, the freeze the ruling record’s dated-measurement idiom states (gate-sdk/SPEC.md §The decisions this substrate already closed). That example’s date is a specimen and attests nothing. A path:line beside it is a locator, which drift falsifies, and a literal position in prose is the de-literalization defect whatever date sits beside it (doctrine-kit/DOCTRINE.md §Methodology-maintenance rules). A date is not a rev either, since two closes can fall on one day, where a carried survey’s witness is corpus, oracle and rev (§The survey record).
So a locator worth keeping is de-literalized to the name it points at, or rev-pinned where the exact span is load-bearing, and the rule reaches a bare verified <date> beside a path:line. For the pendency sweeps, an anchor whose sentence holds while its line moved is a de-literalization finding, fix-shaped at a drain, and an anchor resolving to different text is a false claim. Two readings are refused. A record immune to drift licenses rotting locators and would un-find a citation resolving to different text. A live pointer the drift falsifies whole breaks the dated-measurement idiom and invites the recount the freeze forbids. An anchor-scanner gate is refused on the grounds the paragraph above gives.
The drain’s dispositions are ordered, and promotion is last. The set is fix, forward, promote, discard, tried in that order per bullet, since an unordered set is read as a promotion default. A discard appends its record to the disposed-findings record, or cites the record the bullet matched whose ground still holds (§The disposed-findings record). A promotion states in the close commit message why neither fix nor forward took it. Forward re-files a bullet of a consult class (§The consult inbox) with --emit file-consult, carrying its prose and naming its original date. A bullet asking the operator for a direction is asked instead, through the live channel or, re-filed after the drain’s truncation, by the next intake. A question only the operator can settle is not work a build can land.
A bullet is fix-shaped when it is debt-shaped by the interstitial litmus — it adds no governed name — and lands test-and-doc-complete in the drain’s own commit. Fixing in the drain is not the refused hotfix track, since it contends on no live stage surface and admits only debt-shaped work. It is not an intake violation either: scope gates initiatives, and debt-shaped work — a defect, or an operator’s request for a change converging on names the governed surfaces already carry — fixed in one commit adds nothing for scope to weigh. The icebox is not a drain disposition: the tier takes deferred entries that aged without recurring (queue-kit/SPEC.md §The icebox tier), so a bullet not worth carrying is a discard with cause. A gate reddening a close on net pool growth is refused, because the exits are judgments and a forced exit is a fake one. The figure is surfaced instead: drift-kit’s kpi-queue-net-delta computes it, and the drain’s commit message states its qnet fragment.
The capture warning. The arm reads the cursor (stages::current_stage) to tell the filer which consequence it is buying. An ordinary filing is told the bullet blocks the next first-stage entry until the drain. A filing made while the cursor sits at the last configured stage is told that once that stage finishes, none is left to drain it. The warning rides stderr, and it is a warning rather than a refusal, since a refused capture pushes a real finding back into session context.
The drain re-verifies; capture does not. A bullet’s prose is a claim made at capture speed, and this channel keeps capture cheap, so nothing upstream established it. A bullet can assert a mechanism its filer inferred rather than ran, and a false premise is paid by whoever drains it. The drain therefore names, per bullet, the claim its disposition turns on and the command that establishes it, runs that command, and records in the same commit which bullets were re-verified and what fell (§templates/stages/ carries the step).
Filing looks for an owner first. Re-verification reaches every write of a new deferred entry, in any stage. That is a drain’s or an intake’s →promote, each finding close files on its own account (a lesson →task, an eviction or triage finding, a dispatch’s captured debt), and a finding validate files as a task. Before writing, the session searches the queue file, the icebox included, and the disposed-findings record (§The disposed-findings record) for an entry that already owns the finding, grepping its distinctive terms: the paths, gate and knob names, and slug-shaped words. A match is read, not counted:
- an owner carrying the finding is a recurrence — it takes a
[recurrence:]date and folds any new axis into its body, and no second entry is filed; - an adjacent entry is cited in the new entry’s prose as distinct, with the reason;
- a done slug is a finding that recurred after its fix, filed as a new defect naming that slug;
- a record whose ground still holds is a re-filing of a discarded finding, discarded again citing the record’s date, with no second record.
The filing commit records the lookup per entry — the terms searched, and none or the matched slug with its reading — so a missed owner shows as a bad search rather than a silent omission. A plain grep for the slug and subject finds the owners this step exists for, so it needs no similarity oracle. It binds where the queue is written, never at capture. Honest limit: a filer whose terms miss the owner files a duplicate and nothing reds, since check-task-conservation conserves a duplicate pair, so the commit-message record is the only auditable residue.
Filing marks what it did not run. Each of those writes marks every claim its disposition turns on that the session did not run with the inferred marker (§templates/stages/), on a body line of its own. The marker is written where the entry is written and never at capture: a gap-inbox bullet gains nothing, and the drain turning a bullet into an entry is where the marker first appears.
Two capture-time shapes are refused. A filing-time prompt for the establishing command, and a grammar separating observed fact from inferred mechanism, both add friction at the moment this inbox keeps cheap and bill the mid-stage filer, whose finding otherwise stays in session context. The affordance gains no prompt and no grammar, and §check-gap-inbox-neutrality keeps the bullet’s fields at two. The refusal reaches no surface where capture is not cheap or no drain re-verifies: the survey record takes the split (§The survey record), and a queue entry takes the inferred marker.
Merge semantics. The inbox carries merge=union (git-native, so no per-clone driver registration), not the keep-ours merge=iteration-scoped the boundary-truncated surfaces carry: an iteration-scoped surface is per-iteration scratch superseded at the boundary, but a gap filed on either side of a concurrent merge must survive. The installer emits the line and check-merge-attrs verifies it (§bin/install-lifecycle.sh, §check-merge-attrs).
The boundary check: one detector, two dispositions. --enter-stage’s first-stage (iteration-boundary) entry fires on any - bullet in the inbox and discriminates between two dispositions, because the drain and the filing window do not coincide.
- Close-skipped — the closing iteration’s cursor never reached the last configured stage. The entry refuses (exit 1, the untriaged bullets printed, nothing written — the same refusal contract as the non-empty Lessons section), with the one recovery that applies: run the closing stage’s gap-drain step, truncate the inbox, re-enter. No gap outlives its iteration untriaged (the gap-disposition rule: costed and filed, never flagged-and-skipped).
- Post-close — the cursor sits at the last configured stage, so the closing stage has run and none is coming back. The entry is admitted: the bullets print on stderr as an advisory naming them this iteration’s first-stage intake, and the stamp proceeds. Deleting a bullet without a disposition is still not a drain.
The discriminator is the closing-stage predicate over the cursor (§The stage-machine adapters), the same predicate the --emit-file-gap arm warns from at capture, so a filer told “none is left to drain it” is told so by the test that later admits the bullet. Each arm composes it from the crate’s stages::current_stage read and the last configured stage. Two edges take the post-close disposition. A closing iteration that was never named (the — placeholder) has no close to have skipped, the reason the guard LIFECYCLE_KIT_BOUNDARY_REQUIRE applies. An inbox holding bullets with no cursor at all, a fresh consumer’s first boundary, is that case too. No second detector is added for the post-close window: the one detector fires, and the discriminator picks only what to do about it.
Why admission. A refusal, however worded, leaves the queue write made before any stamp exists, by a session that has entered no stage. Routing a post-close finding into the first stage’s intake is satisfiable only inside that stage, so the entry has to succeed for the finding to reach it. The disposition then becomes an ordinary in-stage queue write. The invariant “no gap outlives its iteration untriaged” is not weakened: a post-close bullet never had a drainer in its own iteration, and on admission it becomes an ordinary bullet of the new iteration, acquiring that iteration’s mandatory close drain. The first stage takes it earlier because it writes the queue anyway; if it does not, close does.
The honest limit. The finding’s disposition lands in the next iteration’s ledger, since the finding postdates its own iteration’s close and no iteration-correct position exists for it. A promoted entry’s provenance sentence carries the bullet’s own date and names the iteration whose close generated it, so the record is late and says so.
That check is the inbox’s forcing function, refusing on the close-skipped branch and obliging an in-stage disposition on the other, so the inbox declares itself on the close-surface roster (§The close-surface roster) here, where the forcing function is documented:
close-surface: .workflow/gap-inbox.md forced=lifecycle-kit/SPEC.md §bin/enter-stage.sh
Producers and consumers. Producer: any mid-iteration session (lead or stage) in the main checkout, via --emit file-gap; an isolated child hands its finding back instead. The knob default makes the channel live everywhere the kit is vendored. Consumers, two, one per disposition of the boundary check. The close skill’s drain step (§templates/stages/) dispositions every bullet — fixed inline that session, forwarded to the consult inbox, asked of the operator, promoted to a deferred entry, or discarded with cause in the close commit message — then truncates the inbox to its header. The first stage’s intake step (§templates/stages/) takes the bullets the boundary carried, with the same disposition set and truncate-in-the-same-commit rule, run after its stamp; it is reachable at every boundary with no enabling config. The boundary check reads emptiness at the next first-stage entry as the backstop for both. The disposed-findings record’s producer is the session running either drain’s →discard, which appends a record or, on a fallen ground, deletes one; its consumers are --emit file-gap’s advisory, every filing’s owner lookup and check-disposed-findings (§The disposed-findings record).
A [recurrence:] date records a session’s judgment that a finding re-occurred, made by reading the bullet’s prose. A slug appearing in a bullet is an input to that judgment and never a verdict; no mechanism produces the tag.
Every stamp, the drain’s and a direct one alike, is written with --queue recur (queue-kit/SPEC.md §The queue verbs), so the verb is the declaration’s one writer and the judgment stays the session’s. The drain step must not skip the judgment: on a bullet it judges a recurrence, it stamps the date onto that entry’s declaration in addition to its ordinary disposition, never instead of it. It is reachable at every close with no enabling config.
A direct stamp is sanctioned, and obliged. A session that observes a recurrence outside the capture channel stamps one directly, in the commit it is already making. The obligation attaches to the judgment, not to the channel the observation arrived through, since the failure the counter exists to end is a recurrence seen and not recorded, and a merely permitted write is one a session under pressure declines.
A lead discharges the obligation by filing the judgment and its grounds into a bullet’s prose. §templates/lead.md forbids the lead every queue write, so this inbox is its one channel, and the stamp is made by the session that may write the queue, judging from that prose. That is the drain’s own shape, so no new authority or producer is created, and the two dispositions above bring a boundary-filed judgment to a judge rather than a refusal. Stages that may write the queue but never load this rule are reached at the consumer’s resident tier, not in the stage templates. The obligation binds any session, and context-kit/SPEC.md §The consumer footprint rules that class carried resident once, since no kit-side surface every session shape loads exists. This section stays the owner and the templates stay silent, except for the lead, whose discharge differs. The resident line earns its one always-loaded bullet by direct stamps, recurrence: dates landing in commits other than the close drain’s.
The direct path is not forbidden, on three grounds. It would strand a class: close’s steps after its drain — audits, lesson disposition, staleness reads, release disposition — generate findings that postdate the drain, and forbidding it leaves them no legal channel. Sessions reading the direct path as forbidden already paid a recorded cost, and a rule whose disciplined observance produces a known-wrong count is the wrong rule. And a ban would red roughly a third of its own precedent, the ground the provenance gate below is refused on. It is not mechanized, since no mechanism produces the declaration, and the only available mechanism, the capture-time matcher, under-counts by its honest limit above.
The rule is prospective. No session backfills a date onto a judgment an earlier session declined to stamp: a date is legible only because the prose it was judged from sits in the same diff, and for a past decline no contemporaneous prose exists. The count those declines left wrong stays wrong; the rule fixes it forward. Stamping is idempotent per (slug, date): two filings of one slug on one day record one date, the day being the only resolution the bullet’s grammar has.
What the judgment rule costs, and what pays for it. A date is not re-derivable by re-running any predicate over the queue, so auditing the count means trusting the session that stamped it. What pays for it is auditability by inspection: the drain stamps the declaration in the same commit that truncates the inbox, so the judgment and the prose it was made from sit in one diff. That same-commit rule is load-bearing, the audit artifact. The invariant is the judgment and its grounds landing in one commit, and truncating the inbox is one way of satisfying it. A direct stamp truncates nothing, so the observation is written into the entry, beside the declaration, in the stamping commit.
The same-commit rule also makes the stamp a mandated write in queue-kit’s sense, the class queue-kit/SPEC.md §check-queue-entry-budget defines and the section a stamping session reads for its relief when that gate’s per-entry cap blocks the stamp. The grounds write is the same mandated write as the date it accompanies, so it claims the same relief; neither the class nor the relief is restated here. Widening the stamping population from one stage to every session adds judges, some grading their own tool use. No gate can hold that, and the grounds obligation is the counterweight, which makes a thin judgment visibly thin.
A provenance gate is refused. Requiring every commit that adds a [recurrence:] date to co-stage the inbox looks like an enforced replacement for re-derivability. A back-test over this project’s history redded roughly one in three of the commits that had ever stamped one, and it would foreclose the direct-stamp path above. A gate that reds a third of the precedent it was derived from is measuring the rule, not the tree.
Each bullet’s two fields have named readers. The date feeds close’s staleness judgment and becomes the stamped recurrence date. The prose is the disposition body, the grounds the drain judges the recurrence from, the claim the drain re-verifies, and — via the capture-time advisory on stderr — the field the filer is asked to write the claim into. There is no third field, which is the point: §check-gap-inbox-neutrality keeps it that way.
The disposed-findings record
A discard’s only other record is the drain’s commit message, which neither the live-slug advisory nor the owner lookup reads, so a finding discarded once is not matched when it is filed again, and every re-filing buys a drain its re-verification over again. Only →discard needs this record: →fix lands a change in its commit, →forward a consult-inbox bullet, and →promote a live entry the live-slug advisory already matches.
The surface. .workflow/disposed-findings.txt (knob LIFECYCLE_KIT_DISPOSED_FILE, §Layout and configuration) is a committed, checked projection of the workflow directory (gate-sdk/SPEC.md §The workflow directory). It is a .txt by that section’s extension rule, since the arm’s matcher and the gate parse it field-wise. Its first line is
# contract: lifecycle-kit/SPEC.md §The disposed-findings record — - <YYYY-MM-DD> — `<term>`[, `<term>`]… — <finding and ground>
and every later non-blank line is one record, - <YYYY-MM-DD> — <terms> — <prose>. The date is the discarded bullet’s own. <terms> is one or more backticked spans joined by , , each non-empty and free of backticks: the words a later filing of the same finding would spell, chosen by the discarding session. <prose> states the finding and the ground it was discarded on, and is non-empty. The date is read by the advisory and by a drain citing the record. The terms are read by the arm’s matcher and the owner lookup’s grep. The prose is read by the advisory and by the drain re-verifying the ground.
Written at the drain, by hand, in the drain’s commit. A →discard at close’s gap drain or at the first stage’s carried-bullet intake appends one record, unless the bullet matched a record whose ground still holds, which it cites instead. The record lands in the commit that truncates the inbox, so the judgment and its grounds sit in one diff. The first record seeds the header above, byte for byte. Capture never writes it, so capture stays the cheap two-field append this inbox keeps it.
A record leaves only when its ground falls. A drain whose re-verification shows a matched record’s ground has fallen dispositions the bullet as any other, by fix, forward or promote, and deletes the record in the same commit. There is no age expiry: a ground that still holds is still the answer. The boundary truncates nothing in it, so a record outlives the iteration that wrote it.
Normal merge semantics. Two branches each appending a record conflict at the file’s end and are resolved by keeping both. It is not a union surface (§Multi-operator semantics), since a record is also deleted, and joining the union set would make check-merge-attrs’ forward edge red every adopter registering that gate until its --install-lifecycle re-ran.
Not a close surface. Close writes it and reads it as a lookup corpus, never as a triage surface with a backlog to drain, so it carries no close-surface: declaration (§The close-surface roster).
The disposed-finding advisory. --emit file-gap reads the record after appending. For each record any of whose terms occurs in the bullet’s prose, it raises one stderr advisory beside the live-slug advisory. The advisory names the record’s date and the matched term and quotes the prose. It asks a filer whose bullet re-files the finding to say in the prose what has changed since, because the drain discards a re-filing whose ground still holds by citing that record. A term matches case-insensitively and word-bounded on [a-z0-9-], the live-slug matcher’s boundary, so a term inside a longer hyphenated token raises nothing. Every matching record is named, in file order, since two records are two findings. The advisory writes nothing and refuses nothing, because the matcher prompts rather than decides, on the live-slug advisory’s ground. An absent record raises none. A record line outside the grammar is skipped by the matcher and left to §check-disposed-findings. gate-tests/file-gap-recurrence.test.sh pins the advisory end to end. Honest limit: a re-filing spelling none of a record’s terms raises nothing, and the drain’s owner lookup is the backstop.
The consult inbox
Work owed to the consult skill has a committed channel of its own, and two classes enter it. A ruling to reconsider is evidence contrary to a recorded ruling or objective, filed rather than annotated on the record (§The steering vocabulary), or a reversal the operator deferred. A request for advice is a strategic question, architecture say, on which the operator wants a judgment-tier recommendation, filed by the operator or at the operator’s request. Without the channel such an item survives only if a live session relays it. The gap inbox cannot carry it: its drain is the closing stage’s, which can neither rule nor advise at the tier consult declares, so a mixed inbox would stall that drain or force close to judge what only the operator may. The two drains have different owners, so the surfaces are separate.
The surface. .workflow/consult-items.md (knob LIFECYCLE_KIT_CONSULT_INBOX_FILE, §Layout and configuration) is committed and append-only, with the gap inbox’s grammar: a # contract: header, then one - <YYYY-MM-DD> — <prose> bullet per item. An item is public-safe; one needing private context names the private-brief section that holds it rather than restating it. It carries merge=union (§Multi-operator semantics), because an item filed on either side of a concurrent merge must survive.
The affordance. run-gates.sh --emit file-consult [--] "<item prose>" appends one dated bullet, seeding the header when the inbox does not yet exist. Its argv, anchor and linked-worktree contract are §The committed gap inbox’s affordance’s, and stdout is the filed bullet. Its declared roster is LIFECYCLE_KIT_CONSULT_INBOX_FILE alone. The raw append stays a legal fallback.
The count. run-gates.sh --emit consult-count is the one arm that reads the inbox for a count, so a session brief can name what is owed (context-kit/SPEC.md §The session-context hook, a brief command). With one bullet or more it prints one line, Consult inbox: <n> item(s) owed to /consult, oldest <date>., the date the first bullet’s, and undated where that bullet carries none. With no inbox file or no bullet it prints nothing. Both exit 0. It takes no argument, one being a usage refusal at exit 2, writes nothing, and declares LIFECYCLE_KIT_CONSULT_INBOX_FILE alone.
A sibling arm, not a mode of --emit file-gap. One arm with two target surfaces and two stderr contracts is the two-output-grammar shape queue-kit/SPEC.md §The queue-counts arm refuses. The arm drops file-gap’s live-slug advisory, because a consult item is judged for no recurrence, and its cursor warning, because no stage drains this inbox.
Producers. A session in the main checkout holding an item of either class: contrary evidence against a ruling, a lead whose operator deferred a reversal (§templates/lead.md), or any session the operator asks to queue a question for advice. A session never files its own question as a request for advice. The filing session commits its own bullet, under the gap inbox’s index-freeness rule.
Everything else owed the operator is asked live. A direction question, an entry declined twice and a stage’s signal for the operator go to the operator through the lead where one is live, and directly where the operator runs the session. One the operator defers, or one no live channel reaches, is filed to the gap inbox (§The committed gap inbox). The next first-stage intake puts it in its escalation, which a live lead relays rather than rules (§templates/lead.md), so it reaches the operator in either posture. One the operator defers again is filed again, and the closing stage’s drain asks it next.
Ruling on an item is the consult skill’s alone (§templates/consult.md). A consultation takes up the items it reaches and gives each one disposition. Ruled lands it where the consumer’s landing surfaces send a ruling, and answered gives a request for advice its recommendation (§templates/consult.md). Re-classed routes it to the queue, as a gap bullet or a direct entry the operator directs, and discarded removes it with cause. Re-classing and discarding are routing, which any session may do with cause, so an item answered elsewhere or never consult-class leaves by either. The bullet is removed in the commit that lands its disposition, and that commit’s message records the disposition. An item the operator defers stays. The file drains header-preservingly (gate-sdk/SPEC.md §The workflow directory).
It gates nothing and outlives iterations. No stage entry reads it and the boundary truncates nothing in it, so a non-empty inbox refuses no transition: a boundary refusal would make an operator’s absence block the machine. Its visibility is a count: a consumer’s session brief or status line reads the number of - lines, with the oldest bullet’s date as its age. Escalation on count or age is the lead’s dispatch trigger (§templates/lead.md), consumer-bound, and the inbox carries no age escalation of its own. It is on no close-surface row, since close cannot rule on it and owes it no disposition.
Honest limit. Nothing gates the grammar. The arm stamps it, the counters read only the bullet prefix and the drain reads prose, so a malformed bullet still meets the one parser. The gap inbox’s grammar gate answers a drain that parses, and this drain does not. An item nobody ever consults stays owed and visible, which is the advisory posture chosen, not a silent loss.
The survey record
A stage that dispatches a survey — a census, a cohort sweep, a roster built by applying a criterion set to a corpus — buys a finding that lives in the dispatching session’s context and dies with it. The next stage needing the same roster dispatches the same survey again. Each session is correct in isolation; the cost is structural. This surface carries the expensive half of a survey across the stage boundary. It is reached through one always-loaded line in the consumer’s instructions file, because both halves of the obligation bind every stage session: reading the record before buying a survey, and filing one a later stage will want. It earns that cost when a later stage runs a recorded block’s witness and cites the finding instead of re-buying the survey.
The decomposition that makes carrying safe. A census written at scope and read at build is correct only while the tree it censused holds still, and the stages between them are when that tree moves. How long a kind of survey stays true depends on what the next stage does, which its author cannot know, so staleness is answered mechanically per record. A survey worth carrying ran an oracle, and that splits it in two: a cheap, re-runnable half, the oracle’s verdict, and an expensive, judgment half, the reading laid over that verdict against a criterion set. This surface carries the expensive half and re-runs the cheap one.
A carried survey is a citation with a falsifiable staleness witness, never a substitute for the oracle. The consuming stage re-runs the oracle and diffs the surveyed corpus since the recorded revision; if both hold, the recorded judgment stands and is cited rather than re-bought. If either moved, the stage dispatches only the delta.
A survey therefore stays true exactly as long as its witness holds, and the witness is checkable in two commands. A queue entry cites a block here as provenance and never as a locator, since the boundary truncates it — the rule and its test are §The committed gap inbox’s.
The surface. .workflow/survey-record.md (knob LIFECYCLE_KIT_SURVEY_RECORD_FILE, §Layout and configuration) is a committed per-iteration record, append-only within the iteration. Grammar: a # contract: prose header, then one block per survey:
## <YYYY-MM-DD> <stage> — <the one-line question this survey answered>
- corpus: <the git pathspecs the survey covered, shell-quoted, or the literal `none`>
- oracle: <the command whose verdict grounds it, or the literal `none`>
- rev: <full commit sha the survey was taken at>
- finding: <the judgment over what this survey's commands established, in prose>
- inferred: <each claim the survey reasoned to without running a command, or the literal `none`>
Each of the five fields is read at a named transition: corpus and rev by the diff, oracle by the re-run, finding by the consuming session under the witness, and inferred by the consuming session before its work turns on any claim listed there. The heading is the discovery key and it states the question, because a later stage searches by the question it is about to ask, not by the corpus it has not yet chosen.
inferred is where a claim goes that nothing ran. A claim belongs in finding only when a command the survey ran established it, the oracle or any other. It belongs in inferred otherwise: a mechanism read off prose, a count reasoned to rather than printed, a premise judged from its description. A verdict on a premise whose owning surface names its own reproduction is established only by running that reproduction. Every block carries the field, with the literal none legal and an empty value refused, on the convention oracle: set. A block missing the field reds, and an author who writes none has written a checkable statement. What stays uncaught is an author who believed an unrun claim verified, a class strictly smaller than the one uncaught without the field. The same argument justifies the retired-spelling block (canon-kit/SPEC.md §The amendment lifecycle).
Why this record takes the split §The committed gap inbox refuses. That refusal rests on capture being cheap for a mid-stage filer and on a drain re-verifying every bullet. Neither holds here: a survey is bought by a session holding the oracle’s output, and nothing drains the record, whose readers are licensed to cite instead of re-deriving.
corpus is spliced verbatim into the witness, so it has a grammar. It is either the literal none or one or more git pathspecs separated by spaces. A pathspec carrying whitespace, a quote, a shell metacharacter, a glob character or pathspec magic is single-quoted, since the witness runs in a shell and must receive the pathspec rather than its expansion. Every pathspec matches at least one path in the tree at rev. Scoping prose belongs in finding: git diff takes prose words as pathspecs matching nothing and exits clean, so the witness would certify a corpus it never read. none names a survey over no tree corpus, such as a remote run, an untracked log or git history, whose witness is the oracle re-run alone. A none corpus beside oracle: none is a note (the honest limit below).
No field carries a ranking’s inbound sums. The sum is an oracle’s output (queue-kit/SPEC.md §The queue-edges arm), printed deterministically in about a second, so Derivation-first re-derives it and never transcribes it. Its reader would be the next boundary’s ranking, and that boundary’s truncation empties this record first. A ranking survey therefore names the queue-edges command in oracle:, where the witness re-runs it, and its finding cites the figures it ranked on without promising them to a later boundary.
No field says how long a survey stays true. An author cannot know it, and a field carrying a guess would be read as a warrant. corpus, rev and oracle let the reader compute it.
Every field’s git-object-shaped tokens are real, not the rev field’s alone. rev is machine-stamped and probed, but the other four are free prose, and a fabricated short hash in corpus makes a dated census read as precise. The class — an identifier you did not read is not a citation — is owned by delegation-kit/SPEC.md §Resume journal — agent writes, scratch reset sweeps. So a word-bounded run of 7-40 lowercase hex carrying at least one a-f, in any of corpus, oracle, finding or inferred, must name a real object in this repository. A block that carries such a token on purpose — an illustrative sha in an oracle: command, a fixture literal — takes a valve line inside the block:
<!-- survey-token-exempt: <why this token names no object> -->
The reason is mandatory, and a valve without one is a finding that does not exempt: a malformed valve must not buy the skip it failed to justify. The valve is per block, not per token, because a block that legitimately carries one illustrative sha usually carries its siblings too.
Append-only within the iteration, never edited in place. A survey that turned out wrong is superseded by a later block answering the same question, not corrected: the record is evidence of what was believed when, and rewriting it destroys what makes a stale finding diagnosable.
The witness — the re-use protocol. A session about to buy a survey reads the record first and, for any block whose heading answers its question, runs the witness:
- Corpus still?
git diff --quiet <rev>..HEAD -- <corpus>— clean means no commit since the survey touched anything it covered. Anonecorpus skips this step. - Oracle still? Re-run
<oracle>and compare its verdict to the one the finding was written against.
The witness is five strings, and that is what makes a finding portable. The protocol’s whole input is corpus, oracle, rev, finding and inferred, and both commands run from HEAD. Copied onto another surface the witness is more durable than it is here, because the copy is not truncated.
Both hold → cite the record; do not re-buy the survey — cite its finding. A claim in its inferred field is not carried by the witness: re-establish it before your work turns on it, or carry it onward as inferred — into an amendment passage or a queue entry, as its inferred marker (§templates/stages/). Either moved → dispatch only the delta, the dispatch prompt naming the record block and the diff, so the child re-surveys what changed rather than the corpus. A false stale costs one re-derivation, the cost without this record, while a false fresh needs a change touching neither the corpus nor the oracle’s verdict, and then the finding is in fact still true. The mechanism can only degrade toward the behavior it replaces.
The honest limit. A survey whose grounds are not an oracle — a judgment over prose, a reading of history — records oracle: none, and that block is a note, not a re-usable survey: readable for orientation, re-derived before it is relied on. An empty oracle is the silent form of the same thing and is refused (§check-survey-record).
Ruled out: a timestamp freshness window. “Trust a survey under N hours old” is wrong in both directions: a one-minute-old survey is stale if the tree moved, and a week-old one is fine if it did not. Ruled out: auto-invalidating every block on any commit, which is too coarse to leave the mechanism any use; the corpus pathspec makes invalidation proportionate.
The affordance. bash gate-sdk/bin/run-gates.sh --emit file-survey [--] "<question>" "<corpus>" "<oracle>" "<inferred>" "<finding>" appends one block, seeding the contract header when the record does not yet exist. It is a non-gate arm (gate-sdk/SPEC.md §The non-gate arm) declaring two reads, LIFECYCLE_KIT_SURVEY_RECORD_FILE and LIFECYCLE_KIT_STATE_FILE, since it resolves consumer knobs a hardcoded flag has no table row to declare. It keeps the repo-root anchor: a relative record path names the same file from any subdirectory, falling back to the working directory outside a repository.
It refuses at exit 2 in a linked worktree, on §The committed gap inbox’s ground, since the record’s producer is the parent. It refuses at exit 2 on a missing or empty argument, and on a corpus outside the grammar above, naming each pathspec that matched nothing. It takes the free-text argument-shape contract of gate-sdk/SPEC.md §The bin/-tool contract on every positional, since five slots make arity no protection: a positional beginning with - not preceded by -- is refused at exit 2, and -- ends option processing. Usage prints at that refusal, so --emit file-survey --help is a refusal printing the usage block, never a capture. It is advisory tooling, not a gate, and the raw append stays a legal fallback, the grammar being the surface’s contract rather than the writer’s.
It stamps rev and the date itself and derives <stage> from the cursor (the crate’s stages::current_stage read over LIFECYCLE_KIT_STATE_FILE, the only reason that knob sits on the arm’s roster). rev is the field the whole re-use protocol turns on and the one an author gets wrong — a short sha, the rev they started at, or none — so machine-stamping it keeps the mechanism from failing silently. A tree with no HEAD commit cannot ground a witness, so that is a refusal (exit 2) rather than a blank field; a tree with no cursor yet stamps the never-named — the queue header already uses. The arm does not inherit --emit-file-gap’s slug resolution: a survey’s prose routinely names queue slugs as its subject, and the resolver would read each as a recurrence.
A permanent surface carries the finding, never a pointer into here. A surface outside the boundary-truncated set, such as a queue entry or a SPEC section, must not promise a reader retrievable content in this record. The boundary reset empties it, so the pointer resolves to nothing one iteration later. It inlines the finding together with the block’s five witness fields instead, which keeps the finding re-usable rather than merely readable. Naming this record as a subject is unaffected — “the survey record is per-iteration scratch” promises nobody a retrieval — and that distinction is what §check-scratch-citation’s red condition is calibrated against.
Commit-pinning the citation is wrong by construction. The capture arm stamps rev as HEAD at filing time, before the commit that lands the block, so git show <rev>:<record> silently prints a record without the block being cited. A pin would need the landing commit, which no field carries and an author would get wrong.
The affordance that makes inlining one command. bash gate-sdk/bin/run-gates.sh --emit cite-survey [--] "<heading-substring>" selects the one block whose ## heading contains the substring and writes it to stdout as an inline-ready snippet. The snippet is the heading rendered **Carried survey — <heading>** and all five fields in record order, off the field set the block grammar above defines. It refuses (exit 2) on no match, on an ambiguous match, and on an absent record, rather than guessing, since a silently-chosen sibling would be pasted onto a permanent surface as the one the author read. The no-match refusal prints the record’s headings and the ambiguous one prints every match, because narrowing is what the author needs back.
It follows the capture arm: the repo-root anchor, exit 2 on a missing or empty argument, and the shape contract of gate-sdk/SPEC.md §The bin/-tool contract for its one free-text positional, with usage printed at that refusal. Its declared read is LIFECYCLE_KIT_SURVEY_RECORD_FILE alone, since it derives no stage and stamps no rev. It is advisory tooling, not a gate. It does not rewrite the citing surface: the author chooses where the finding belongs and how much finding prose to carry, and a splicing tool would need a marker block that made a hand-written citation second-class.
Where it is implemented. The affordances above declare this section in their own # spec: headers. --enter-stage carries this section’s read trigger — the entry report that prints the record’s headings and never its findings — and the boundary truncation below, and it declares §bin/enter-stage.sh. LIFECYCLE_KIT_PERMANENT_SURFACE_GLOBS, the surface list the no-retrieval-pointer rule reads, is a row of the kit’s static table (§Layout and configuration). A stated-contract cut ports the files that declare a section, not every file that implements it, and the two sets come apart wherever a shared entry point or a config library carries one clause of another section’s contract.
How this differs from the gap inbox, which it sits beside. The two are sibling committed per-iteration surfaces with opposite semantics on both axes:
- Merge. The gap inbox is
merge=union— a gap filed on either side of a concurrent merge must survive. The survey record ismerge=iteration-scoped(keep-ours): a survey from the other side describes a tree state this clone never had, so surviving the merge is the hazard, not the save. - Boundary. The gap inbox refuses the iteration boundary while it holds bullets, because every gap owes a disposition. The survey record is boundary-truncated and never refuses: a survey owes nobody a disposition, being scratch whose whole lifetime was the iteration that just ended (§bin/enter-stage.sh).
The record resets as a kit built-in member, not through the consumer’s LIFECYCLE_KIT_BOUNDARY_TRUNCATE array, by the rule LIFECYCLE_KIT_LESSON_EVIDENCE_FILE follows: a defaulted array is replaced when a consumer assigns it, so a default member would silently lose the reset in every consumer that sets the knob.
Two homes were ruled out. The resume journal (delegation-kit/SPEC.md §Resume journal — agent writes, scratch reset sweeps) is crash recovery for the writing session, discoverable by its per-session name, where a hand-off surface must be discoverable by content. It also lives in untracked scratch with a second reclaimer, context-kit’s session-context hook, whose age guard is shorter than an iteration (context-kit/SPEC.md §The session-context hook), so a scope-stage journal can be swept before its reader runs. The gap inbox has the right tier and the wrong semantics on both axes above: the boundary would refuse on residue nobody owes a disposition for.
Producers and consumers. Producer: any session that bought a survey, via --emit file-survey through the battery front-end (the raw append the sanctioned fallback); the knob default makes the channel live everywhere the kit is vendored, so no deployed configuration must be set. Consumer: the next stage session, at its --enter-stage entry, which prints the record’s headings and never the findings, and at the moment it is about to dispatch a survey, where the witness applies. Each field’s reader at its transition:
corpusandrev— the consuming session’sgit diffat the pre-dispatch check (anonecorpus skipping it);corpusalso the capture arm at filing andcheck-survey-recordat commit time, which hold its grammar and its match atrev.oracle— the same session at the same transition, to re-run.finding— that session only after the witness holds, which is why the entry report prints headings and not findings.- the
##heading —--enter-stageat every stage entry, andcheck-survey-recordat commit time as the block delimiter. - the whole surface — the first-stage truncate at the iteration boundary, which discards it.
Nothing is produced on the delegation side. No journal contract moves, no dispatch shape changes, no obligation lands on a dispatched child. The producer is the parent, where delegation-kit’s durable-before-you-act rule already puts the write. It is not the durable escalation artifact delegation-kit/SPEC.md §The delegation model prescribes as a child’s upward route: that one is mid-run, dispatcher-minted, per-dispatch and written by the child, where this is a hand-off, single-path, per-iteration and written by the parent after the child returns. They must not be collapsed.
Committed as the gap inbox’s bullets are: by the filing session, at the first moment no stage session’s work holds the index, and written then too where the filer supervises live stage sessions (§The committed gap inbox).
The close-surface roster
Close reads a set of inbound triage surfaces — capture logs, harvest sinks, the gap inbox, the queue’s Lessons section. Enumerated only as prose, that set has no closure: a surface close never reads leaves no trace, and an inbox added without being named costs nothing at the mistake and everything afterwards. The roster replaces the enumeration with a derivation, and gives each surface a mode so a skip is a visible judgment rather than an invisible omission.
The declaration. A surface declares itself in the section that already owns it, in one full-line directive of the same shape and altitude as canon-kit’s spec: and contract: directives. (The derivation skips fenced blocks, so the grammar is quotable where it is specified — check-spec-pointer’s carve-out, for the same reason.)
close-surface: <path> <mode> [reclaim=<command>]
<path>— repo-relative, or a<file>#<section>locator when the surface is a section of a larger file; the fragment is the heading in anchor form (spaces as-), since<path>is the line’s first whitespace-delimited token.<mode>— exactly one of:forced=<owner-path> §<section>— a structural forcing function exists and the citation names it. The gap inbox’s is the iteration-boundary entry refusal; the Lessons section’s is that refusal’s sibling assertion.advisory— no forcing function. Close reads it by procedure, and a skip is sanctioned and visible rather than undetected, a judgment someone may audit.
reclaim=<command>— required when<path>is a capture-tier (gitignored) member, naming the drain that empties it. It runs to end of line, so it is written last. The runtime-artifact lifecycle rule demands a paired reclaim path for every write path, and this is where that pairing becomes machine-readable.
Declaration lives with the owner, never in a central list, which would be a second source drifting from the surface it names. A vendored kit root holds no SPEC.md, so the payload carries each kit’s declarations into its root as close-surfaces.txt, generated at pack time from the SPEC and read by the derivation beside it (gate-sdk/SPEC.md §Consumer payload). The SPEC stays the declarations’ one home.
A forced= declaration belongs on a manifest surface, because that is what gives the citation its resolver. check-spec-pointer’s prose-citation pass sweeps the manifest set for a free-prose <path>.md §<heading> citation and resolves it in prefix mode, and a forced= citation is that shape. The restriction binds forced= only: an advisory declaration carries no citation, so a consumer-owned capture surface can declare itself in the binding that owns it, which is not a manifest surface. A carried close-surfaces.txt row is no authored declaration: it copies one authored on a manifest surface, whose citation the publishing tree resolved, so carrying it adds no unresolved citation. The honest limit follows from the same seam: a forced= declaration outside the manifest set carries an unresolved citation no gate here catches, because the resolver is canon-kit’s and lifecycle-kit does not depend on canon-kit.
The derivation is the close-surfaces emit arm (§The close-surfaces emit arm), never a maintained registry. It unions two sources: every close-surface: declaration across the resolved kit roots and the consumer’s configured declaration surfaces, and every gitignored member of the workflow directory, capture-tier and therefore close-inbound by definition (gate-sdk/SPEC.md §The workflow directory). A drain file beside a declared log (gate-sdk/SPEC.md §The workflow directory) is part of that log’s row rather than a surface of its own. The second source makes the roster fail loudly: a capture surface added with no declaration appears as (undeclared) rather than not appearing at all.
A consumer’s ruling record is a roster member, which makes close’s repair step derived rather than remembered. Close reads the record for aged facts and fired discharge conditions (§templates/stages/), so it is an inbound triage surface by the same test as any other. It declares itself in its own header, advisory, since nothing can red on a record nobody read, and the mode makes a skipped repair a visible judgment. It is tracked rather than capture-tier, so no reclaim= is owed. A consumer keeping no ruling record leaves LIFECYCLE_KIT_RULING_RECORD empty and writes no declaration, so the roster carries no such row and close’s step skips rather than fails.
Ruled out: merging the two friction capture logs behind one file with a type column to shrink close’s inbox count:
- The logs are owned by different kits, and the dependency runs one way — the drift-kit KPI already reaches into guard-kit through the shared kit-root resolution, so guard-kit cannot depend back without a cycle. A merged log has no legal owner short of the base gate framework, which is not a friction sink.
- Their producers differ: one is a harness hook fallthrough writing raw command text at a prompt, undated and ungrammared; the other is a deliberate structured capture.
- Their consumers are disjoint — allowlist-filtering and pattern-ranking on one side, doc-owner remediation on the other — so every consumer would filter by type first, re-deriving the two logs at read time.
- Their reclaim moments are independent whole-file drains, so a shared file makes each sweep’s drain take the other type’s untriaged lines.
The complaint behind the merge is real, since the two frictions compete for one triage attention, and the roster answers it directly: both appear on one derived roster, with modes.
The audit roster
The audit roster is the capture mechanism of doctrine-kit’s Enforcement-first carve-out. A class that no check can decide cleanly stays a stated manual duty, and a duty with no named cadence is one no session performs. So the class joins a tracked roster that the close stage reviews, with event-keyed due-ness: a named observable event — a contract edit, a release prep, a template upgrade, a new member on a governed surface — beats an iteration counter no surface tracks. The roster is hand-curated, not derived. Which classes escape a clean check is a judgment no tool enumerates, so Derivation-first’s ladder lands on state-once-at-the-owner. The roster and its review step replace a gate; they are not one. Which classes a tree runs is consumer content: the blocks live in the consumer’s file, and the kit ships no class.
The surface. LIFECYCLE_KIT_AUDIT_ROSTER_FILE names it, and the knob defaults empty. A tracked record file carries a # contract: pointer header, then one block per class, separated by blank lines:
class: <class-slug>
scope: <what to sweep, and each standing reading that changes how the next sweep runs>
due: <the named event(s) that make the class due>
last: <iteration> <stage>
corpus: <the command the sweep ran> | surfaces: <path>[, <path>...]
hits: <candidates triaged>
declined: <what the sweep left unread or ruled outside its trigger, and why> | none
A never-swept class carries last: never and stops there. Every other block carries all seven keys, in this order, one per line, with no stray line. Each field has a named reader:
class— the review, as the block’s key.dueandlast— the review, to judge due-ness.scope— the sweeping session, to derive its corpus.corpus— the next sweep, which re-runs it as a floor.hits— the next sweep. A count that falls with no tree change to explain it is a signal to read.declined— the next sweep, which takes up what its predecessor set aside.
Nothing appends. A sweep replaces the last, corpus, hits and declined lines. A reading that changes how the next sweep runs is folded into scope by re-phrasing it, never by appending. The sweep’s narration, its findings and its cleared candidates go into the commit message that re-stamps the block, so git log -p over the roster recovers every earlier reading, and the file carries only the standing ones. Every line is bounded by LIFECYCLE_KIT_AUDIT_ROSTER_LINE_CAP unless the consumer set it off, so a whole-file read stays affordable at the review.
Ruled out: one file per class. It moves growth from one file to many and removes none of it, and a review still reads each accreted body whole. Ruled out: capping an appending row and forcing the appending close to compress. How much a sweep appends depends on the iteration’s shape, such as a deletion or a ruling landing, so a per-close cap would truncate hardest in the iteration the roster exists for. The cap here bounds a standing line that is rewritten, never an append. Compaction without a grammar is unsafe: a pass that reads an accreted row as prose can drop a mandatory field. That is why the fields are separate lines and check-audit-roster grades them.
corpus attests what was read, not what the next sweep must read. A stored probe goes stale whenever a class’s instances are event-derived, such as the path components a deletion touched. So scope states how to derive the corpus, each sweep derives its own, and the predecessor’s corpus is a floor that the sweep widens. A class whose scope derives its corpus from a commit range bases that range on the iteration-start commit by default. A class that must also see the previous close’s own later commits and interstitial commits names the previous-close commit instead (§The state machine).
A differential sweep of an un-gateable class is such a roster block, never a new close step: the roster is already the event-keyed cadence close reviews, and a second cadence mechanism for one class would sit on a surface the class may itself govern. A class with no single-command corpus stamps corpus: surfaces: and the list of surfaces it actually read; a capability claim’s instances are an example, a tree set no scanner infers. A sweep that read nothing has nothing to stamp there, so it cannot record a verdict. declined records any live-looking hit the sweep ruled outside its own trigger. A declined hit that is a live defect is still dispositioned, under the Gap-disposition rule.
A class swept on an executor outside the session can be skipped with notice. Such a class runs its sweep on another vendor’s agent (delegation-kit/SPEC.md §The foreign-vendor run), whose window is not the iteration’s budget. Where that executor reports itself unavailable, the review skips the class: the run fails without a shape refusal, or the spend its scope: requires was not authorized at this close. The block’s lines stay as they are, so last: does not move and the class is due again at the next review. The commit message names the skip and the executor’s verdict line. A skip is neither a costed filing, since nothing about the class changed, nor a block, since an outside budget does not gate the iteration. A class whose sweep spends such a budget states the spend’s authority in its scope:, which is the consumer’s operator’s to give.
last names its stage, and the stage is checked against the stamps. <stage> is a member of LIFECYCLE_KIT_STAGES. The review belongs to the last configured stage, but any stage may stamp. A stage that just moved a class’s population would otherwise leave the row stale. The review reads a last naming any other stage as a pre-stamp: the audit is still owed and has not run. Where <iteration> is the queue header’s current iteration, the state file must carry an <iteration> <stage> stamp, so a row cannot claim a stage that never ran. The honest limit: the check proves the named stage was entered. It does not prove that session wrote the line, and it does not prove the audit was faithful.
A consumer that sets the knob owes the roster a close-surface: declaration, advisory (§The close-surface roster). The roster is tracked, so without one it reaches no derived roster, which is the same position as the pre-flight valve’s ledger. No forcing function exists, and the mode makes a skipped review visible. An empty knob leaves the review step skipped and check-audit-roster inert.
Testing
Every gate ships the ordinary good/+bad/ fixture pair, and the scenarios one pair cannot hold live in gate-tests/*.test.sh scenario runners. This section owns how those runners reach their gate.
A runner that drives a gate names that gate, never a substrate: it resolves through gate_run <name> <checks-dir> <args> (gate-sdk/SPEC.md §run-gate-tests), which dispatches through gate_command exactly as the fixture harness does. A member that ports to a compiled subcommand therefore keeps its scenario coverage running unchanged, where a runner holding a checks/<name>.sh path loses it in the motion that lands the descriptor. For a case that differs by a knob rather than by argv, gate_env NAME=VALUE sets that one case’s environment in the caller’s subshell; check-stage-evidence’s session-boundary posture cases are the worked instance. Every gate-driving runner in this kit resolves through gate_run.
The rest exercise advisory tooling with no gate to dispatch. Those that drive --enter-stage reach the binary directly rather than through gate-sdk’s front-end, because that front-end refuses outside a git repository and these harnesses run in a non-git mktemp -d by design. That is the arm’s sanctioned second caller (§bin/enter-stage.sh), not a bypass, and it is spelled once in gate_arm_run <arm> <argv>.
A ported non-gate member owes a both-substrates comparison. With both implementations present, each harness runs against each, and a byte-level driver compares them per case over twin sandboxes. It compares exit status, stdout and stderr byte for byte including every help: line and simulate prefix, every written file, every unwritten file on each refusal path, and the wiped set over a seeded scratch tree. <head> is read live and <session-id> is derived, so those two columns are compared by shape: each case pins them or accepts a short sha, eight hex characters or the literal none. A consumer with a real queue adds a --simulate of every configured stage on the live tree under both implementations, since --simulate writes nothing by contract. Two differences are named rather than hidden: the usage text’s program word, and the wiped set’s report order, a sorted walk, so the set is identical and the order deterministic.
gate-tests/gap-inbox-route.test.sh pins the iteration-boundary gap-inbox check, whose refusal and recovery text are what an entering session acts on. It drives --enter-stage against a sandboxed queue, state file and inbox — the harness gate-tests/boundary-scratch-wipe.test.sh established — and pins the branch both ways. The close-skipped case refuses with the drain recovery and writes nothing; the post-close case stamps and carries the bullets with its advisory; the never-named and no-cursor edges take the post-close branch; and --simulate reports each branch with its recovery relayed and writes nothing. No fixture pair is owed: this is a bin/ tool, not a gate.
Per-component contracts
The stage-machine adapters
native/src/stages.rs is the sole holder of the stage machine’s shared state adapters: the header parse and its iteration extraction (stages::header, stages::header_iter), the cursor (stages::current_stage) and stage membership (stages::stage_known). Every gate must read the two axes identically, and one shared adapter removes that drift axis. The knobs they read resolve from the kit’s static table (§Layout and configuration). The module holds values and adapters only, never gate structure (gate-sdk’s lib/gate.sh rule).
stages::current_stage is the cursor derivation: the last data line’s <stage> token, so every lifecycle reader shares one definition of “current stage”. It answers empty for both no-cursor shapes (§The state machine), an absent file and a file with no data line. “No cursor” is a legitimate state rather than a parse failure, so each caller rules on what it means for it (§check-stage-entry, §check-stage-evidence). stages::header_iter strips an optional trailing bracketed field, residual-field healing, so a pre-upgrade header still carrying [stage:] reads as the bare iteration name. The cross-kit readers do not call this adapter: each derives the cursor from a path it already configures, so no consumer kit gains a lifecycle-kit dependency.
The same module holds the iteration-start read. It takes a state-file path and returns the first data line’s <head> exactly as recorded, or empty in every no-commit case §The state machine lists. It verifies the commit resolves in one git rev-parse --verify <head>^{commit} before returning. A cross-kit reader hands in the path its own knob resolved, so it calls the crate read without gaining a lifecycle-kit vendoring dependency (the shared-derivation shape of §bin/session-id.sh).
The closing-stage predicate is true when the cursor equals the last member of LIFECYCLE_KIT_STAGES, and false otherwise, including for both no-cursor shapes, since a cursor that does not exist has reached nothing. Its two callers must agree. The --emit-file-gap arm reads it for the capture-time warning naming the consequence a filer buys, and --enter-stage reads it at the iteration-boundary gap-inbox check to choose between refusing and admitting (§The committed gap inbox). Each composes it from stages::current_stage and the last LIFECYCLE_KIT_STAGES element, so a filer warned that “none is left to drain it” is warned by the same test that later admits the bullet. No knob is minted and none is possible: the last configured stage is the predicate’s whole configuration, so it is config-derived in every consumer. It must not acquire a third caller silently; stages::current_stage remains the general reader.
The module renders the resident registration block, stages::registration_block (§bin/install-lifecycle.sh), from the live config. Three members give the merge-attribute surface the same writer/asserter shape, between the --install-lifecycle arm and the gates asserting what it writes (§check-merge-attrs, §check-lifecycle-registration):
stages::supersede_set, the derived iteration-scoped supersede set: the state file, the two kit-owned built-ins (the lesson-evidence file and the survey record) and eachLIFECYCLE_KIT_BOUNDARY_TRUNCATEmember, exactly what--enter-stagetruncates at the boundary.stages::union_set, the derived union-merge set: the gap inbox and the consult inbox (§The committed gap inbox, §The consult inbox).stages::merge_attrs_block, rendering the supersede set as<path> merge=iteration-scopedlines and the union set as<path> merge=unionlines (§Multi-operator semantics).
The derived supersede set is read past the merge-attribute pair, by check-stage-evidence and by check-scratch-citation, which forbids a permanent surface pointing a retriever at any member. So a consumer adding a LIFECYCLE_KIT_BOUNDARY_TRUNCATE member gets citation enforcement over it with no second roster to keep in step.
The journal-path derivation is LIFECYCLE_KIT_STAGE_JOURNAL_PATTERN with <stage> expanded (enter_stage::journal_path). It is hoisted on the cursor’s ground: its readers must name one file, or the assertion checks a path nobody was asked to write. They are --enter-stage, at the opener and at the entry assertion, and --emit journal, through which the stage session writes (§The journal arm). All three resolve a relative expansion at one anchor (enter_stage::repo_anchored), the repository root or, outside a repository, the working directory, since a second anchor names a second file from a subdirectory. The entry’s queue, state file and scratch dir stay relative to its working directory. Two members sit beside it: the opener writes the journal’s opening line and reports the path it wrote, and the written predicate is what the entry assertion reads. They share the derivation and one spelling of the opening line’s fixed lead, so the writer and the reader telling the tool’s bytes from a session’s cannot drift apart (§bin/enter-stage.sh). A second literal would make that drift silent, since a diverged reader passes on every skeleton rather than erroring.
The caller-stage read, a session’s last stamp’s stage (stages::caller_stage), is hoisted beside it.
The table validator is fail-closed on shape, not merely on presence. It runs at the kit’s first knob read in a process, collects every finding, and refuses at exit 2 under the kit’s malformed-config lead line (gate-sdk/SPEC.md §The knob file). A knob takes an arm wherever a load-time check can refuse a malformed shape:
LIFECYCLE_KIT_WORKTREE_LOCK_PID_RE, when non-empty, is compiled by the engine that will match it (gate-sdk/SPEC.md §The POSIX ERE matcher’s one-group capture) and refused for any shape outside that item’s, the finding carrying the engine’s own reason. An uncompilable pattern matches nothing and a group-less one captures nothing, so either would classify every worktree unclassified while looking configured.smoke/exercises it with a pattern set, since the empty default never reaches the branch.LIFECYCLE_KIT_STAGE_JOURNAL_PATTERNis refused without a<stage>placeholder. Such a pattern names one file for every stage, so the entry assertion would read another session’s journal and pass on it: a wrong answer, not a missing one.LIFECYCLE_KIT_STAGE_JOURNAL_REQUIREtakes the0|1arm shape ofLIFECYCLE_KIT_BOUNDARY_WORKTREE_CHECK.
LIFECYCLE_KIT_PREFLIGHT_VALVE_FILE gains no arm by that rule, not by omission: its value is a path that need not exist, header-only being the valve ledger’s resting state (§bin/enter-stage.sh), so no load-time shape is refusable. The ledger’s two fail-closed refusals live in the writer, where the file is read. A validator parsing the ledger would refuse every entry on a malformed one, including entries that never asked whether a valve was armed.
bin/session-id.sh
Prints the canonical stamp id, so a stage skill reads it rather than guessing. The member is the gate binary’s --emit-session-id arm, reached through the generic composer as bash gate-sdk/bin/run-gates.sh --emit session-id. The heading is a section name, not a file name. No .sh driver stands behind it, and it keeps this spelling because the citations pointing here resolve against it. It is not a gate: no gates.list row, no .gate descriptor, no fixture pair. It is an arm-table member (gate-sdk/SPEC.md §The non-gate arm) whose contract is a document: one normalized id on stdout and exit 0, or a diagnostic on stderr and exit 2.
This section owns the derivation’s contract, and one shared crate module holds its implementation. native/src/sessions.rs serves this arm and drift-kit’s two meters alike (drift-kit/SPEC.md §The overhead meter). The module reads no environment: every input arrives as a value, so each consuming arm resolves its own kit’s sessions-dir knob and hands the answer in. It exposes source 3’s child-flag verification as its own verdict, top-level, delegated or undetermined, because the overhead meter decides on that verdict whether it may resolve at all. That is one verification with two readers, never a second copy.
The id derives by a fixed source order, first hit wins. Every source ends in the same normalization: strip a leading agent- token if present, then take the first 8 characters.
LIFECYCLE_KIT_SESSION_ID— a harness-neutral consumer override. A consumer whose harness exposes a session identity by any means wires it here.CLAUDE_CODE_SESSION_ID— the shipped default, harness-specific by nature. This harness exports the current session’s transcript uuid into every Bash environment, identifying the session directly rather than by file mtimes. It is taken directly only whenCLAUDE_CODE_CHILD_SESSIONis unset. With the flag set, a lead-dispatched stage session (§templates/lead.md) may see the lead’s uuid here, so source 3 verifies the flag before trusting it and can route back to this uuid.- The newest transcript under the sessions dir, as the next paragraphs state.
Source 3’s sessions dir is LIFECYCLE_KIT_SESSIONS_DIR when set, else <config-home>/projects/<cwd-slug>. The config home is $CLAUDE_CONFIG_DIR when set non-empty, else ~/.claude, ~ being USERPROFILE on Windows, read even with HOME set, and HOME elsewhere, for every reader of that home. The cwd slug is the harness’s encoder: every UTF-16 unit outside [A-Za-z0-9] maps to -, and a slug past 200 units is cut there and suffixed with - and the base-36 absolute value of the path’s 32-bit string hash. The cap and the hash are the harness’s own values, never knobs. The top-level glob is widened with <dir>/*/subagents/*.jsonl, so a dispatched session with neither env var resolves without a per-dispatch override.
Newest-file selection is the documented single-operator assumption: one live session per project tree. The widened glob makes it bite within one session too. A top-level session reaching this source just after one of its subagents finishes picks that subagent’s transcript when it out-mtimes the session’s own. So a lead deriving its own id here for its session-role marker (§templates/lead.md) can name the wrong session. A lead in that position verifies the printed id against its own transcript before writing the marker.
A dispatched child narrows the scan. With source 2 skipped and CLAUDE_CODE_SESSION_ID carrying the lead’s uuid, the scan reads <dir>/<lead-uuid>/subagents/*.jsonl alone. That excludes the lead’s own top-level transcript, which is written concurrently and can out-mtime the dispatched session’s. The flag is verified, not trusted, because this harness sets it in top-level sessions too:
- a non-empty narrowed scan is a genuine child, and the newest subagent transcript wins;
- an empty scan with
<dir>/<lead-uuid>.jsonlpresent marks the flag spurious, since a genuine child’s transcript lives undersubagents/while it runs; the uuid names a live top-level session, and the derivation falls back to source 2’s answer; - an empty scan with no such top-level transcript exits 2, which only a wrong sessions dir or a broken layout reaches.
Two races are accepted. A genuine child stamping before its transcript’s first write would fall back to the lead’s uuid, which is theoretical: a child’s transcript has its first writes by the time it can run a tool call. A spurious-flagged session that dispatched subagents earlier stamps the newest subagent’s id, a provenance smudge rather than a correctness break. An absent dir or transcript exits 2.
Callers. --enter-stage invokes the derivation for the <session-id> field, so the stage skills reach it through that arm rather than calling it. The one direct caller is a lead writing its own session-role marker (§templates/lead.md), which the front-end route serves.
The declared knob roster is empty, and it must be. Neither LIFECYCLE_KIT_SESSION_ID nor LIFECYCLE_KIT_SESSIONS_DIR is a row in the kit’s static table. Declaring a name its owning kit does not declare is the undeclared-name refusal (gate-sdk/SPEC.md §The knob file), which would fail-close the arm on every invocation. LIFECYCLE_KIT_SESSIONS_DIR is an env_only name there, so an adopter’s docs may cite it with no table row behind it. Table membership is what makes the arm reachable, so the row exists with an empty roster, the second such member after --emit-md-section. The two names never become rows: a knob file must never set a stamp-id override, since every session reading that file would then stamp one id.
Both names reach the arm anyway, with the harness’s, because the front-end hands the binary its environment unchanged. The front-end execs the binary with the arm (gate-sdk/SPEC.md §lib/gate.sh, §run-gates), never under an env -i. So LIFECYCLE_KIT_SESSION_ID, LIFECYCLE_KIT_SESSIONS_DIR, CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_CHILD_SESSION, CLAUDE_CONFIG_DIR and the home variable all reach it. §The non-gate arm’s rule that a deleted driver’s inline default moves into the owning kit’s library does not bind here. Its ground is a declared knob, and this arm declares none.
The process cwd is an input to source 3, so --enter-stage reaches the binary directly rather than through the front-end. bin/run-gates.sh cds to the git toplevel before dispatch (gate-sdk/SPEC.md §run-gates). That would change the sessions-dir slug under any other cwd, and turn a non-repository cwd into exit 2. So the sole production caller invokes the binary’s --emit-session-id directly, which §The non-gate arm sanctions: it forbids a second entry point into the emission path, not a caller. The front-end route serves templates/lead.md and the consumer smoke, both at the repo root where the two agree. The sessions dir is never pinned to the toplevel. The arm reads PWD where it is set and the crate’s crosser answers otherwise, because bash’s pwd prints the logical path where the process’s own cwd is the physical one.
--enter-stage refuses on an absent binary. It reads the id before it dispatches any gate, so nothing else in that run would report an unbuilt binary first. It checks the binary GATE_SDK_NATIVE_BIN names is executable and refuses with the build command, in the shape §lib/gate.sh’s own two readers use. The refusal is in-envelope: the arm needs a built binary where a vendored shell driver did not, so it covers a failure mode the arm itself introduced. A consumer’s permission surface needs no new grant, since its committed run-gates entries already cover the invocation.
The kit’s templates name this arm as the id’s source. Its sole production caller is --enter-stage, but the stage templates and templates/lead.md cite it, and one of them invokes it directly. So a change to its invocation edits kit templates/*.md and stales whatever projection reads them.
bin/enter-stage.sh
The deterministic writer for a stage transition. bash gate-sdk/bin/run-gates.sh --enter-stage <stage> appends the invocation stamp and derives the session id in process by the --emit-session-id derivation. The id is never an argument, so the no-hand-picking rule rides into the tool.
The heading is a section name, not a file name. No shell file stands behind it, and the kit’s only shell members are its two smoke/ members, no-port by declared cause. It keeps this spelling because roughly eighty pointers in the governed manifest set cite it, backstopped by check-kit-ref-liveness, and citations outside that set have no gate behind them, so a rename would stale them silently.
The grammar, whose flags ride as operands, so the argument grammar is this tool’s own and only the program word is the front-end’s:
--enter-stage [--simulate] <stage>
--enter-stage [--simulate] --rename <name>
--enter-stage [--simulate] --open-lead-journal
--enter-stage --dispatch <stage> [--waive <reason…>]
--enter-stage --dispatch-withdraw <stage>
--dispatch and --dispatch-withdraw never take --simulate. <stage> must be a configured stage, else exit 2. The positionals are membership-validated rather than free text, so the tool owes no leading-- refusal (gate-sdk/SPEC.md §The bin/-tool contract). It owes the help half: -h or --help as the first argument prints usage on stdout and exits 0.
It is an Arm::Run arm-table member, and the variant is the exit contract. The status is three-state and every code is load-bearing:
- 0 — a stamp, or a reported no-op;
- 1 — a refusal: the entry pre-flight, a
LIFECYCLE_KIT_ENTRY_PREFLIGHTcommand, the predecessor-journal assertion, or a boundary refusal; - 2 — a usage or configuration error, an absent or unbuildable binary included.
An emitting arm collapses to {0, 2} and would rewrite every refusal to the misuse code. The stage templates’ on a refusal, do not force the entry rests on that distinction, since a session cannot follow it if a refusal reads like a typo. The front-end execs the binary, so the arm’s status becomes the front-end’s. Unlike a harness-integration arm, it never declines with 0.
Every stamp write’s report ends in the commit to make and its subject. After the next: commit line, a subject: line names the subject the commit carries (§The stamp protocol). An entry, a valve-admitted one included, takes chore(<stage>): stamp the <stage> stage entry, and the boundary reset appends at the iteration boundary. The subject is scoped to the stamp the write added, so the writer hands the session the spelling check-stamp-subject asserts, from the same derivation (the last stamp the write added). --simulate writes nothing and prints neither line.
A surplus argument is refused, never dropped. <stage> takes exactly one operand, as do --rename, --dispatch and --dispatch-withdraw, and --open-lead-journal takes none; --dispatch may add --waive and its reason. Anything after exits 2 naming the surplus token. A trailing --simulate is named specially, with the flag-first spelling, because that is the misuse the refusal exists for. Dropping it would fail open on exactly that input. The dropped token alone separates a read-only probe from a real stamp, and at an iteration’s first stage from a boundary reset that truncates the evidence file.
Two alternatives stay refused. Reading the flag in any position repairs one spelling and keeps discarding every other unparsed argument in silence, where the refusal covers the class. An environment knob for the mode mints a config surface for what an argument already expresses, and every child the arm spawns would inherit it.
The declared roster is the prefix family LIFECYCLE_KIT_*, plus four names outside it. The family is derived rather than transcribed. This arm and the pre-flight gates it dispatches read lifecycle-kit knobs, and a transcribed union drifts the first time one of those gates gains a knob. Each dispatched gate inherits the invoking environment unchanged and resolves its own knobs. The four:
GATE_SDK_PRUNE_DIRSandGATE_SDK_PRUNE_EXTRA_DIRS, which arecheck-stage-entry’s;GATE_SDK_KIT_DIRS, the kit roots whosechecks/directories the two pre-flight members resolve in;GATE_SDK_TMP_DIR, the scratch dir: the temp state file’s home, the boundary wipe’s subject and the resume journal’s parent.
Two callers are sanctioned, and the second is not a convenience. A stage session reaches the arm through bash gate-sdk/bin/run-gates.sh. A hermetic test harness resolves the binary itself and invokes the arm directly. The front-end cds to the git toplevel and refuses outside a repository, while the harnesses driving this tool run in a non-git mktemp -d. One of them asserts, as a named case, that a non-git tree skips the linked-worktree check rather than failing on it; a repository git refuses (gate-sdk/SPEC.md §The crate’s crosser) refuses the entry instead. Routing every caller through the front-end would turn that supported invocation into a configuration error no gate would report.
A conditional cd in the front-end is refused: every entry point resolves repo-root-relative paths from the toplevel, and the whole battery rests on that precondition. gate-sdk/SPEC.md §The non-gate arm sanctions a caller reaching the binary and forbids only a second entry point into the emission path.
The process cwd is an input. At the repository root, every stage session’s case, the cwd equals the toplevel and nothing moves. From a subdirectory the entry succeeds, deriving the session id off the toplevel slug, the project directory transcripts are keyed on. Through the second caller no cd happens, so a harness’s sandbox cwd is untouched.
It is the sole production writer of <head> (§The state machine). The field is read as git rev-parse --short HEAD in the state file’s own work tree at the instant of the append, and is none where that yields nothing. It takes no enabling knob, the field being unconditional.
An ordinary stage writes the evidence file only. The appended stamp is the transition, since the last stamp is the cursor, so the queue file is untouched and need not be committed. The first stage, LIFECYCLE_KIT_FIRST_STAGE, performs the iteration-boundary reset instead.
Idempotent: if the state file already ends with a stamp for the same <iteration> <stage> <id>, the tool reports and exits 0 without appending, so a crashed-and-resumed session re-runs its entry step safely. It reads the kit’s knobs LIFECYCLE_KIT_QUEUE_FILE, LIFECYCLE_KIT_STATE_FILE, LIFECYCLE_KIT_STAGES, LIFECYCLE_KIT_FIRST_STAGE, LIFECYCLE_KIT_BOUNDARY_TRUNCATE, LIFECYCLE_KIT_BOUNDARY_PRESERVE, LIFECYCLE_KIT_BOUNDARY_REQUIRE, LIFECYCLE_KIT_LESSON_EVIDENCE_FILE, LIFECYCLE_KIT_SURVEY_RECORD_FILE, LIFECYCLE_KIT_WORKTREE_LOCK_PID_RE, LIFECYCLE_KIT_STAGE_JOURNAL_PATTERN, LIFECYCLE_KIT_STAGE_JOURNAL_REQUIRE, LIFECYCLE_KIT_ENTRY_PREFLIGHT, LIFECYCLE_KIT_PREFLIGHT_VALVE_FILE, LIFECYCLE_KIT_DISPATCH_MARKER_FILE, LIFECYCLE_KIT_AUDIT_ENTRY_STAGE and LIFECYCLE_KIT_WAIVER_TOKEN.
This arm depends on gate-sdk, and the dependency is stated rather than absorbed. It is reached through gate-sdk’s front-end, reads gate-sdk’s knobs beside its own, and dispatches through gate-sdk’s resolver. A tree that vendored lifecycle-kit without gate-sdk cannot run stage motion by any path. A consumer whose platform carries no artifact cannot enter a stage, cross an iteration boundary or rename an iteration. The loss is total but loud and immediate: the front-end’s unavailable diagnostic names the build command, and the failure lands at a session’s first step. A second dispatch resolver written in shell is the duplicate the shared substrate exists to remove, so the dependency is taken.
Advisory tooling, not a gate, so no fixture pair is owed. smoke/install.sh exercises it end to end, including the boundary require-check scenarios: a member naming the closing iteration passes, while a member missing the line, a member absent from disk and a never-named closing iteration each take their branch. The hermetic harnesses in gate-tests/ drive the arm through its second caller from a non-git sandbox.
The iteration boundary
The reset truncates the state file and every LIFECYCLE_KIT_BOUNDARY_TRUNCATE member back to its contract header, and restarts the queue header at the unnamed-iteration form. That header rewrite also drops a residual pre-upgrade [stage:] field, so a consumer that vendored the cursor extraction mid-iteration heals at its next boundary with no migration step. LIFECYCLE_KIT_BOUNDARY_TRUNCATE is a generic per-iteration reset knob, and the kit names no consumer surface in it. A downstream kit whose per-iteration file must start each cycle from its header adds itself, as evidence-kit’s manifest does. The kit-owned LIFECYCLE_KIT_LESSON_EVIDENCE_FILE and LIFECYCLE_KIT_SURVEY_RECORD_FILE (§The survey record) reset by the same rule as built-in members, riding no consumer knob; git history keeps what they held.
That written-surface set is also the source of the stamp-commit purity assertion’s exemption (§check-stage-evidence). The boundary reset is the one legitimate reason a stamp commit touches anything but the state file. So the assertion reads the members this reset already resolves, never a roster minted to describe it, and widening the reset cannot leave the assertion behind.
What counts as the header, for the truncate. The retained run is the member’s leading blank and # comment lines, stopping at the first data line or at a markdown ## heading, whichever comes first. The heading stop is load-bearing. On a surface whose blocks are ## headings, the survey record, a bare “keep every leading # line” rule keeps the first block’s heading as header. One stale survey then crosses the boundary, and the record’s own read trigger advertises it. That failure is silent under a green battery and invisible to any fixture that runs no real boundary entry, so gate-tests/ exercises the boundary end to end.
The reset also wipes the scratch dir (GATE_SDK_TMP_DIR). Every immediate child is deleted with everything below it, except .gitkeep, .gitignore, LIFECYCLE_KIT_LEAD_JOURNAL_FILE and each LIFECYCLE_KIT_BOUNDARY_PRESERVE entry. A spared child is not descended, so a spared directory is kept whole. The spare test is anchored at the scratch root, and below it nothing is spared: a nested .gitkeep, or a nested file whose basename equals a keep-list entry, goes like any other member. An unanchored test would let one nested .gitkeep keep its parent and every ancestor up to the root. Filtering the delete but not the descent would, once anchored, empty the very directory a consumer named in order to keep it.
The report names what the wipe did, in three notes. The first names the removed set, as the report names the truncated set. The second names each member whose removal failed (boundary-wipe could not remove from <scratch>: <paths>) rather than counting it wiped. The third names each keep-list entry containing /, which is no scratch-root name and spared nothing. All three are notes, never an abort or a refusal, because a config typo must not block the iteration boundary. A non-empty directory is either spared whole or fully descended, so a failed-removal line is real residue, such as a permission error or a concurrent writer.
Truncate and wipe share the trigger and the report line, and nothing else. Truncate rewrites a tracked file down to its # contract: header, so the file survives with an empty body. The wipe deletes untracked scratch members outright. So LIFECYCLE_KIT_BOUNDARY_PRESERVE is a keep-list for the delete and never a truncate target. The wipe runs last, after the truncate loop and after the tool removes its own temp files, so those are never candidates. It is boundary-only: an ordinary entry appends and wipes nothing. It is unconditional over the directory because scratch is disposable by definition, and a consumer’s persistent measurement lives outside it by retention contract.
Keep-list entries are root-relative basenames, never paths. A path grammar, with anchoring, .. or a trailing slash, answers a need no reader has. Every attested keep-list member is a root child, and a consumer keeping nested scaffolding names the root directory holding it, which the wipe keeps whole.
So scratch protects nothing, and verified-but-uncommitted work parked there has one copy. The wipe is the far end. The near end is a three-way merge dropping a scratch artifact mid-iteration with no conflict to notice, since the file is outside git’s view. A verified patch or analyzer parked in scratch has no second copy unless the session makes one, and the only two are a commit and a stash. The disposable-by-definition rule invites parking work there, and this corollary is not obvious from it.
.gitkeep and .gitignore are kit invariants, not configuration, and a consumer cannot unset either. The wipe spares the lead journal and the keep-list, so deleting the self-ignore (gate-sdk/SPEC.md §Layout and configuration) would expose those survivors as untracked until the next writer re-made it. And the kit must not delete a file the consumer tracks, which would dirty the tree just as the first-stage session commits the reset. The exemption is not about the directory surviving: the tool mkdir -ps it, and the wipe removes members only. It is generic mechanism, and a consumer that gitignores its scratch dir wholesale never exercises it.
Two other ways to ship that exemption are ruled out. As LIFECYCLE_KIT_BOUNDARY_PRESERVE’s default it fails because a defaulted array is replaced, not merged, when a consumer assigns it, so protection would fall as configuration grows. A git-aware “spare any tracked file” rule makes filesystem behavior git-dependent for one case. It would also spare any tracked file parked in scratch, re-opening the accumulation the wipe closes. The invariant’s honest limit follows the anchoring: a consumer tracking a .gitkeep below the scratch root loses it at the boundary unless it names that root child on the keep-list.
The lead journal is the second invariant, and it is a scalar knob for the reason .gitkeep is not a default. A lead session is live at the boundary, filing a boundary judgment there for the entering session’s intake (§templates/lead.md). Its journal has a live session’s lifetime rather than the iteration’s, and the kit instructing a lead to store state here must not delete it. The replaced-not-merged argument applies unchanged: an adopter assigning the array for its own reasons would silently drop a journal named there. A scalar default cannot drop a second value it never mentioned. LIFECYCLE_KIT_LEAD_JOURNAL_FILE exists only so a consumer who renames the file can say so.
Preserving is not draining, so the boundary entry reports an undisposed lead journal. When the journal exists, the entry splits it into segments at its ## lead-journal opened after <key> headings (§The lead-journal opener) and compares each key with the boundary key. That key is fields 2 through the last of the state file’s last stamp before this entry wrote, or none. It is read before the reset truncates the stamp it names.
- The last segment is live when its key equals the boundary key: it was opened after the stamp this boundary closes over, so it belongs to the lead dispatching this entry.
- Every other segment is prior. Each prior segment whose last non-empty line is not the disposition mark
DISPOSEDis reported: the advisory prints its##headings, its opening heading first, and proceeds. - A journal with no opening heading is a single prior segment, which is exactly the whole-file test.
This is the survey record’s read trigger reused (§The survey record): the same print over a different file, headings and never findings. DISPOSED is a presence marker on the DONE marker’s precedent, written by the lead at its close (§templates/lead.md). It carries no date and no author, since no reader would branch on either.
The advisory never refuses. The gap inbox refuses at a close-skipped boundary because its bullets are queue-shaped and drainable by the entering session, which may write the queue. A lead journal holds another session’s working prose, whose disposition the entering session lacks the context to judge. A refusal it could clear only by deleting the file would train deletion of the artifact the invariant protects, so the state is loud and never blocking. Its honest limit: the advisory can be read and ignored, and nothing forces the lead’s disposition step. It buys only that the undisposed state stops being silent. The live exemption trusts the heading. A lead that opens its journal by hand, or not at all, gets the whole-file test and a false advisory with it. A hand-written heading with a wrong key reads as prior, which is the safe direction.
The scratch dir has a second reclaimer, and the two do not overlap. context-kit’s session-context hook sweeps it at every session start, age-guarded so a concurrent same-checkout session’s in-flight scratch survives (context-kit/SPEC.md §The session-context hook). The boundary wipe is deliberately not age-guarded. It fires once, at the iteration transition, where any scratch meant to cross is named in LIFECYCLE_KIT_BOUNDARY_PRESERVE. An age guard there would leave the previous iteration’s fresh residue behind. Neither mechanism reads the other.
The boundary entry refuses on a non-empty ## Lessons Learned: exit 1, the untriaged entries printed, nothing written, the built-in pre-flight’s refusal contract. A queue keeping no such section passes the check. An untriaged lesson must not cross into the next iteration, so no [attend] injection (queue-kit §The queue-index arm) outlives the iteration that filed it.
Its gap-inbox check (LIFECYCLE_KIT_GAP_INBOX_FILE) is one detector with two dispositions (§The committed gap inbox owns the design). It fires on any bullet, then reads the closing-stage predicate to choose. A close-skipped boundary refuses on the Lessons check’s contract, so a gap the close skill did not drain cannot cross untriaged. A post-close one admits the entry and prints the bullets as a stderr advisory naming them the entering stage’s intake, since no stage of the closing iteration is coming back for them. An absent inbox has no bullets and passes either way.
The survey record, the gap inbox’s neighbour in this tool, deliberately does not refuse (§The survey record). It is truncated like any built-in member, and a non-empty one never blocks: a survey owes nobody a disposition, being scratch whose lifetime was the iteration just ending. The two surfaces sit one line apart with opposite semantics, so the asymmetry is stated. Every entry carries the record’s read trigger instead: when the record is non-empty, the report prints its ## headings. Those are the questions prior surveys answered, never their findings, since printing a possibly-stale judgment ahead of its witness is what the witness prevents. The trigger rides the tool every stage already invokes first, so it adds no invocation point and lands when a stage session is guaranteed to be looking.
The boundary entry refuses while any linked worktree stands: exit 1, each path printed, nothing written, gated by LIFECYCLE_KIT_BOUNDARY_WORKTREE_CHECK. The predicate is a property of the boundary, not a path: at an iteration boundary no linked worktree should be live. An in-flight dispatch must not straddle a boundary, and everything else is residue. So it fires on every git worktree list entry beyond the main checkout, and no knob names a residue directory, since a kit default spelling one harness’s layout would publish it. It reads git worktree list and never git status, because a gitignored worktree leaves the status clean while it stands. --simulate relays the would-be refusal, and a tree that is not a git checkout skips the check rather than failing on it.
Each linked worktree carries a liveness class, read off git’s own signal. A worktree held by a live process is locked, and git worktree list --porcelain prints the lock’s reason; the same string sits at .git/worktrees/<name>/locked. For at least one harness the reason is a liveness record naming a pid and that process’s start time. The start field equals the process’s /proc/<pid>/stat field 22, so the reason carries a PID-reuse guard. That record is strictly richer than the pid=<n> run=<key> grammar evidence-kit reads for backgrounded shell producers (evidence-kit/SPEC.md §check-producer-liveness).
The reason’s format is consumer vocabulary, disposed as the residue directory is. LIFECYCLE_KIT_WORKTREE_LOCK_PID_RE is a POSIX ERE with exactly one capture group in the admitted shape (§Layout and configuration), matched against a lock reason; the group is the pid that consumer’s harness writes there. The kit does not take that pid for the holder’s. A harness may lock with the pid of the process supervising its agents, and nothing in the reason tells the two apart. So the class states a fact about the captured pid and never about the holder. The default is empty, so an unconfigured consumer classifies nothing. The kit ships the mechanism, which reads the porcelain, applies the pattern and probes the pid. The consumer ships the pattern, the split check-graph takes over its vocabulary (gate-sdk/SPEC.md §The provenance seam).
| observation | class |
|---|---|
| locked, reason matches, captured pid alive | live |
| locked, reason matches, captured pid dead | orphaned |
| locked, reason does not match the pattern | unclassified |
| not locked at all | orphaned |
With the pattern unset every linked worktree is unclassified, an unlocked one included. An empty pattern means no classification is configured, not a pattern matching everything, which keeps the default additive.
Liveness is evidence-kit’s producer-liveness probe, never a second predicate: the probe the .run path uses, so how liveness is decided stays settled in one place (evidence-kit/SPEC.md §check-producer-liveness).
The capture is gate-sdk/SPEC.md §The POSIX ERE matcher’s one-group item, compiled once per run. One interpreter judges and matches the pattern, and that item’s differential oracle holds its agreement with bash’s =~. The table validator’s fail-closed config check (§The stage-machine adapters) refuses a pattern outside the admitted shape, so the classifier never meets a pattern whose capture cannot be read.
One capture group, not two. The start-time field is matched and deliberately not captured. Parity is one ground: the .run grammar carries no start-time guard, and capturing one here would make the worktree path stricter than the record path for no stated reason. The error direction decides it. A stranded worktree whose pid was reused classifies live, and a live class refuses and authorises nothing by itself, so dropping the guard errs toward refusing. That safety depends on what reads the class. Once anything reaps on the classification instead of printing it, the argument inverts, which is why the strengthening is filed rather than banked.
That ground holds only while the captured pid is the holder’s, and the condition can fail. A pattern may match a reason whose pid names the process supervising a harness’s agents. Every locked path then reads live for as long as the entering session runs, and errs toward refusing becomes refuses until the reader gives up. The classification and the one-group decision stand, since a start-time guard would not tell a supervisor’s pid from a holder’s either. What the condition bounds is the reading of a live class, which is why that class’s guidance is a loss-gated remedy rather than a wait. A boundary can be fail-closed against losing work and open to deadlock at once, and refusing buys only the first.
The refusal set does not narrow; the class changes the remedy. Both classes refuse: a live worktree because an in-flight dispatch must not straddle the boundary, an orphaned one because residue is cleared before it is crossed. The refusal prints one line per path carrying its class:
- live — the lock reason’s pid is printed beside the loss report. The guidance leads with what a live reading does not establish: that an agent still holds the path. A supervisor’s pid outlives every child and lives as long as the entering session, so wait for the named pid ends only when the waiting session exits, a deadlock dressed as a conservative refusal. The holder is established from the dispatch the session knows is in flight. Where none is, the path is residue and takes the loss-gated reap: unlock, then an unforced
remove, then the branch ref. A force-removal is actively wrong here and is not offered. - orphaned — the holder is gone, so the lock states a fact that has become false. This is the class force-removal exists for, and it is named only here. Git requires
--forcetwice to remove a locked worktree; once suffices only for an unlocked dirty one. - unclassified — the generic reap guidance, which is the whole behaviour an unconfigured consumer sees.
The live class’s reap is gated on the loss report, because the two halves of the guard differ. An unforced remove refuses on its own on a modified or untracked file, which is git’s guard and needs no classification. A clean worktree whose branch carries commits removes silently at exit 0, and the branch delete then takes those commits. So the loss report alone carries the commit half, and the remedy waits on that report saying lossless, never on the absence of a git complaint.
The reap stays a session act. A worktree can hold commits existing nowhere else, so a wipe is destructive rather than merely wasteful. Liveness answers is anyone working here; it does not answer would removing this lose anything. What a session lacked at a residue firing was not a reap, but anything telling it there was residue.
The loss question is answered mechanically. For every path, the live class included, the refusal reports two facts read at the moment of refusal. One is whether the worktree’s tree is dirty, the other how many commits its HEAD carries that the main checkout’s HEAD cannot reach. It says so plainly when a path is clean and commitless, where removal is lossless by construction. That costs two git reads and no vendor vocabulary. No help line beside it teaches the hand read, git status --porcelain inside each worktree, because two lines teaching two ways to learn one fact is residue.
Away from the boundary the same scan runs as an advisory. Every other stage entry reads the worktrees and reports orphaned paths only, with the same loss report and reap guidance. It is a stderr advisory that never refuses and never suppresses the stamp, though a repository git refuses exits 2 first (gate-sdk/SPEC.md §The crate’s crosser). The class is what makes it safe. Mid-iteration, in-flight dispatches are the normal state, so an unclassified report would cry wolf at every entry or push a session to refuse valid work. A live worktree goes unreported, as no entering session can act on it. An unset pattern emits no advisory.
Two bounds stay open. First, surfacing is not sweeping: residue is surfaced at every stage entry, so accumulation within an iteration is visible rather than prevented, and the reap remains a session act. Second, the check cannot see a dangling branch ref whose worktree is already gone. git worktree list does not report one, and the branch-name pattern that would is one harness’s vocabulary. So the guidance names the branch half, and the reaping session removes the ref with the directory.
No gate reads any of this. check-stage-entry’s assertions do not cover it, and the boundary-precondition family has deliberately never had a gate sibling (§check-stage-entry states the predecessor-map omission for the same class of reason). A gate over tree state cannot see a worktree at all. A reap in the dispatch guard is refused on delegation-kit’s grounds (delegation-kit/SPEC.md §The delegation model).
The boundary entry refuses when a LIFECYCLE_KIT_BOUNDARY_REQUIRE member lacks a disposition line for the closing iteration: exit 1, nothing written. Each member must carry a data line whose first token is the closing iteration’s name. A consumer wiring its release-disposition evidence here makes the close-stage disposition a mechanical boundary precondition rather than a decorative stamp. The check is value-agnostic by construction. It tests the first token and never parses the value field, so a disposition grammar gaining a value, as with deferred:<version> (§templates/stages/), needs no widening here.
The check is fail-closed: a member absent from disk is a refusal naming the path. A never-named (—) closing iteration has nothing to disposition and skips the check. --simulate relays the would-be refusal. The check runs after the Lessons refusal and before the truncation, so a member that is also a LIFECYCLE_KIT_BOUNDARY_TRUNCATE file is verified by the boundary that then consumes it.
The entry pre-flight and its valve
Before writing, the entry runs its pre-flight: the built-in check-stage-entry for the entered stage, plus each LIFECYCLE_KIT_ENTRY_PREFLIGHT command whose stage key matches. Any red refuses, with exit 1, the findings printed and nothing written. It is pre-flight, not enforcement. The refusal is advisory in the sense the gate is at commit time, and it has no --force, so the easy path is the compliant one. Only the LIFECYCLE_KIT_ENTRY_PREFLIGHT half is conditional: the one-shot valve below admits a single entry past it. The valve is no --force. It is armed in a committed file, carries a mandatory written reason, and valves one arm rather than the tool.
The hand-off keeps the <queue> <state> argv, and the candidate rides a temp state file. The cursor is the last stamp, so the candidate transition lives in a temp state file under ${GATE_SDK_TMP_DIR:-.tmp} carrying the not-yet-written stamp. The live queue passes through untouched, except that the boundary reset, which rewrites the header, passes a temp queue as well.
The pre-flight gates are dispatched as child processes, never called in process. Both arms name a gate and never a substrate. The member is resolved across the kit checks/ directories, a .sh before a .gate in each, and a .gate becomes the argv <binary> <name>, run under this arm’s environment unchanged. A member that resolves nowhere is exit 2 with the dispatcher’s own diagnostic, never an entry pre-flighted by a check that did not run. The arm spawns even though both gates live in the same binary: the compiled dispatcher refuses the in-process call on fault isolation and the surviving .sh members. The resolution set is the kit’s own checks/ directories, never the consumer’s gates directory, since the kit alone declares these two members, and a .sh beside a .gate in one directory still shadows it.
LIFECYCLE_KIT_ENTRY_PREFLIGHT is a generic per-stage hook, naming no consumer surface. A downstream kit whose gate is the real precondition for a stage wires itself here, as evidence-kit’s manifest gate does for close entry. That turns a would-be pre-commit deadlock into a loud refusal at the entry.
Each entry’s <command> is split on whitespace and exec’d as argv, with no interpreter word prepended. The configured path rides its own exec bit, so a consumer wires a gate’s invocation and not its declaration file. A path that stops being executable, such as a member ported to a binary substrate leaving a data-file descriptor behind, is a stage-entry breakage rather than a stale reference. The <queue> <state> pair is appended to the end of that argv on every matching entry, whether or not the entry carries arguments of its own. A command whose grammar consumes its tail, such as a selector reading a list of names, takes the pair as two more of its own arguments. Such a front end needs a separator its grammar honours, wired unconditionally. The kit fixes only that the pair is appended.
The one-shot pre-flight valve admits one entry past a refusing pre-flight command. LIFECYCLE_KIT_PREFLIGHT_VALVE_FILE names a committed valve ledger. Its default is the empty string, so no valve exists, every pre-flight refusal is final, and an unconfigured consumer sees exactly the behaviour above. The ledger takes the grammar this kit’s evidence files use, a # contract: pointer header and then one data line per arming:
<iteration> <stage> armed|used <reason...>
<reason> runs to end of line and is mandatory: a line without one is not a weaker arming but no arming. It is free text authored by the consumer’s own session, and the kit specifies only that it be non-empty. There is no date field. The ledger is truncated at the boundary, so every line belongs to one iteration, and the state file already dates its stages; a date would be a drift-capable second copy. The iteration matched is the queue header’s, not the stamp’s, since the first stage’s stamp carries the unnamed placeholder rather than a name any arming could be written against.
An armed line admits the entry. When a LIFECYCLE_KIT_ENTRY_PREFLIGHT command refuses and the ledger carries an armed line for the entering iteration and stage, the entry is admitted instead. That line’s state token is rewritten armed → used. The admission is loud. The report relays the pre-flight command’s own findings, the text the refusal would have printed, and says the valve admitted them. It prints the reason, and how many used lines this iteration carried before this one. The entry then proceeds and stamps as normal.
The valve exists for exactly one deadlock: a stage whose entry pre-flight is refused by a precondition only a later stage can clear, so the stage chartered to clear it is the stage being refused. Reaching for it twice in one iteration is the failure, not a supported mode. A valve gets reached for whenever the refused stage is inconvenient. The answer is a count at the moment of the act, not a prohibition. The admission report prints the iteration’s prior used count, so a second reach announces itself in the reaching session’s own transcript before it proceeds. The predecessor-journal escape takes the same shape: an evadable assertion that makes the deviation deliberate and written. The refusal’s help: line names the valve with that single sanctioned cause, so it cannot read as a generic bypass.
The honest limit: a session can arm its own valve. The valve is bypassable exactly as the journal escape is. What it buys is that a bypass leaves a committed artifact with a written reason, a named obligation on the closing stage (§templates/stages/), and a count that makes the second one visible.
Four narrowings, each a consequence of an existing ruling rather than a carve-out.
- This arm only. The valve does not reach the built-in
check-stage-entrypre-flight, which asserts the state machine’s own stamp-protocol invariants, nor the predecessor-journal assertion, which has its own escape. Nor does it reach any boundary refusal: Lessons, gap inbox, linked worktree orLIFECYCLE_KIT_BOUNDARY_REQUIRE, each guarding against work leaking across a boundary and each stating a recovery.LIFECYCLE_KIT_ENTRY_PREFLIGHTis the consumer-wired arm, the only precondition whose deadlock a consumer can reach at all. - One line per admission. Only the first matching
armedline is consumed. One entry consumes at most one, however many of its stage’s commands refused, because the valve admits the entry, not a command. Arming twice does not admit twice, and the second line stays for the closing stage to see. - Iteration and stage must match. An arming aimed at another stage or left from another iteration never admits. Both halves are asserted although the boundary truncation should make the cross-iteration case unreachable. That truncation is a knob a consumer may decline to set, and an assertion resting on another consumer’s configuration is not an assertion.
- The idempotent no-op consumes nothing. A re-entry whose stamp is already the last line exits 0 before the pre-flight runs, so a crashed-and-resumed session cannot spend a second line on one transition.
Two fail-closed refusals, at exit 2 with nothing written: a data line with fewer than four whitespace-separated fields, and a state token that is neither armed nor used. An unparsable ledger makes is it armed? unanswerable, and both silent branches are wrong: admitting hides a malformed arming, refusing hides a valid one. The ledger is read only where that question is asked, at a pre-flight refusal, so a malformed ledger cannot wedge entries that never needed a valve.
A configured path that does not exist is not armed, not an error. Header-only is the ledger’s resting state, and requiring the file would oblige every consumer setting the knob to create one. The failure direction is then a refusal, which is the safe one. So a typo’d path cannot pass for a never-armed valve, the refusal message names the configured path and says which case holds: absent, or present with no matching arming.
Two writers, and the split is contract. The arming session writes every line (§templates/stages/). This tool writes none: it rewrites the state token of a line that already exists. So the line set is the arming session’s alone, and this tool can only narrow what is admissible, which makes the two-writer surface safe. The rewrite rides the write, not the match. The boundary refusals run after the pre-flight loop, so a matched entry can still refuse, and a refused entry must neither report an admission nor spend a line.
A consumer wiring the knob owes its ledger a close-surface: declaration (§The close-surface roster). The ledger is tracked, so check-close-surfaces’s undeclared-surface arm, whose second source is the workflow directory’s gitignored members, never reaches it. Undeclared, it would be missing from the roster close’s inbound-triage sweep recomputes. No reclaim= field is owed, that being a capture-tier member’s obligation. The kit names no path: the ledger’s location is consumer configuration, and the knob’s empty default holds that seam.
The stage journals
The tool opens the entering stage’s journal and reports the path it wrote, under the LIFECYCLE_KIT_STAGE_JOURNAL_REQUIRE gate the assertion below takes, so at the kit default a consumer without the feature gains no file. The opening line is the stamp’s own five fields, with no new field anywhere. The printed path is the entering session’s one source for it, and the note beside it names the arm that appends there (§The journal arm). It is the anchored path (§The stage-machine adapters) and never the pattern’s relative spelling, which names another file to a raw append from a subdirectory. So the stage template’s last step names the journal the entry tool named rather than restating a knob-driven derivation on every stage surface (§templates/stages/). A session that writes nothing still leaves a file naming who owed it and when, so the escape below is an append under a header, not a record invented from nothing. Three behaviours are pinned:
- It appends where a journal exists, and never overwrites. A stage may run several sessions that share one file (§The state machine), so a second session’s entry appends a heading naming itself. That mechanizes the section’s rule that the discriminator belongs in a heading.
- It writes nothing under
--simulate, whose contract is to run everything up to the write and write nothing. - It writes after the boundary wipe, not before. The first stage of an iteration truncates the state file and then wipes the scratch dir, and a skeleton written ahead of the wipe would be deleted by it silently.
The predecessor-journal assertion runs in the same pre-flight, gated by LIFECYCLE_KIT_STAGE_JOURNAL_REQUIRE and defaulting off. At 1, an entry refuses when the stage the cursor names left no journal at its derived path (§The state machine). It exits 1 with the expected path printed and nothing written, the boundary-precondition family’s contract, and --simulate relays the would-be refusal. Both inputs are already read here: the cursor, and the pattern expanded against it. Three narrowings, each a consequence of an existing ruling:
- Written content, never the
DONEmarker. delegation-kit rules the marker redundant wherever the supervisor consumed the agent’s return, reserving it for a cold read. A stage entry is no cold read, the entering session existing because the previous stage returned. So asserting the marker would mint an obligation its owning section retires at this transition. The predicate is that the journal carries a line the opener did not write: neither blank nor an opening line. Writer and reader share one spelling of that line’s shape in the crate, and a bare non-emptiness test would pass on every skeleton the tool opens. The message tells three states apart: absent, where the tool never ran for that stage; unwritten, where it ran and the session did not, the file empty or carrying opening lines only; and written. - The in-iteration predecessor only. The boundary reset wipes the scratch dir at the first stage’s entry, and
LIFECYCLE_KIT_BOUNDARY_PRESERVEdoes not keep stage journals. So the first stage has no predecessor journal by construction and is never asserted against. A supervising session’s journal is a legitimate keep-list member (§templates/lead.md) and no stage journal, so keeping one leaves this construction untouched. - Stages only. A supervising session’s journal has no stamp, so the cursor cannot name it, and a second roster to reach it is a surface this assertion did not buy.
The refusal is evadable, and that is the point. Its help text names one way forward: write the missing journal yourself, stating that the predecessor left none. An oracle over an artifact the asserted-against session can itself create is bypassable by definition. What it buys is that a journal’s absence becomes deliberate and written instead of silent, the defect being that nobody knew a session had not journaled. The escape also keeps the refusal off the deadlock class: with no reachable exit, it would wedge the lifecycle behind a session that has already ended. The opener strengthens that ground. The file’s opening line already names the session that owed the journal and when, and the escape clears an unwritten journal as it clears an absent one.
Default 0, and the switch is thrown at a boundary rather than mid-iteration. REQUIRE=1 asserts against the predecessor. Enabling it inside a running iteration lands it under stages dispatched before the rule existed, and puts the first enforced firing mid-iteration. At a boundary, a refusal costs one stage re-entry instead of a wedge.
The simulate mode
--simulate <stage> is the read-only pre-flight mode. It runs everything a real entry runs up to the write, in the order a real entry runs it:
- config load, stage validation, header parse and session-id derivation;
- the idempotence probe, a would-be no-op reported as such at exit 0;
- the candidate-stamp temp state build,
check-stage-entryand every matchingLIFECYCLE_KIT_ENTRY_PREFLIGHTentry; - where one of those refuses, the valve lookup, which names the line a real entry would consume and its reason, leaves the ledger byte-identical, and holds exit 0 because the real entry would proceed;
- the predecessor-journal assertion;
- at an iteration boundary, the Lessons check, the gap-inbox check, the linked-worktree check and every
LIFECYCLE_KIT_BOUNDARY_REQUIREmember.
Then it stops: no stamp, no truncation, the temp files removed. The roster is stated in full because one that stops short of the refusals the code runs reads as a guarantee the mode does not give. Every output line is prefixed enter-stage (simulate):, so a transcript never reads as a stamp. Exit 0 means the real entry would proceed or no-op, and 1 that it would refuse, the refusing check’s output relayed line by line. Exit 2 is a usage or configuration error, as for a real entry.
Every refusal’s recovery is relayed too, as the mode’s contract. A refusal’s help: line is its actionable half (§The committed gap inbox), so each prints under --simulate, prefixed like every simulate line. Relaying a verdict while withholding the line that resolves it hands the reader the refusal and keeps the recovery. The mode’s designed consumer is the lead (§templates/lead.md). It runs the mode before every dispatch, as a step and not an option, rather than hand-deriving prior-stage completeness. So a would-be refusal naming a missing predecessor journal is relayed while the owing session is most likely resumable. That step is gating, never liveness: it composes with the lead’s dispatch precondition and never substitutes for it.
The gap-inbox check’s post-close disposition reports the branch it would take: that the entry would proceed, carrying how many bullets into the entering stage’s intake. “Would refuse” and “would proceed carrying work” answer the lead’s question differently. The mode is not a gate. smoke/ exercises it beside the other enter-stage coverage: would-pass, would-refuse, would-no-op, and nothing written.
The rename mode
--rename <name> is the mechanized iteration rename. Naming an iteration writes two surfaces, the queue header’s placeholder and column 1 of the first stage’s stamp. check-stage-evidence requires them to agree, so the rename rides this writer. [--simulate] --rename <name> sets the header to ## Iteration: <name> and rewrites column 1 of every data line in the state file. Every line is correct by the boundary invariant, not by convenience. The first-stage entry truncates the state file, so every line below the separator belongs to the current iteration, as check-stage-evidence asserts, and the whole-file rewrite also heals a half-landed hand-rename.
It is not stage motion. No stamp is appended and no stage token is written, so the cursor is untouched and “stage motion never writes the queue” stands. The boundary reset is the precedent for this tool writing the queue header at all.
Before writing, the mode asserts its columns-2-to-last witness. Fields 2 through NF of every data line, which are the stage, session id, date, head and any field a later grammar appends, must be identical before and after. It reads to the end of the line, not a pinned column, since a field riding outside the witness could be dropped by the rename unnoticed, and a column-5 check would re-hardcode the arity. The writer holds this predicate, where it is cheap and exact, rather than the Write/Edit guard. A PreToolUse hook sees only proposed content, and would have to reconstruct the pre-edit file to diff it, with no clean way to refuse.
The rename is pre-flighted like the stamp path. The candidate header and state file are built as temporaries and check-stage-evidence runs against them. A non-zero exit refuses with the gate’s output relayed and nothing written. That gate is resolved through gate-sdk’s gate_command, never named by script path, so the arm names a gate and never a substrate. The resolved argv is prefix-shaped, so the two positionals ride it unchanged. A gate the resolver cannot find is exit 2, the dispatcher’s own verdict, never a rename pre-flighted by a check that did not run.
Refusals, at exit 2 with nothing written:
<name>empty;<name>equal to the unnamed placeholder, which only the boundary reset may write, checked ahead of the grammar that would also reject it so the message names the owning writer;<name>outside the queue slug grammar[a-z0-9][a-z0-9-]*. Whitespace is the corrupting case: column 1 is whitespace-delimited, so a two-word name silently shifts every field of every stamp.
Idempotent in the stamp path’s sense: a header and every column 1 already reading <name> reports and exits 0 without writing. --simulate --rename relays what would change, prefixed enter-stage (simulate):, and writes nothing. The report names both written files and says to commit them together, making the one-commit coupling a property of the writer. Its subject: line is chore(<stage>): name the iteration <name>. There <stage> is the stage of the last stamp the rewrite changed, the stamp check-stamp-subject reads off the commit. That is the first stage whenever the rename runs before any later stamp exists, as it does.
A rename invalidates one reader, and that is why un-naming is refused. LIFECYCLE_KIT_BOUNDARY_REQUIRE’s check matches the closing iteration’s name against a disposition line’s first token and reds on finding none. A rename landing after the close stage stamped its disposition therefore reds the next boundary. Renaming a dispositioned iteration is a rename to redo at the disposition surface too, and un-naming one is refused outright.
A separate rename tool is ruled out. It would be a second sanctioned writer of the state file, and one writer is the property the Write/Edit guard’s block message asserts. Like --simulate the mode is advisory tooling owing no fixture pair. Its hermetic cases live in gate-tests/, since a rename cannot run against a live checkout’s own queue the way smoke/ runs the stamp path. They cover both surfaces rewritten, the witness held, the half-landed heal, each refusal, the idempotent no-op, and --simulate --rename writing nothing.
The lead-journal opener
--open-lead-journal is the lead’s journal opener. [--simulate] --open-lead-journal appends one heading to the lead journal (<scratch>/$LIFECYCLE_KIT_LEAD_JOURNAL_FILE), creating the file and its directory when absent:
## lead-journal opened after <key>
<key> is fields 2 through the last of the state file’s last stamp, or the literal none when the file carries no stamp. The heading opens a segment, which runs to the next such heading or to the end of the file. Content before the first heading is a segment as well, so a journal written before any opener ran reads as one segment with no key. A blank-only run is no segment.
- Append, never overwrite, on the stage-journal opener’s precedent. An undisposed prior segment is what the boundary advisory reports, and an overwrite would delete it before any reader ran.
- Disposed segments are dropped. Before appending, the opener removes every segment whose last non-empty line is
DISPOSED, keeping every other segment in order. A disposed segment has been discharged by the lead that wrote it, so no reader remains for it, and dropping it keeps the protected file from becoming an accumulator. The file is rewritten only when a segment is dropped; otherwise the heading is a plain append. - Idempotent. When the file’s last segment already opens with the heading for the current key and is undisposed, the opener reports and exits 0 without writing, which covers a resumed lead re-running its first step. A disposed last segment under the same key is dropped and reopened like any other: the no-op serves a lead still writing, and a lead that disposed has stopped. Honest limit: a second lead opening at the same cursor, after the first ended without disposing, continues the first lead’s segment. The opener cannot tell a resumed lead from a replacing one. Either way the segment belongs to the iteration about to open, the property the advisory reads.
- Not stage motion. No stamp is appended, no pre-flight runs, the queue is untouched, and nothing tracked is written.
--simulate --open-lead-journalrelays what it would write and drop, prefixedenter-stage (simulate):, and writes nothing. - Exit contract: 0 opened, or a reported no-op; 2 a usage or configuration error, an absent state file included, on the stamp path’s precedent. Surplus arguments after the operand are refused by the surplus rule. It has no 1, because it refuses nothing a caller could clear.
The key derivation is one function the opener and the boundary advisory both call. The heading lead is one constant beside DISPOSED, spelled once for the reason the stage journal’s opening mark is: the writer and the reader of the key must agree byte for byte.
The opener keys on the stage cursor, and the entry tool writes the heading. The cursor is the last stamp (§The state machine), so a journal opened after it was opened for the iteration about to open. The stamp is unique by construction, carrying a session id and a head. The key is fields 2 through the last, not the whole line, because --rename rewrites column 1 of every stamp, the same witness --rename asserts. A hand-written key would mean the lead copying a line out of the state file, which §templates/lead.md forbids and which drifts by a single space. The opener is an operand of this tool because the tool already owns the lead journal’s knob, its disposition mark and the advisory, so the key derivation has one home.
Advisory tooling like --simulate, so no fixture pair is owed. The hermetic cases live in gate-tests/: creation, an append after an undisposed segment, the disposed-segment drop, the idempotent no-op, --simulate --open-lead-journal writing nothing, the surplus refusal and the none key. The key’s survival of a rename is a unit test beside the rename witness’s.
The dispatch marker
--dispatch <stage> is the lead’s declaration that a stage session is starting. It runs the --simulate <stage> pre-flight and, only when that would proceed, appends one line <stage> to the dispatch marker (<scratch>/$LIFECYCLE_KIT_DISPATCH_MARKER_FILE), creating it when absent. A refusal writes nothing and exits 1 with the simulate’s output, so the one command both gates the dispatch and declares it. A usage or configuration error is exit 2, as on every form.
--dispatch <stage> --waive <reason…> also declares an audit waiver the user ruled. The arguments after --waive are joined with single spaces into the reason, which is mandatory. The simulate then runs against a candidate state carrying the waiver line ahead of the candidate stamp. So assertion C reads the declared waiver, and every other assertion runs exactly as a plain dispatch runs it. On a clear verdict the marker line is <stage> <waiver-token> <reason>. The form is refused at exit 2, with nothing written, when:
<stage>is notLIFECYCLE_KIT_AUDIT_ENTRY_STAGE, the stage whose entry assertion C gates;- the waiver token is empty, as on a roster with no audit stage;
- the reason is empty, or spans a line break.
The arm cannot tell who ran it, the honest limit the pre-flight valve states, so a session can declare its own waiver. What the form buys is that the declaring act is separate from the entry, named in the marker, and carried with its reason into the stamp commit.
Every stamp discharges one marker line. A marker line names its stage in its first field. Every stamp this tool writes, the boundary reset’s included, removes one line naming the stamped stage, preferring a waiver-bearing line to a bare one. It deletes the file once it is empty, and its report notes the discharge. A marker with no line naming the stamped stage is left as it is. --dispatch-withdraw <stage> removes one line naming <stage>, whichever it is, for a dispatched session that ended without entering. It is a reported no-op when there is none.
A discharged waiver is recorded in the state file. Where the removed line bears a waiver and the state file carries no waiver line for the current iteration, the entry writes <iter> <waiver-token> <id> <date> <head> immediately before its stamp, in the same append. <id> is the entering session’s, the recorder’s, and the entry’s pre-flight candidate carries both lines. The report prints the waiver with its reason. A body: line after subject: carries the reason into the stamp commit’s body, since the state line has five fields and no room for one. A refused entry writes neither line and leaves the marker as it was. A waiver line is not a stamp, so the cursor, check-stamp-subject and the purity assertion read the entry as they read a plain one.
The marker is lifecycle-kit’s scratch, like the lead journal, and never lifecycle state. Neither form writes anything tracked, and no stage reads the marker for the cursor. The entry reads a waiver-bearing line as the dispatcher’s declaration and records it, and check-dispatch-entry reads the marker for one commit-time question. Its hermetic cases live in gate-tests/dispatch-marker.test.sh.
The dispatcher declares the waiver, and the entry writes it. Assertion C’s waiver needs a writer the state file’s guards admit. The workflow-state guard blocks a Write/Edit of the file, the shell guard blocks a shell append to a tracked file, and --rewrite refuses it. The dispatcher is the party holding the operator’s words: the lead under the split posture, relaying a direction, or with no lead the session the operator instructs. --dispatch is already its sanctioned, non-stamping act on scratch, so the lead still writes no lifecycle state. This tool stays the state file’s one writer. The entering session is structurally the recorder and never the issuer, since the entry form takes no waiver of its own.
Two other routes are refused. A waiver operand on the entry itself would make the entering session the issuer, the self-issuing assertion C forbids, in the one form no reader could tell from a self-grant. A pending waiver carried into the entry commit names a carrier and no writer, and its line would still have to reach the file past the guards.
The journal arm
run-gates.sh --emit journal [--] "<text>" appends its operand to the resume journal of the stage the calling session entered, and with no operand appends its standard input. A stage session journals without spelling the path: the entry tool derived it once (§The state machine), and this arm derives it again from the caller’s own stamp. A second form, --emit journal --lead [--] "<text>", appends to the lead journal instead, the file a supervising session opened and no stamp names. It is a flag that selects a form on --foreign-resume <key> --close’s precedent, so it adds no arm-table row and no front-end case arm.
- Which journal. The caller’s id is §bin/session-id.sh’s derivation, the one its entry stamped. Where no stamp carries the derived id, the arm walks the candidates of the scan that derivation read, newest first, and takes the first id a stamp carries. A session that has dispatched a child derives the child’s id while the child’s transcript is the newest, and its own is the next one stamped. A derivation answered from the environment read no scan and takes no walk.
--emit session-idis unchanged, since an entering session has no stamp to be found by. Its stage is its last stamp’s stage, the read the workflow-state guard’s third rule makes (§check-dispatch-entry). The path is the journal-path derivation over that stage (§The stage-machine adapters). The stage is never the cursor’s: a session resumed after its successor entered still owes its own stage’s journal. - The write. The text lands verbatim in one append, standard input byte for byte and never decoded, a closing newline added where it lacks one, and the file and its directory are created when absent. Nothing is prefixed: the heading the opener wrote already names the session (§bin/enter-stage.sh), and
DONEmust be able to stand as the last line (delegation-kit/SPEC.md §Resume journal — agent writes, scratch reset sweeps). - Stdout is one line,
journal: <path> <stage> <id>, the anchored path and the id the one whose stamp named the journal. The caller reads the stage and id against its own stamp, because the derivation’s newest-transcript race is this arm’s too. - Refusals, exit 2, nothing written. An empty text is refused, one of whitespace alone included, since the entry assertion’s written predicate would read it as nothing. So are a second operand and an unrecognized leading
-, with--ending option processing (gate-sdk/SPEC.md §The bin/-tool contract). A derived id no stamp carries, with no stamped id down its scan, is refused with a message naming the derived id and the remedy,--enter-stage <stage>first, an absent state file included. A linked worktree is refused on--emit file-gap’s ground, that a line written there never reaches the main checkout (§The committed gap inbox). LIFECYCLE_KIT_STAGE_JOURNAL_REQUIREis not read. That knob gates the opener and the predecessor assertion; a session calling the arm has asked for the file.- The lead form.
--leadis recognized only as the first argument, so a text that begins with it is passed after--. The file is<scratch>/$LIFECYCLE_KIT_LEAD_JOURNAL_FILE, the one--enter-stage --open-lead-journalopens (§bin/enter-stage.sh), at the journal-path derivation’s anchor. The knob names it, so no stamp is read, no id is derived and no walk is taken, and the newest-transcript race does not reach the form. The write and the argv and worktree refusals are the stage form’s. Stdout isjournal: <path> lead, with no id field since none was derived. Two refusals are its own, each exit 2 with nothing written and each naming the remedy,--enter-stage --open-lead-journalfirst: an absent journal file, which the form never creates, and a last segment that is disposed, its last non-empty line beingDISPOSED. A line appended after the mark would turn a discharged segment back into an undisposed one that the opener then keeps and the boundary advisory then reports. The disposed read is the segment reader the opener and the boundary advisory share, called a third time rather than copied.
Its declared roster is LIFECYCLE_KIT_STATE_FILE, LIFECYCLE_KIT_STAGES and LIFECYCLE_KIT_STAGE_JOURNAL_PATTERN, and for the lead form LIFECYCLE_KIT_LEAD_JOURNAL_FILE and GATE_SDK_TMP_DIR. The state file and the journal take the journal-path derivation’s anchor (§The stage-machine adapters), which is the one the capture arms anchor theirs at. It is advisory tooling, so no fixture pair is owed; the hermetic cases live in gate-tests/journal-arm.test.sh. The raw append stays a legal fallback for both forms, the contract being the file’s and not the writer’s. The path the entry printed is the anchored one, so the append names the stage journal from any working directory (§bin/enter-stage.sh).
Honest limits. A session whose derived id is a sibling’s writes where that sibling’s stamp points, a live child that is itself stamped included. For a same-stage sibling that is the same file, the shared journal by design. For a resumed session racing a live successor it is the successor’s file, which the printed line shows and the raw append avoids. The walk cannot tell a stamped caller behind a live child from a caller that never entered: an unstamped session whose scan holds a stamped sibling writes where the newest such stamp points, which the printed line shows too. The lead form’s flag is the caller’s claim to be the supervising session, and nothing checks it: a stage session that passes --lead writes the lead’s file. The raw append it replaces checked nothing either, and an identity read would bring back the race the form avoids, a lead with a live child deriving the child’s id. The opener resolves the scratch dir against its working directory and the lead form at the repository root (§The stage-machine adapters), so a lead that opened its journal from a subdirectory meets the absent-file refusal there.
The stage-contract arm
run-gates.sh --emit stage-contract <stage> prints a stage’s contract for a reader that loads no skill and holds no write path: a foreign coding agent in a read-only sandbox, which can make none of a stage’s three writes, the stamp, the journal and the commit. It moves a stage’s reading onto such a reader and leaves every write with the stage session that called it (§The host protocol).
- The document is the frame, one separator line naming the stage, then the consumer’s stage skill,
<LIFECYCLE_KIT_SKILLS_DIR>/<stage>.md, verbatim. A binding shim’s directive names its template by a repo-relative path the reader opens in its clone, and a copied-and-specialized skill stands alone, so one rule carries both adoption modes (§templates/stages/) and the arm resolves no template. - The frame is the file
LIFECYCLE_KIT_STAGE_CONTRACT_FRAMEnames, shipped astemplates/frames/stage-contract.md. It is instruction only. It tells its reader that it executes the contract below on behalf of a session holding the write path, in a clone at a committed revision. It writes nothing and skips the contract’s first and last steps, returning every other write as a proposal. It runs only commands that change nothing, naming one its sandbox refuses or one needing a built artifact rather than working around it. And it orders the report’s parts: findings with their evidence and whether each was measured or inferred, proposed writes, questions, what was not done, and a verdict on the exit condition. - The kit parses no report. The parts are for the calling session’s reading, as delegation-kit classifies no report (delegation-kit/SPEC.md §The foreign-vendor run).
- Exit 2, nothing printed: a
<stage>outsideLIFECYCLE_KIT_STAGES, an absent skill file, an absent or empty frame file, a missing or surplus operand. - It emits for any configured stage. Which stage’s reading is worth sending is the dispatcher’s judgment, and the kit instructs every stage whose template points at the host protocol (§The host protocol).
It is an Arm::Emit member on --emit stage-rules’ precedent (doctrine-kit/SPEC.md §stage-rules): its contract is a document, and each failure is exit 2. Its declared roster is LIFECYCLE_KIT_STAGES, LIFECYCLE_KIT_SKILLS_DIR and LIFECYCLE_KIT_STAGE_CONTRACT_FRAME, both paths at the journal-path derivation’s anchor (§The stage-machine adapters). It is advisory tooling, so no fixture pair is owed; its cases are crate tests beside the arm, and one hands the document composed for each stage that admits the host protocol to an audit-mode foreign run under a stub adapter (delegation-kit/SPEC.md §Testing).
An arm, where a caller could concatenate two files. A prompt composed by hand per dispatch is a second spelling of the contract that nothing tests, and the per-stage executor binding (LIFECYCLE_KIT_STAGE_EXECUTOR, §Layout and configuration) has one derivation for every bound stage’s host to call.
Honest limit. The frame is an instruction and confines nothing. A reader that writes is caught afterwards by the foreign run’s shape check as a refusal, never prevented, and whether a vendor’s sandbox lets the contract’s read-only commands run is that vendor’s.
The host protocol
A stage whose reading runs foreign is held by a host: a session of that stage whose dispatch names a foreign adapter. The host enters, stamps and holds the stage as any session does. It hands the stage’s contract to the foreign agent as an audit-mode run of --emit stage-contract’s document (§The stage-contract arm) and performs itself every write the returned report proposes. The host is a stage session and never the lead, because the session that performs the state writes writes the stamp, and a lead writes no lifecycle state (§The stamp protocol). So nothing in the state machine learns a new writer. A run that fails or is refused returns nothing to build on, and the host executes the contract itself. Which adapter the reading rides is the dispatcher’s pick by name, and a consumer may bind a stage’s pick in LIFECYCLE_KIT_STAGE_EXECUTOR (§Layout and configuration).
The steps are one file, templates/host-protocol.md, worded for whichever stage the reading session entered. A stage template that admits the protocol carries one line pointing at the file and restates none of it. The file is load-triggered: a session reads it only when its dispatch names an adapter, so a stage that runs on the master harness pays nothing for it.
The prompt carries nothing read from a file the repository ignores. The host appends to the emitted document what its dispatch gave it that varies, and no more. The clone already withholds ignored content from the foreign agent (delegation-kit/SPEC.md §The foreign-vendor run, The tree), and a host that pasted a private brief into the prompt would hand a vendor what the clone was built to keep back.
The host runs every command the report names as refused or as needing a built artifact, its stage’s gates among them. The frame has the foreign reader name each one rather than work around it (§The stage-contract arm), and the clone carries no built gate binary.
A stage admits the protocol when its work separates into a reading and writes a session can land from a report. The frame returns every write as a proposal carrying its full text, which the host checks and lands. Authoring, auditing, surveying and reviewing separate that way. The build stage’s work does not: its writes are proved by running what they produce, a loop that needs the write path and a built artifact inside it, and a reader that returns code as report text gives the host the whole build to redo. So of the shipped stage templates the build stage’s alone carries no pointer line.
What a foreign reading cannot reach, at any admitted stage:
- An oracle needing a built artifact, the gate binary first. The host runs it, so a stage that is mostly oracle-running returns mostly a command list, and a consumer weighs that before sending one.
- Content the repository ignores or has not committed. A stage whose reading rests on a private surface gets a reading without it, and the host supplies that part.
- A remote act: a push, a release, an authenticated read of a hosting service. The first two are writes and the third is the sandbox’s to refuse; each is the host’s.
The stamp says who held the stage, and the commit message says who read. The stamp grammar is unchanged: the id is the host’s, and the tier verdict a stamp id resolves to is the host’s model. The foreign reading is on record in the host’s journal and the landing commit’s message alone, each carrying the run’s verdict line. A host is tiered by what its writes demand, templates/lead.md §Economics’ rule for every batch: a report whose proposals the host can land after an oracle run is mechanical work, and one the host must turn into authored spec text is not.
bin/install-lifecycle.sh
bash gate-sdk/bin/run-gates.sh --install-lifecycle [agent-file] idempotently writes the resident registration block into the always-loaded agent file, LIFECYCLE_KIT_AGENT_FILE (default CLAUDE.md). The positional override points a smoke or fixture at a scratch tree without touching consumer config. The heading is a section name, not a file name, and the citations in this SPEC resolve against it.
The block sits between fixed marker lines, <!-- lifecycle-kit:begin --> and <!-- lifecycle-kit:end -->. A run replaces the content between them when present and appends the block when absent, so re-running never duplicates. A begin marker without its end is a malformed target, exit 2 rather than a guess at the bounds. The arm edits an always-loaded file and never mints one, so a missing agent file is exit 2 too. The insert and replace ride gate-sdk’s marker-bounded span writer (gate-sdk/SPEC.md §lib/inject.sh), so no second replace path exists to drift.
It is an Arm::Run arm-table member (gate-sdk/SPEC.md §The non-gate arm), reached by its own bin/run-gates.sh front-end branch rather than the --emit <name> composer. Its contract is an action with an exit status: it mutates two files and writes one git config key, narrating on stdout, and Arm::Emit collapses every error to 2.
It is not an op of the --install <op> family, on that family’s own terms. installer/SPEC.md §The install boundary rules that that arm reads no kit config and no knob, its caller being the bootstrap, which may not be a POSIX shell. This member renders blocks from resolved kit config, so a knob-free op would need all nine knobs on argv from a caller unable to resolve them. The name collision makes this the first place a reader looks.
Its declared roster is the union of what the two renderers read, taken from the gates that already declare those knobs, nine in all:
- from
check-lifecycle-registration,LIFECYCLE_KIT_AGENT_FILE,LIFECYCLE_KIT_STAGESandLIFECYCLE_KIT_QUEUE_FILE; - from
check-merge-attrs,LIFECYCLE_KIT_STATE_FILE,LIFECYCLE_KIT_LESSON_EVIDENCE_FILE,LIFECYCLE_KIT_SURVEY_RECORD_FILE,LIFECYCLE_KIT_BOUNDARY_TRUNCATE,LIFECYCLE_KIT_GAP_INBOX_FILEandLIFECYCLE_KIT_CONSULT_INBOX_FILE.
A hardcoded top-level flag would have no table row to declare them on, so no check reading the declared rosters could see what it reads.
The [agent-file] positional is the file the rule writes into, read from the arm’s own argv and overriding the resolved default. §The non-gate arm’s distinguishing test makes an argument unportable only when it selects where configuration comes from, which the tree’s knob files already answer. The near miss is an env var: smoke/install.sh runs the arm under LIFECYCLE_KIT_KNOB_FILE=lock-stages.knobs, a genuine config-file selector. It works because the arm resolves the kit’s knobs in process from its own environment, so the redirection happens inside the resolution.
The block is pointer-only, and its roster is derived. crate::stages::registration_block renders three things. They are the line naming LIFECYCLE_KIT_QUEUE_FILE as the file the repo runs the state machine on, the stage roster as skill invocations (/<stage> per LIFECYCLE_KIT_STAGES member), and the markdown link to the kit SPEC. It renders no stage prose and no hand-listed roster, so a reshaped stage set flows into the block by construction. The arm and check-lifecycle-registration share that renderer, so the emitted block and the certified block cannot diverge.
The same run performs two further steps for the multi-operator merge surface (§Multi-operator semantics).
- The merge-attribute step injects a marker-bounded block (
# lifecycle-kit:merge:begin…# lifecycle-kit:merge:end, the same installer writer) into the repo-root.gitattributes, rendered fromcrate::stages::merge_attrs_block. It carries onemerge=iteration-scopedline per supersede member (keep-ours) and onemerge=unionline per union member (the two inboxes, git-native). A reshaped set flows into the lines by construction, andcheck-merge-attrscertifies the same rendering from the same two set derivations. Unlike the agent file, the arm mints.gitattributeswhen absent, since it is no always-loaded file the consumer authored. The two adjacent writes keep opposite absent-file dispositions. - The driver-config step registers the keep-ours driver per clone,
git config merge.iteration-scoped.driver true, in the--install-hooksopt-in class. A non-repo cwd or a repository git refuses degrades to a printed skip naming its cause, never a hard failure, leaving the attribute inert until a clone installs the driver. The skip goes to stderr and the two action lines to stdout. That split is load-bearing: the arm is machine-drivable, and a finding on stdout is a finding in a caller’s data stream. The union attribute needs no such step, sincemerge=unionis live the moment.gitattributescarries it.
The front-end requires a repository. bash gate-sdk/bin/run-gates.sh cds to git rev-parse --show-toplevel and refuses outside a repository (gate-sdk/SPEC.md §run-gates), so a non-repo cwd exits 2 with nothing written. That is correct rather than a narrowing. Both install targets are repo-root-relative by this kit’s config, so a run outside a repository would write two files into whatever directory the caller stood in. The driver-config step keeps its own non-repo skip, which the front-end’s refusal leaves unreachable through it.
Advisory tooling, not a gate, so no fixture pair is owed. smoke/install.sh, the member’s only caller, exercises every step end to end. No platform residual bites at adoption: a host the artifact roster does not cover is refused at the bootstrap (installer/SPEC.md §Hosts refused at the bootstrap). So no vendored consumer is left holding the registration block and merge attributes with no writer, and check-lifecycle-registration and check-merge-attrs, compiled too, never face a host that cannot run them.
The close-surfaces emit arm
Prints the derived close-surface roster (§The close-surface roster), one row per surface, tab-separated <path> <mode> <reclaim> <owner> <state> <tracking>, sorted by path. A field with nothing declared is -; an owner-less row is a capture surface source 2 found with no declaration, whose mode reads (undeclared). The mode is echoed verbatim — a malformed one is passed through for check-close-surfaces to rule on, so the derivation never silently repairs what the gate exists to catch. An empty roster prints nothing and succeeds: a resolved-empty derivation is an answer, never an error.
<state> is the row’s on-disk state under the computed base. A file row reads absent (no such file), empty or non-empty. It reads empty when the file holds nothing past the header run §bin/enter-stage.sh’s truncate keeps, so a surface truncated to its # contract: header reads as drained; the arm and the truncate share one predicate in the stage module. A file row also reads non-empty when its drain companions hold content. A <file>#<section> row reads absent when its file is, else -. A section’s emptiness is its owner’s read (the Lessons row is forced by the boundary refusal, the Deferred row is never empty), and resolving an anchor-form heading would give this arm a heading resolver it needs nowhere else.
The state is three named states, not a byte size, on drift-kit/SPEC.md §The knowledge-friction loop’s precedent: a size is a number whose only consumer is this question, and a header-only file has a non-zero one. Close reads it twice: the triage sweep states empty as a clean read and takes absent as a finding, and the gap drain’s first read skips both.
<tracking> is the row’s file as git sees it under the computed base: tracked when the index holds it, ignored when git check-ignore matches it (the capture tier), and untracked otherwise. A <file>#<section> row reads its file’s value. It is computed once per row by two batched git calls over the roster’s file paths, and a git call that cannot decide is the arm’s existing exit-2 cause. It takes three values because a path can be neither in the index nor gitignored, a declared file that does not exist yet, and two values would answer one reader wrongly for it. Two readers ask two questions of it:
check-close-surfacesassertion C readsignored.- A session delegating a row’s read takes anything but
trackedas not delegable to an isolated agent, which sees only committed state.
<state> and <tracking> are appended columns, so the four positions every reader indexes stay put.
A non-gate arm (gate-sdk/SPEC.md §The non-gate arm), invoked as bash gate-sdk/bin/run-gates.sh --emit close-surfaces [scan-root]. Its two callers are check-close-surfaces, which reaches the derivation in process rather than spawning anything, and close, at its gap drain’s first read and its inbound-triage sweep. Nothing stores the roster: it is recomputed at the moment close reads it, so a capture surface added yesterday appears today.
The declaration surfaces are each resolved kit root’s LIFECYCLE_KIT_ROSTER_BASENAME file and its carried close-surfaces.txt, each where present, plus every LIFECYCLE_KIT_CLOSE_SURFACE_GLOBS match, resolved consumer-first with kit shadowing like every kit registry; duplicate surfaces collapse. A row’s <owner> is the surface it was read from, so a vendored tree’s kit rows name the carried file. No kit root in the authoring tree holds one, since the packer writes it into the assembly alone, so no declaration is read twice. The arm follows the affordance contract, with the cd a compiled arm cannot take becoming a computed base: the scan-root argument, else the repo toplevel, every path globbed, ignored and printed relative to it. Config is via env through the knob files. Three exit-2 causes are the arm’s error return, which the front-end and the in-process caller both surface: a non-repo base, an unreadable declaration surface, and a git ls-files or git check-ignore that could not decide. It is advisory and owes no fixture pair: the gate below is what blocks, and an arm returning a document has no pass and no fail to fixture (§The non-gate arm).
The sort is by path, with the whole row as its tie-break, and that is observable. One path may be declared on two surfaces, a duplication the derivation does not collapse: it collapses duplicate surfaces, not duplicate declarations. A path-only sort would leave the tie order at the sorter’s discretion and churn the gate’s error order for no edit.
The ruling-staleness probe
A reporting arm, --emit ruling-staleness, over a consumer’s ruling record. It renders a document whose exit carries no verdict a caller branches on: it escalates to a reader rather than gating an act. Like the roster arm above it owes no good/bad fixture pair, on §The non-gate arm’s ground that an arm returning a document has no pass and no fail to fixture. It resolves three knobs (§Layout and configuration) and reads nothing else. With LIFECYCLE_KIT_RULING_RECORD empty it reports that it is unconfigured and returns, so close’s repair step skips rather than fails for a consumer keeping no such record.
Two declarations are its whole ruling grammar. Both are body lines in the record, hand-written under judgment by the session recording or retiring a ruling. Neither is a tag: a tag marks a move across a pending/ready boundary and these mark none, in the further-tag test’s own words.
ruling: <name>[ <name>…]
discharge: <name> <oracle>
The operand separator is a two-space run in both grammars, stated in words because the fence cannot show it. A ruling’s name carries single spaces by construction, being the noun other surfaces use, so only a doubled space can end one. A ruling: list written with single spaces parses as one long name rather than a list. discharge: falls back to a single space when no doubled one is present, a convenience for a one-word slug and not a second grammar. A multi-word name written that way takes the fallback and surrenders its own tail to the oracle. Neither mistake reports, since both parse, which is why the run is spelled here and in the malformed band’s messages.
ruling: names the ruling in the words other surfaces use for it: one or more names, appended and never rewritten. It serves the citing side, because a stale citation rarely points at a missing section. In a measured cohort of eleven stale citations every one still resolved, since the heading survived and only the body under it was rewritten. A probe over citation targets reaches none of them. The invariant across all eleven was the ruling’s proper noun in a present-tense claim anchored to no file. A twelfth site survived its repair by writing the noun one word differently, so one declared name is insufficient by construction and the field takes a list.
discharge: is for a ruling conditioned on a future event, and it declares an oracle, a command, never a predicate the kit interprets. A condition language is refused on a structural fact. Every machine-parsed conditional in this kit’s neighbourhood resolves against one domain, a queue’s live/done partition, because a queue entry has a slug and a pool with an exit and a ruling has neither. A condition grammar for rulings would mint the first non-queue condition domain. It would also have to express what records carry: a CI leg producing an artifact, an owed count reaching zero, a metric read across three closes, a release channel flipping.
The oracle form reuses §The survey record’s rule instead: a carried finding is a citation with a falsifiable staleness witness, never a substitute for the oracle. A discharge condition is that shape read forward. The ruling names the command, and the session that would rely on the ruling runs it. The kit dispatches and reports, and interprets nothing.
The oracle’s contract is the exit status and one line of output, in three bands, and the third band is why a broken oracle can never read as a fired condition:
- not fired — a non-zero exit, or a clean run printing nothing. The ruling stands.
- fired — a clean run that prints. The ruling becomes a retirement candidate a session must judge under the record’s mood test, never a retirement the arm performs.
- dispatch failure — the command could not be run, or exceeded
LIFECYCLE_KIT_RULING_ORACLE_TIMEOUT. Reported as such and joined to neither other band.
Two oracle shapes sit in the not-fired band while their condition holds, and neither reports.
- An absence test spelled
grep -L <x> <file>prints the file and exits non-zero under GNU grep when<x>is absent, so it reads as not fired exactly when it should fire. Spell absence asgrep -q <x> <file> || echo <evidence>. Check it throughbash -c, the arm’s own dispatch, because an interactive shell may resolvegrepto an implementation whose-Lexits zero. - A substring test for a slug is satisfied by any live citation of that slug. A discharge keyed on an entry leaving its section anchors the match to the line that move writes, a bare done line under
grep -x, never the slug anywhere in the file.
A condition no command can settle takes the literal operand manual, followed by the prose condition. A record may carry a ruling whose condition resolves only against an untracked local surface no tracked oracle can reach, which a probe scoped to tracked surfaces would silently skip. manual makes it report as owed to judgment: a probe that knows what it cannot answer, rather than one that answers wrongly.
Two reports, one arm.
- The discharge report is one row per declared condition: the ruling’s name, the oracle’s band, the declaration’s own site, and for a fired one the oracle’s output line as the evidence. A
manualrow carries its prose. - The citing report is, per subject ruling, the sites across
LIFECYCLE_KIT_RULING_CITERSnaming it. Each citing line is quoted verbatim, so a reader judges the claim rather than the match. A ruling’sruling:names are the search keys, all of them. A subject the record declares no names for is swept under its own spelling alone, the no-retrofit decision showing through.
The subject set is the fired conditions plus the arm’s argv tail. The tail reaches a retirement the record does not yet carry. A session asking what would go stale asks before it writes, so no artifact records the retirement yet; the session names the ruling, and the arm answers. With no operand the report covers the fired conditions only, which is the close-stage step’s reading.
Run bare after a retirement has landed, the citing report is vacuous and reads as clean. A retired paragraph fires nothing, so the subject set is empty just when a session most wants the citing sites the retirement stranded. The tail answers that case as well, and what to pass is the ruling’s names. The declared-name lookup reads the live record, so a slug that has just left it resolves to nothing, and each operand degrades to a literal search over the corpus.
Escalation-only, and the refusal is the design. The arm proposes no edit, retires nothing, and never says a citation is wrong. That answers this kit’s standing objection to mechanizing this class (§The committed gap inbox): a claim about what a finding is has no syntactic tell. A surface may name a retired ruling as evidence about the past, which is correct prose, or restate it as a live rule, which is the defect, and no scanner tells them apart. In the eleven-site cohort itself, one site recorded a live ruling under the retired run’s name, and the remedy was to re-name the run and leave the ruling untouched. A probe blind to that distinction proposes reversals, the one act the record holds operator-class throughout.
An undeclared condition is reported, not inherited. A ruling whose prose names a future event and which carries no discharge: line reports as undeclared. That is the closure §The close-surface roster takes for a capture surface nobody declared: the roster reports the hole instead of inheriting it. Without it the no-retrofit decision would be a silent hole. A record whose rulings name discharge events in prose, none machine-readable, would report one condition and look complete once a single declaration landed. The detection is deliberately weak. It is a prose match over forward-looking phrasing, false-positive-bearing by construction, the posture a forward-precondition scan takes. It reports and never reds, and a false positive costs a reader one line.
A paragraph stating the record’s contract takes a valve, which excuses it from this pass alone. A record that opens by defining what a ruling is matches the detector, since the definition carries a ruling’s forward phrasing. That paragraph takes one line, anywhere inside it:
<!-- undeclared-condition-exempt: <why this paragraph is no ruling> -->
The reason is mandatory. A valve with no reason reports in the malformed band and does not excuse its paragraph, because a malformed declaration must not buy the skip it failed to justify. The valve reaches the undeclared pass only. It declares no ruling, so the discharge report, the citing report and the ruling: name lookup never read it. It is per paragraph, the pass’s unit. The line is found by the reading the two declarations get, the fence skip and a leading-whitespace trim, so a valve quoted inside a fence, like the one above, excuses nothing.
Structural answers are refused. Skipping a record’s preamble by heading position would couple the probe to the record’s shape and silently drop a real ruling placed there. Narrowing the detector’s phrases is the evasion §The committed gap inbox refuses. The valve puts the fact on the record, where the paragraph’s author can state it.
A malformed declaration reports as malformed, and never as absent. Both declarations and the valve are hand-written, each with a likeliest slip: a discharge: naming a ruling and no oracle, a ruling: whose name list is empty, a valve with no reason. A reader that only parses would drop these, or honour the valve, rather than report them. Composed, that is worse than writing nothing: the undeclared pass skips a paragraph on the presence of a discharge: line, so a typo would suppress the report naming the ruling. So the arm carries a fourth band, malformed: one row per unreadable declaration, with its site and what the grammar wanted. The undeclared pass reads well-formed declarations only.
A paragraph whose declaration cannot be read reports under both bands, correctly rather than duplicatively. They answer this line is unreadable and this ruling has no readable condition, and repairing the first must leave the second true. This is a validity read over a written declaration, so it neither takes nor reopens the refusal of a presence gate demanding one.
The producer/checker split is the one the port track runs on. A declaration reports, and a separate reader escalates when the named thing changes state. That pairing is why a stale hold cannot silently under-count owed work.
check-close-surfaces
Four assertions, over the derived roster. (A) No undeclared surface: every capture-tier workflow-dir member carries a declaration. (B) Every declaration carries a mode, and a forced= mode’s citation is well-formed, a repo-relative <path>.md followed by §<section>. (C) Every declaration whose <tracking> reads ignored names a reclaim command. (D) That reclaim rotates: its last tokens are capture-drain, an optional --, and the row’s own path.
Assertion D is gate-sdk/SPEC.md §The workflow directory’s rotation rule applied to the roster. Every row is a surface close reads before it reclaims, so a truncating reclaim erases whatever a live session appended between the read and the truncate. The positive form is the check because a truncation has many spellings and the rotation has one.
Assertion B is shape-only, because resolution is already check-spec-pointer’s: its prose pass resolves a forced= citation on any manifest surface (§The close-surface roster). canon-kit’s heading resolver is a private function of its compiled gate, not a library export, so no second gate can call it. Reaching it would mean copying the resolver, which canon-kit’s tiering rule bans, or making lifecycle-kit depend on canon-kit, the ownership cycle that also rules out the log merge. So a pair of gates reads one surface independently, each asserting what it owns.
The gate reads the roster by calling the derivation in process (§The close-surfaces emit arm), which makes “the derivation and the gate can never disagree” structural rather than conventional. A roster it could not derive is fail-closed (exit 2), as is an unreadable declaration surface. The gate splits the row’s fifth and sixth fields off, reading <state> as nothing and <tracking> for assertion C; a four-way split would fold both into the owner of every error line. Assertion C calls no git check-ignore of its own, so the emitter and the gate cannot disagree about which rows are capture-tier.
The gate never reds on absent: a capture-tier surface is gitignored and legitimately absent in CI and a fresh clone, so an absence is a closing session’s finding, never a tree violation. The descriptor carries a source coupling: the gate module and the emit module it reaches transitively join couples= beside the surfaces. Without it the gate stays registered and green, and never fires on the edit that broke it.
An optional scan-root argument, passed to the derivation as its base, is the fixture capability. git check-ignore never reports a tracked path, and a fixture file must be tracked to survive a clone, so a case dir cannot hold a capture-tier member. The good/+bad/ pair therefore covers assertion B on a consumer-set LIFECYCLE_KIT_CLOSE_SURFACE_GLOBS. gate-tests/check-close-surfaces.test.sh builds sandbox repos with a real .gitignore for assertions A, C and D, for a declared log’s drain companions folding into its row, and for a vendored kit root read through its carried declarations, on the check-exec-bit precedent. Both move with the member across a substrate change: the pair is keyed by gate name, and the scenario runner dispatches by name through gate_run (§Testing). Tier precommit; the # graph: manifest couples the workflow dir, the declaration surfaces and the two crate modules.
The four assertions carry assertion A: through assertion D: markers in the implementing member’s source, in its substrate’s comment leader. check-gate-assertions set-matches the enumeration above against them and reds on an empty set, so a port that drops them reds.
Calibration and honest limit: the gate asserts the roster is complete and moded, never that close read a surface. Reading is a session act with no mechanical residue short of a per-surface disposition stamp, a stamp-per-surface-per-iteration ritual this design does not take. The marking converts an invisible omission into a sanctioned one, where a stamp would make it a gated one. The lighter disposition comes first because an advisory surface’s skip is often correct, and gating a correct action is the failure the gap-inbox refusal demonstrates. The un-gateable half rides the Enforcement-first carve-out’s cadence, as a class on the consumer’s close-stage audit roster (§The audit roster).
check-lifecycle-registration
Invariant: the configured agent file (LIFECYCLE_KIT_AGENT_FILE) carries a lifecycle-kit marker block whose inner content byte-matches the block regenerated from the live stage machine (stages::registration_block). A reshaped machine (a renamed or reordered stage, a relocated queue file) or a hand-edit stales the block by construction, on the exact path the kit advertises: reshape the config, re-run the installer. That drift-prone generated surface is where the enforcement-first weighing owes a gate. The freshness posture is check-doctrine-registration’s, byte-strict like check-docs-mirror-fresh.
A missing block is a finding with the install remedy, and a block out of lockstep is a finding printing the diff and the regenerate remedy (both exit 1). The stale-block report prints every hunk, in the format and uncapped contract of the crate renderer (gate-sdk/SPEC.md §The diff renderer), whose one live consumer this gate is. Resolution fails closed: a missing agent file, a begin marker without its end, or an errored marker capture is exit 2, since a half-written or unreadable target must not pass as clean.
The four gate-sdk contracts (gate-sdk/SPEC.md §The gate model):
- Output: the single
LIFECYCLE-REGISTRATION: cleanline and ahelp:remedy on each finding path. - Fail-closed: exit 2 on an unreadable target.
- Fixture pair: a
good/+bad/pair undergate-tests/(byte-lockstep-clean and stale-block), plus a sibling*.test.shfor the block-absent, unpaired-marker and agent-missing cases one pair cannot hold. - Self-lint: registration in this repo’s
gates.list, where its own always-loaded file is the scan target.
The positional form check-lifecycle-registration.sh [agent-file] points the fixtures at a synthetic agent file. Its # graph: manifest couples the agent file, the kit’s table in native/src/knobs/lifecycle_kit.rs and this repo’s scripts/lifecycle-config.knobs, the config feeding the block, so an edit to any re-fires the gate.
check-stage-evidence
Invariant: the evidence file’s stamps are well-formed and every one of them belongs to the header’s iteration. Each stage skill appends a stamp as its first step, and the last stamp is the current stage. So “the current stage has a matching stamp” holds by construction, and the gate does not assert it, a tautology being no invariant. It asserts the name-axis agreement between the two surfaces through the staleness assertion: every data line’s iteration must be the header’s. That assertion forces the first stage to rewrite its — bootstrap stamp once it names the iteration. It is the sole remaining enforcer of header↔stamp agreement, not a bystander to it.
A state file that exists but carries no stamp is red, in the missing-file message’s shape: the gate’s one no-cursor ruling. The window is legitimate only inside --enter-stage’s boundary reset, which stamps in the same motion. By commit time an unstamped file means no stage was ever invoked, which is what this gate rejects, and an empty file gives the grammar and staleness passes nothing to reject.
Every data line must be well-formed:
- exactly five fields;
- the stage in the configured stage set, or the waiver token, which is a stamp token but never a header stage;
- the date
YYYY-MM-DD; <head>the literalnoneor a lowercase hex abbreviation of 7 to 40 characters (§The state machine owns the field).
Every data line’s iteration must be the current one, so a stale line from a prior iteration is rejected. The — unnamed-iteration sentinel may appear only while the header itself is unnamed.
Different stages may not share one session id within the current iteration. This holds at the default stage posture of LIFECYCLE_KIT_SESSION_BOUNDARY (§Layout and configuration). A stage entry is a context boundary and demands a fresh session, so a duplicate such as build == validate is a self-reported skip and fails. Same-stage re-entries are in-contract: a multi-session build, or a lead’s sibling batch sessions of one stage (§The state machine), may share or rotate ids freely. This section is the home of the rule the gate’s distinctness message echoes. Waiver-token stamps are never a stage, so they are exempt from the map. At the iteration posture the gate skips only this distinctness map. Grammar, staleness and sentinel scoping hold identically, and a reused session id stays on the audit trail but stops failing the gate.
Calibration: — is the bootstrap name for a new iteration before the first stage names it. Any stage past the first carrying — in the header is rejected. This unnamed-iteration guard reads the name axis from the header and the stage axis from the cursor, and works only because the two stay independently sourced. Admitting — at every stage would let an unnamed iteration reach the final stage undetected, so the allowance is stage-scoped. The data section begins after the first --- separator, and prose above it is not validated line by line.
Argument mode $1 $2 (queue, state), with configured defaults, makes the gate fixture-capable. gate-tests/check-stage-evidence.test.sh covers the sentinel-scoping interplay one good/bad pair cannot. It also exercises the two assertions below, each needing a real git history no static fixture pair can carry.
The stamp-provenance assertion. A stamp proves the stage skill was invoked, and this assertion makes it prove the invocation came first. For every newly introduced stamp inside a git work tree, <head> must name HEAD. The recorded abbreviation, at least seven characters, must be a prefix of the full HEAD sha, so a consumer’s core.abbrev never enters the contract.
A stamp is newly introduced when no data line in HEAD’s version of the state file carries the same <session-id> <head> pair. Identity is that pair, not the whole line, because --enter-stage --rename rewrites column 1 of every data line and must not read as re-introducing them. The migration clause applies that reasoning to the format’s one other bulk rewrite. A HEAD-version line carrying only four fields matches any working-tree line with the same <session-id>, so the one-time five-field migration (§The state machine) is a rewrite and not an introduction. Without it the migration commit, and every consumer’s own rewrite, would red against an assertion no historical stamp can satisfy. The clause is self-retiring: four-field lines vanish once migrated, and the boundary reset truncates the file within one iteration.
What it catches, with no history walk and no per-stage surface roster. A session that stamps first and commits the stamp after its work commits has moved HEAD between the write and the commit. The recorded head is stale, and the gate reds naming both commits. Same-stage re-entry is in reach by shape, not by exemption: a second session’s stamp records its own HEAD and is committed on it, and the assertion never compares two stamps to each other.
Four inertness conditions, all stated, because an unstated one reads as coverage. The assertion does not run when
- (a) git answers that the state file’s directory lies in no work tree, or in a different one from the configured surfaces, such as a vendored tree under test or a sandbox. A repository git refuses, or a dead
git, is no such answer and exits 2 (gate-sdk/SPEC.md §The crate’s crosser); - (b) the file handed to the gate is not this work tree’s own configured state file, its repo-root-relative path not being
LIFECYCLE_KIT_STATE_FILE. Being inside a work tree is not the discriminator: agate-tests/fixture tree sits inside the repo, and firing there would assert a recorded head against a history the fixture’s stamps were never taken in. This also keeps the argument mode fixture-capable at no cost to the assertion; - (c) the state file is not tracked in
HEAD, since there is then no prior version to diff against and “newly introduced” is unanswerable rather than false, as at a consumer’s first state-file commit; - (d) no stamp is newly introduced, which is every battery run that stamps nothing.
On the live file, none on a newly introduced stamp is red, which keeps the inertness from being a disarm. The residual is git rm --cached of the state file, which would restore condition (c): the same class as --no-verify, and closed by neither.
The recovery names the delete, because of how newly introduced is defined. Every stamp this assertion reports is uncommitted, since a line already in HEAD’s version carries a matching pair and is skipped. So re-running --enter-stage alone never clears the red. The arm appends, the fresh line lands beside the stale one, and the assertion scans every data line, so it reds on the stale one again with a byte-identical message. The recovery is delete the stale line, then re-enter and commit the fresh stamp on its own. The delete loses nothing, because the line was never committed. A help text prescribing only the re-run sends a session round a loop with no exit.
The purity assertion’s recovery is its own, because one help text serving both classes is wrong for whichever it was not written for. A surplus staged path is unstaged and committed separately, the stamp itself being sound.
The stamp-commit purity assertion. The provenance assertion alone is defeated by a session writing the stamp and its work into one commit, since HEAD has not moved. The complement closes that. Where the state file is among the staged paths and introduces a stamp, the staged path set must contain only:
- the state file, for any stage’s stamp; and additionally
LIFECYCLE_KIT_PREFLIGHT_VALVE_FILE, for any stage’s stamp, because an admitting entry rewrites the valve ledger’s state token in the same motion as the stamp (§bin/enter-stage.sh); and additionally- the queue file,
LIFECYCLE_KIT_LESSON_EVIDENCE_FILE,LIFECYCLE_KIT_SURVEY_RECORD_FILE, the members ofLIFECYCLE_KIT_BOUNDARY_TRUNCATE, andLIFECYCLE_KIT_GAP_INBOX_FILE, for the first stage’s stamp only, because the iteration-boundary reset legitimately writes all of them in one motion (§bin/enter-stage.sh).
The set is one predicate: the paths --enter-stage writes at this entry. Every member derives from that, and none is minted. The boundary-reset members are scoped to the first stage, the only entry that writes them. The valve ledger is scoped to no stage, because its scoping rides membership rather than a stage name. A non-admitting entry leaves the ledger untouched and unstaged, so the exemption is reachable only at the one branch that earns it. Widening the set can only remove violations and narrowing it can only add them, so a config change here is safe to reason about by inspection.
Gating on the staged set, not the working tree, keeps a sibling session’s staged file out of this verdict, since a stamp nothing is committing is not one this commit introduces. The assertion mechanizes the sentence every stage template carries, commit the stamp on its own. That sentence’s one exception, an admitted entry committing the valve ledger with its stamp, is this set’s second bullet rather than a weakening of the rule.
The concurrent-session false fire is real and its remedy is cheap. Where a repo shares its git index between sessions, a sibling committing between a stamp’s write and its commit moves HEAD and reds the stamp. The remedy is to re-run --enter-stage, which appends a fresh stamp at the current HEAD. That is a same-stage re-entry, in-contract, and cheaper than any weakening that would admit what the assertion catches. The tool’s idempotence guard reads the head for this reason (§The state machine).
Honest limit: the stamp proves the stage skill was invoked, never that it produced its green result. A validate stamp says validate ran, not that the suites passed. evidence-kit closes that gap. It commits a per-run evidence manifest, a suite verdict per line, and through the optional LIFECYCLE_KIT_BOUNDARY_TRUNCATE integration couples a close entry to the full green block.
One ordering case neither assertion reaches, structurally rather than by a gap in their design. A session does its work, commits it, and only then runs --enter-stage. The stamp legitimately records the post-work HEAD, the stamp commit is pure, both assertions pass, and the work still preceded the mark. Between stage X-1’s stamp and stage X’s lie X-1’s legitimate work and X’s illegitimate pre-stamp work, and nothing in the tree separates them. Only a declared boundary can, and two candidates were weighed:
- a per-stage output-surface roster, refused because a stage’s own output surfaces are not derivable from the tree, a build stage’s being approximately everything, and a configured roster false-fires when coarse and misses when narrow;
- an exit mark, which this kit does not have and which no reading of an entry stamp can supply.
The declared boundary is the dispatch. A lead declares each stage session it starts, and check-dispatch-entry reds a commit landing under an undischarged declaration, which closes the case wherever a lead dispatches. A session run with no lead declares nothing, and there the case stays as stated.
The assertions do refute that every entry-time assertion is satisfiable retroactively. The recorded head is bound to a commit that already existed when the stamp was written. A stamp introduced later is checked against the HEAD of its own introduction. So re-satisfying it means re-stamping, which yields a fresh, honest stamp rather than a forged one.
The uncommitted window. This gate, and every other that would catch a hand-written stamp, fires at commit. The cursor, though, is the working tree’s last stamp, and every reader reads the working tree. So a session that hand-edits the state file and never commits moves the stage cursor for its entire life and is caught by nothing. Content-based detection narrows this without closing it. The <head> field makes a hand-written stamp name the HEAD it claims, but a hand reading git rev-parse --short HEAD writes an identical line, and an uncommitted stamp is never checked at all.
The workflow-state-guard harness-integration arm closes the part of that window agent tooling passes through. It is registered as the binary GATE_SDK_NATIVE_BIN names under <project-dir>, as command, with --hook workflow-state-guard as args (the anchor and the exec form are guard-kit/SPEC.md §The shell guard’s). It is a PreToolUse(Write|Edit) hook on the alternation matcher from guard-kit’s wiring template. It refuses a write whose target resolves to the state file, naming --enter-stage as the sanctioned writer. Resolved, not textual: an absolute path, a ./ prefix and a route through a symlinked directory are one file, and a textual comparison catches only the spelling it was written against. It advises or blocks through the channels every harness-integration arm uses (gate-sdk/SPEC.md §The non-gate arm), sourcing no kit library of its own.
Every rule resolves the state file through LIFECYCLE_KIT_STATE_FILE, the stamps’ knob, so the guard follows a relocated file. A door that fails its read costs a block’s message its command, never the block. The first rule adds no knob of its own. The second and third rules, a dispatched stage session’s write before its stamp and after its stage is left, read LIFECYCLE_KIT_STAGE_SESSION_TYPES and are stated at §check-dispatch-entry.
The residual is stated, because an unstated residual reads as a closed hole. One Bash writer is closed by construction rather than by the hook. guard-kit’s --rewrite arm refuses an operand resolving to the state file through this hook’s own predicate (guard-kit/SPEC.md §rewrite), since guard-kit rule sed_file steers in-place rewrites to that arm. Otherwise the guard reaches agent Write/Edit tool calls and nothing else. It misses a Bash redirect, a --no-verify at the commit that would have caught the result, a human editing outside the agent tooling, and any future writer that is not one of those tool calls. It narrows the window without closing it. And it is inert until wired: a consumer who omits guard-kit’s settings-json block gets a hook that enforces nothing while looking like it does.
check-stage-entry
Invariant: the stage being entered re-verifies its prior stage’s static exit, extending the invocation-stamp floor check-stage-evidence provides one hop back. The two share a surface and hold distinct invariants: stamp grammar + name-axis agreement there, prior-stage invoked + entered-stage static exit here.
It reads the entered stage from the cursor, the state file’s last stamp, and the iteration from the header. Both come through the shared stages::current_stage and stages::header_iter derivations, which read the last stamp’s stage field and the queue header and never the <head> field. So a grammar change to <head> moves nothing here. An empty cursor is unreachable by construction and stays a hard parse error rather than a disarm. --enter-stage hands the gate a temp state file always carrying the candidate stamp, and at commit time the entry commit stages that same stamp.
Every finding and help: line that names the state file names the configured LIFECYCLE_KIT_STATE_FILE, never the positional. The positional is the file read, which under --enter-stage is a scratch candidate the run deletes. A relayed recovery naming it would point at a file that is gone. A read failure alone names the path it failed to read.
It owns five assertions:
- (A) Prerequisite-stamp ordering. For an entered stage X, the file carries a stamp for X’s configured mandatory predecessor. That closes the “jumped straight to the last stage with no prior stamp” hole its sibling, which asserts no stage coverage at all, cannot see.
- (B) Drain-entry queue-empty. A drain-stage entry requires the configured active queue sections to carry no
###entry (queue-kit/SPEC.md §The queue format), catching entry on incomplete build. Its exemption, backstop and partition follow the list. - (C) Audit-trigger. An audit-entry-stage header carrying a cross-component amendment signal but no
<iter> <audit-stage>stamp demands either that stamp or an explicit recorded waiver line. It mechanizes the audit stage’s self-reported cross-component trigger, so a missed trigger cannot silently skip the audit. Its signal and waiver follow the list. - (D) Inferred-claim residue. At audit-entry-stage entry, any on-disk amendment line or active queue-entry line carrying a well-formed
**Inferred, not run:**marker is a refusal. - (E) Marker grammar. Whatever the cursor, a marker in the same corpus whose command or reason is empty, and a misplaced spelling, is a refusal (the marker grammar: §templates/stages/).
B has one modeled residue class, [drain-exempt: <reason>]. An entry whose tag line carries it (syntax: queue-kit/SPEC.md §The tag algebra) is skipped at drain-stage entry: a drain-spanning feature whose remaining half is drain-stage work. The reason must be non-empty, an empty one being malformed and red. It is echoed in the refusal or clean detail, the audit trail its semantic reader. Ruled-but-unpromoted work is never exempt residue. It files as a Deferred entry for a later scope’s promotion, the deferred-filing model for designed-but-unscheduled work.
B’s backstop runs with no exemption. At entry to every drain successor, each stage whose LIFECYCLE_KIT_PREDECESSOR value is the drain stage, nothing may remain active, tagged or not. The map is many-to-one, so every match backstops, and a tagged entry drains by whichever successor is entered first. So untagged entries drain by drain-stage entry and tagged ones by successor entry. A roster whose drain stage is terminal, with zero successors, is refused fail-closed at config load by the table validator (§The stage-machine adapters). An exemption with no reachable backstop would be permanent.
B’s blockers are partitioned. Any carrying [observed-by:] (queue-kit/SPEC.md §The tag algebra) are named in a message of their own, because their remedy is not the one the ordinary message implies. An untagged blocker says finish the work. An observation-predicate blocker can have its work finished and still be undrainable, so its message cites §The state machine’s push placement and the scope-stage split for a release-run producer. [drain-exempt:] still wins at drain-stage entry. An entry carrying both is exempt residue there, and joins the observation message only at a successor’s entry, where no exemption holds.
The partition’s honest limit: this branch enforces nothing. A push’s timing is not a tree state, so no arm here can observe that the placement rule was kept. What the branch buys is that the failure, when it happens, names its own remedy at the moment it fires. The enforceable half of this class is the tag’s tag-line hygiene (queue-kit/SPEC.md §check-tag-lead-line).
C’s signal reads the on-disk amendment tree: cwd-relative, the gate-sdk prune set applied, and templates/ paths excluded. A shipped SPEC-amendment.md skeleton is a copyable stub, not a live amendment, the same exclusion canon-kit’s finders apply. An amendment is a file the amendment glob matches, by basename or by path as that knob’s bullet states. The signal fires when amendment files span ≥2 component dirs. It also fires when a single amendment’s component set, its own dir ∪ the contract-surface tokens in its body that resolve to a roster dir, is ≥2.
A roster dir is a directory holding LIFECYCLE_KIT_ROSTER_BASENAME, and the roster test screens the body tokens alone. An amendment file’s own directory is a component wherever it sits. So a second component arrives from a second amendment file anywhere, or from a body token resolving to a roster dir.
A generated mirror of a roster file is a roster dir until the consumer declares its root. With LIFECYCLE_KIT_MIRROR_ROOT empty, a single-component amendment obliged to name its mirror as an update target, as a consumer’s generated-projection rule may require, reaches ≥2 on that token alone. Set, a body token resolving to <root>/<dir> counts as <dir> where <dir> is itself a roster dir. A projection is its source’s component, so naming it reaches nothing the source’s own token does not. The fold never drops a token: a mirror of another component still adds that component, and a dir under the root that mirrors no roster dir stays a component of its own. Whether the mirror exists is not tested: a token naming another component’s mirror not yet generated adds that component as a generated one does, the amendment’s reach being the same, and where that demand is unowed the valve is the waiver. The amendment file’s own directory and the two-amendment-dirs signal are not folded.
C’s waiver rides the stamps’ own file, auditable, and is written only on an explicit user ruling. The dispatcher declares it with --enter-stage --dispatch <stage> --waive <reason>, and the entry consuming that declaration writes it (§bin/enter-stage.sh). The entering session never issues one. A waiver satisfies only assertion C: A’s predecessor scan matches the audit stage exactly, so a waiver is never read as an audit stamp. C’s help: line names the waiver form as the recovery, run by the dispatcher on an explicit user ruling. It also says that respelling a body token so it stops resolving is not a remedy, the token being the amendment’s reach, and that a generated mirror is declared through LIFECYCLE_KIT_MIRROR_ROOT. Every other over-demand’s valve is the waiver, per C’s honest limit below.
Assertions D and E read C’s amendment walk and B’s queue read, and nothing else. The walk runs at every firing, since E is not cursor-gated, and it is C’s: prune set, templates/ exclusion and LIFECYCLE_KIT_AMENDMENT_GLOB’s match. The queue read is every line inside an entry of a LIFECYCLE_KIT_ACTIVE_SECTIONS section of LIFECYCLE_KIT_QUEUE_FILE. An entry’s extent runs from its heading to the line before the next heading of the same or a shallower level, or a --- rule, so a section’s preamble is in no entry. The deferred section, the icebox and the done section are not read.
Both reads are already this gate’s, so D and E need no knob and no --reads root of their own. The queue read is a named file, outside the walk class gate-sdk/SPEC.md §check-reads-couples rules on, and couples= already carries it. D runs whether or not an audit stamp exists, because the audit stamp proves the audit ran, not that the markers were consumed.
Where a marker is read. A line is read as a marker only where the marker opens it, after optional indentation and one optional - or > lead. It is never read inside a fence, backtick or tilde, read over the whole file on the fence reader (canon-kit/SPEC.md §The shared spec adapters). Either marker’s tail, the not-run form’s command or the cannot-run form’s reason, is the text after its last spaced em dash. The tail is empty when that separator is absent or nothing follows it.
E refuses a malformed marker, whose tail is empty, and a misplaced one. A misplaced marker is either bold spelling standing outside a code span where it does not open its line, or one split across a line break, where a hard wrap puts it. The split is found by reading each line joined to the next with one space, so a consumer that wraps gets the same answer as one that does not. The fence rule applies unchanged. A code span is a backtick run and the next run of the same length on that line. A split is reported at its first line, and a line that opens with a marker is read as that marker and never also as misplaced.
Red prints, amendment or queue alike:
- D’s
<file>:<line>: <marker line>per unrun marker; - E’s
<file>:<line>: malformed marker: <line>per malformed marker; - E’s
<file>:<line>: misplaced marker: <line>per misplaced one.
A marker is one finding, so a malformed not-run marker is E’s and never also D’s residue, and E reports before D.
The help lines name the remedies. For an unrun marker: run the command, correct the passage or entry and delete the marker, or rewrite it to the cannot-run form with a reason. That fix is made at the stage the refused entry leaves the cursor at, since a refused --enter-stage writes nothing. The unrun-marker help also says that a claim no delta rests on keeps its honest limit, stated in prose, when its marker goes. Deleting the limit with the marker leaves the passage claiming more than anyone ran. For a grammar finding: write the missing spaced em dash and command or reason, move the marker to open its line, or put the mention in a code span. That fix is made at the site, in the commit the finding refuses.
Clean detail at audit entry adds N cannot-run claim(s) carried when N > 0, summed over both corpora, the count build’s run-the-system paragraph consumes. D is inert where LIFECYCLE_KIT_AUDIT_ENTRY_STAGE is empty; E is not. The honest limits: a roster with no audit stage has its marker grammar held and its residue left to the template obligations. A dedicated knob for the implementing stage would mint a name for one assertion, which the knob roster refuses without an attested consumer. A queue file whose active-section names are misconfigured reads no entries for D, exactly as for B.
Why a block and not an annotation. An annotation already existed, the survey record’s inferred field, and the failure D closes is that nothing consumed it. A marker that blocks nothing reproduces that failure one surface later. D is not the assertion-C sibling for the authoring stage refused below. That refusal rests on the pairing rule verifying an authoring stage’s output, and D asserts a property of amendment content the pairing rule never reads. Not gated, and stated so: whether the authoring-exit pass ran, which leaves no tracked residue, and whether an unmarked claim is true, the class the pass concedes (§templates/stages/).
Calibration: the predecessor map omits a trigger-gated stage as anyone’s mandatory predecessor. That covers the audit stage, and equally a trigger-gated authoring stage where a roster splits one out (§templates/stages/). Demanding its stamp before a successor would false-fire on an iteration that legitimately skipped it: an amendment-free iteration runs no audit, and a debt-only one runs no authoring stage. The build→align re-check when align did run is the build skill’s step-0 procedural precondition, not this gate.
A trigger-gated authoring stage takes no assertion-C sibling. Its trigger is procedural, the prior stage’s next-stage recommendation, and canon-kit’s bidirectional amendment-pairing rule already backstops it. A feature entry carries a [spec:] ref only when the amendment exists on disk, so a skipped authoring stage cannot ship a feature without its amendment. The disanalogy with C is decisive. C mechanizes because the audit it gates is otherwise-unverifiable judgment, whereas an authoring stage’s output is verified by that rule. A process assertion there would only duplicate the on-disk amendment signal, or smear canon-kit’s feature-section grammar into this gate.
Assertion C’s honest limit: it approximates “changes ≥2 components’ contracts” with “touches or names ≥2 component surfaces”. It can over-demand, which the cheap waiver valve absorbs, and can under-detect a purely semantic cross-component impact. It converts a silent skip into a stamp, a recorded waiver or a narrow false negative, strictly better than self-report. The mirror fold moves one case to the under-detecting side: an amendment whose reach into the mirror’s generator is carried by its own mirror’s token alone is silent under the fold.
The gate dispatches to the binary substrate, checks/check-stage-entry.gate to native/src/gates/stage_entry.rs. The predecessor map is a keyed knob and is read by key (gate-sdk/SPEC.md §The knob file). Its couples= reach the amendment and roster corpora assertion C scans, since an amendment landing anywhere changes what C sees. Its two whole-tree scans are declared to --reads as one root under two filter-knob names, LIFECYCLE_KIT_ROSTER_BASENAME and LIFECYCLE_KIT_AMENDMENT_GLOB, the channel gate-sdk/SPEC.md §check-reads-couples provides. Honest limit on that declaration: neither fixture case reaches assertion C. So the crate’s declared-roots unit test holds over an empty observation for this member, and the scans’ coverage rests on the behavioral test below and on the live battery.
The good/bad pair covers assertion A; gate-tests/check-stage-entry.test.sh covers B, C, D and E in sandbox scenarios. Suite runs and other non-static exits are not re-runnable as pre-commit gates and stay human-judged at the stage approval. The prerequisite-stamp floor is their mechanical residual.
check-stage-skill-coverage
Invariant: the configured stage set and the skills dir (LIFECYCLE_KIT_SKILLS_DIR, default .claude/commands; override with the first argument) cover each other, in four directions. A skills dir that does not exist is fail-closed (exit 2). Its declared knobs are LIFECYCLE_KIT_SKILLS_DIR, LIFECYCLE_KIT_STAGES and LIFECYCLE_KIT_STAGE_EXECUTOR.
- Forward: every
LIFECYCLE_KIT_STAGESmember has a<stage>.mdskill file, since a stage with no skill cannot be entered. - Reverse: every skill file invoking
--enter-stagenames a live stage in the token it passes. That invocation is the mechanical marker separating a stage skill from an ordinary one. So a retired stage’s orphan skill (its.mdstill invoking a now-unknown stage) reddens, and a non-stage skill like/agent-execution, which never invokes--enter-stage, is never flagged. - Third: every configured stage’s executed surface carries the resume-journal step (§templates/stages/, §The state machine), which a later template edit could otherwise drop with nothing noticing. The direction rides this member because it already resolves
LIFECYCLE_KIT_STAGESand already tells a stage surface from an ordinary one by a marker. - Fourth: every stage
LIFECYCLE_KIT_STAGE_EXECUTORbinds has an executed surface carrying the citationlifecycle-kit/SPEC.md §The host protocol. A bound stage whose surface lacks it reds, naming the stage, the surface, the adapter and the two remedies: unbind the stage, or carry the protocol’s pointer line.
The executed surface is derived, never configured. For a skill carrying a binding directive it is the template the directive names, through the resolver check-skill-binding owns. For a copy-and-specialize skill carrying none it is the skill file itself, the surface the stage session then executes. So the fork mode is covered rather than exempted, with no knob and no second positional to reach the template directory. A directive naming a missing file is skipped here: that finding is check-skill-binding’s, and one defect owes one reporting gate.
The marker is a citation, never a sentence. The gate asserts the step’s lifecycle-kit/SPEC.md §The state machine citation, a token check-spec-pointer resolves in the same battery, so no prose is duplicated for a gate to match. A gate matching a sentence acquires editorial control over wording it does not own. Whitespace is collapsed before the search, so a citation wrapped across two lines still resolves. The fourth direction’s marker is a citation on the same ground, read off the same executed surface under the same collapse.
The set of stages that may be bound is derived from the surfaces and never listed. A roster knob of host-capable stages would be a hand-kept copy of a fact the surfaces already state, drifting the first time a template gains or drops the line. A copy-and-specialize consumer admits a stage by carrying the citation in its own skill, which is the act of adopting the protocol. The direction is inert while the knob is empty, and the clean line says how many bound stages it read, so a tree binding none reads differently from one whose bindings passed.
Honest limit. The fourth direction proves the surface points at the protocol, not that a session follows it or that the adapter is configured. Where the binding lives in a gitignored overlay, the direction reads it on that machine alone.
The # graph: couples the skills dir and the stage-template dir at tier=precommit, as check-skill-binding does, so an edit to either fires the gate. A stage-set or executor-binding edit (lifecycle-config.knobs) lies outside the coupled surface, and the whole-tree run-gates.sh battery backstops it. The good/+bad/ pair drives both citation directions. good/ is a fixture stage set whose surfaces all carry the journal citation, with one stage bound whose surface carries the host citation. bad/ has one stage whose surface lacks the journal citation and one bound stage whose surface lacks the host citation. gate-tests/check-stage-skill-coverage.test.sh covers the forward and reverse directions, the bound-template resolution one pair cannot hold, for both citations, and a malformed binding exiting 2.
check-stamp-subject
Invariant: when the staged state file (LIFECYCLE_KIT_STATE_FILE) adds data lines whose stage field is a LIFECYCLE_KIT_STAGES member, the subject line of the message file parses with a <scope> equal to the last such line’s stage — the cursor the commit leaves. Parsing is check-commit-subject’s grammar (gate-sdk/SPEC.md §check-commit-subject); roster membership of the type stays that gate’s to judge. The rule and its ground are §The stamp protocol’s.
What the gate reads. A tier=commit-msg gate has the message file and the index. The added stamps are the staged state file’s data lines absent from HEAD’s version and, during a merge, from the version of every commit MERGE_HEAD lists. That one read covers the ordinary entry, the boundary reset (which truncates, then stamps), the valve-admitted entry and --rename (whose rewritten lines are the added ones). A waiver line is not a stamp, since its token cannot collide with a stage name (§Layout and configuration), so the roster filter skips it. --enter-stage derives its printed subject the same way, so writer and asserter read the same stamp.
A merge inherits its parents’ stamps, so only a line no parent holds is an addition; the inherited stamps were checked at their own commits. A stamp the merge itself introduces, say while resolving a conflict, still needs the stamped stage as the merge’s subject scope. The exemption rides the parents and never the subject, so a hand-written Merge … subject on a commit introducing a stamp still reds.
- Skips, clean. A commit adding no stamp line; a no-argument run (the whole-tree battery), on
check-commit-subject’s ground that the message is not a tracked surface; no staged state file. With noHEADstate file, every staged data line is an addition. - Fail-closed. A message-file argument naming a missing file exits 2, as does an argument count of two.
- Red message. It names the subject, the stamp line it read the stage from, the expected scope, and the tool’s printed subject as the fix.
The honest limits. An amend of a stamp commit compares against the commit being amended, which already carries the stamp, so a mis-scoped stamp commit fixed by --amend is not re-checked. A parent whose state-file version git cannot show counts as carrying no lines, which is HEAD’s absent-side rule applied to it.
The fixture form is <message-file> <staged-state> <head-state> [<merge-parent-state>…]: the staged and HEAD state-file blobs, then one blob per merge parent, so the pair runs hermetically without a git index — the synthetic second argument check-trajectory-fresh takes (drift-kit/SPEC.md §The published-evidence extractor). The bad/ case is an align stamp committed under chore(workflow); the good/ case an align entry followed by a waiver line. gate-tests/check-stamp-subject.test.sh covers the boundary reset, the rename subject, a commit adding no stamp, an unscoped subject, a Merge … subject whose stamp only a merge-parent blob carries (clean) or no blob carries (red), and the argument edges.
check-dispatch-entry
Invariant: while the dispatch marker holds a line, a commit adds a stamp. The marker’s lines are stage sessions a lead dispatched that have not yet entered (§bin/enter-stage.sh), so a commit made under one is work landing ahead of its session’s stamp. That is the ordering case §check-stage-evidence states its own assertions cannot reach.
What the gate reads. A tier=commit-msg gate, so it runs once per commit and skips the whole-tree battery. A pre-commit gate would run in the battery too, and a stage session runs the battery before its stamp (the build template’s step 0). It reads the marker and the added stamps, and the added stamps are §check-stamp-subject’s read, merge parents included, called from one shared function, so this gate and §check-stamp-subject never disagree about what a commit adds. Red when the marker holds a line and the commit adds no stamp. It names the outstanding stages and prints the remedy: run --enter-stage <stage> and commit the stamp on its own first, or, for a lead whose dispatched session ended without entering, --enter-stage --dispatch-withdraw <stage>.
- Skips, clean. No marker, or an empty one. A no-argument run. A commit whose staged path set is exactly one inbox, the gap inbox or the consult inbox (
LIFECYCLE_KIT_GAP_INBOX_FILE,LIFECYCLE_KIT_CONSULT_INBOX_FILE), which is a lead’s sanctioned capture commit (§templates/lead.md) and carries no stage work. - Fail-closed. An unreadable marker, a missing message file and an argument count the forms below do not admit all exit 2.
Honest limits. With no lead nothing writes the marker, so the gate is inert and the case stays prose. A lead dispatching two batches in parallel holds two lines. A sibling that has entered and commits before the other has entered reds, and a retry after that entry clears it, since the second batch stamps first. A session that stamps and then lands work under another stage’s name is outside this gate. Its Write and Edit calls meet the workflow-state guard’s third rule once its stage is left. The stamp’s stage and the commit’s content are §check-stamp-subject’s and review’s.
The write-time half rides the workflow-state guard. That PreToolUse(Write|Edit) member (§check-stage-evidence) carries a second rule. It blocks a write when the payload’s agent_type is a member of LIFECYCLE_KIT_STAGE_SESSION_TYPES and no data line of the state file (LIFECYCLE_KIT_STATE_FILE) carries the caller’s id in its session field. The caller’s id is the payload’s agent_id normalized by §bin/session-id.sh’s rule, which is the id that session’s own entry stamps. A dispatched session’s PreToolUse payload carries its subagent transcript’s agentId as agent_id, as observed on a stage session’s own Write and Edit. The block names the remedy, --enter-stage <stage> first, and why: its stamp is what records the session’s work. A caller with no agent_type is not a dispatched session and the rule is silent. That covers a top-level session, the lead and a stage run by hand. A subagent a stamped session dispatches carries its own type, so it is out of the roster.
A third rule refuses a write after the caller’s stage has been left. A roster-typed caller whose stamped stage is not the cursor’s is blocked from writing outside the scratch dir that GATE_SDK_TMP_DIR names. The caller’s stage is its last stamp’s stage, so a same-stage sibling is never superseded, and at the iteration posture a session that entered a later stage holds the cursor. The rule exists because a stage session stays resumable after its successor enters, as the lead’s intent oracle or to answer a routed question. The answer lands through the live stage session (§templates/lead.md), and a clean index says nothing about who holds the stage. The scratch dir stays writable, so the resumed session keeps its resume journal. A target is inside it by the rule’s own resolution, the nearest existing ancestor canonicalized, and a .. component never is. A commit-time refusal is refused: a commit carries no session identity, a subagent’s shell carries only the root’s id, and --emit-session-id names the newest transcript (§bin/session-id.sh). A cursor that is not a configured stage, or an unresolvable scratch dir, declines.
It keys on the caller, never on the marker. A marker-keyed refusal would reach every session sharing the tree while any line is outstanding: the lead, a parallel sibling that has entered, and that sibling’s own subagents. It would still miss a dispatch nobody declared. The marker keeps its one reader, this gate, and the two halves together cover the write and the commit. A waiver-bearing marker line is invisible to the rule, which reads no marker.
It rides the existing member rather than a new one. The guard’s first rule is the other half of the same property, that lifecycle state has one writer and the stamp comes first. A new member would need a new settings registration in every adopter’s operator-owned settings file, for no separation a reader would use.
Degradation costs this rule alone. An unresolvable roster, a roster-typed caller carrying no agent_id, or an unreadable state file declines through hook::decline (gate-sdk/SPEC.md §The harness-integration arm), and the first rule still runs. An empty roster is the not-adopted case and makes the rule inert, not a decline.
Honest limits. The rule reaches Write and Edit and nothing else. An unstamped or superseded session’s shell write is not refused, so neither is a superseded session’s git commit of work it wrote before its successor entered. That needs work left uncommitted across the session’s own turn end, which the dispatch policy already forbids. The first Write/Edit it attempts is, and its commit meets this gate. Refusing the Bash tool wholesale is refused: the build template’s step 0 runs its battery before the stamp. Classifying a shell command as a tree write is the shell guard’s machinery, not this member’s. The id derivation’s newest-transcript race can stamp a parallel sibling’s id (§bin/session-id.sh). The session it skipped is then refused and clears the refusal by entering again, which appends a stamp under its own id. The second and third rules’ hermetic cases live in gate-tests/stamp-before-write.test.sh.
The fixture form is <message-file> <marker> <staged-state> <head-state> <staged-paths>, the last a file of one staged path per line, so the pair runs hermetically: bad/ is a work commit under a standing build line, good/ the same commit adding the build stamp. gate-tests/check-dispatch-entry.test.sh covers the two inbox skips, the empty marker, the parallel two-line case and the argument edges.
check-skill-binding
Invariant: every skill under LIFECYCLE_KIT_SKILLS_DIR (default .claude/commands; override with the first argument) that carries a binding directive, Execute the template at <path>, applying the bindings below., (a) names a template file that exists and (b) binds exactly that template’s slot set. An unbound slot is red, and so is an orphan binding naming no slot. A skills dir that does not exist is fail-closed (exit 2).
A skill with no directive is not read, so a copy-and-specialize skill is untouched: the directive-as-selector mechanism check-stage-skill-coverage applies to --enter-stage. A bound skill need not be a stage skill; /agent-execution binds a delegation-kit template, and the resolved path may point at any kit. The grammar:
- Template slots are the
*<slot-name: …>*opening tokens. - A shim’s bindings are the
**slot-name** —lead lines under## Bindings. - The directive’s template path resolves relative to the current directory, the tree root at pre-commit.
The # graph: couples at tier=precommit the skills dir and every kit’s templates (kit:templates/*.md, whose * crosses /, gate-sdk/SPEC.md §Reading a couples= field’s reach), so a binding changed in a shim or a slot added to any kit template fires the gate. The couple is derived, not listed: a by-name list of bound templates is a second copy of what the shims’ directives say, and a template a later shim binds would fire nothing until the list caught up. A kit template no shim binds fires the gate too, an over-trigger a cheap gate pays without cost to anyone. Refused: asserting that every bound template matches a couple. Under the derived couple its one finding would be a template outside every kit’s templates/, which a kit-shipped descriptor cannot name for a consumer anyway. Honest limit: a shim binding such a template fires this gate only in the whole-tree battery. The good/bad pair drives the unbound-slot case; gate-tests/check-skill-binding.test.sh covers the orphan-binding, missing-template and skip (no-directive, no-slots) cases one pair cannot.
check-shim-restatement
Invariant: no binding shim under LIFECYCLE_KIT_SKILLS_DIR shares a normalized word n-gram of length ≥ LIFECYCLE_KIT_SHIM_NGRAM with any surface in the dedup corpus LIFECYCLE_KIT_SHIM_DEDUP_CORPUS. It is the duplication tripwire, under check-skill-binding’s directive-as-selector rule: a file with no Execute the template … directive is not a shim and is not read. The corpus defaults to the consumer’s always-loaded agent file (LIFECYCLE_KIT_AGENT_FILE, default CLAUDE.md) plus every kit’s templates/**/*.md (kit set from gate_kit_roots). An explicit LIFECYCLE_KIT_SHIM_DEDUP_CORPUS overrides it, and so do positional corpus arguments, the hermetic-fixture affordance. Comparison normalizes first (lowercase, punctuation stripped to a word boundary, whitespace collapsed), so cosmetic rewording does not evade the tripwire. A resolved corpus yielding no n-grams is fail-closed (exit 2), never a false clean.
LIFECYCLE_KIT_SHIM_NGRAM is calibrated to the smallest window with zero false positives on the corpus, with a floor of 8 words so a citation line (a path plus a §<heading>) never fires. The default is 9, the width at which a citation of an 8-word heading stops tripping. Honest limit: the n-gram holds the copy shape only. Which tier a fact belongs to stays semantic judgment, so a paraphrase below N words passes and is still a defect to fix on sight (check-comment-tier’s floor doctrine).
The # graph: couples the skills dir, CLAUDE.md and the kit template dirs at tier=precommit, so editing a shim or a corpus surface fires the gate. The kit:templates/*.md couple reaches every template subdirectory, since the field’s * crosses / (gate-sdk/SPEC.md §Reading a couples= field’s reach). So the stage templates the shims bind, a shim’s likeliest collision surface, re-trigger the gate with no couple of their own, and the boundary skills at templates/ root sit inside the plain couple. A red run names the shim, the corpus surface and the shared n-gram, so the fix (delete the restatement, keep a citation) is mechanical. The good/bad pair drives the plain restatement/clean split; gate-tests/check-shim-restatement.test.sh covers the no-directive skip, the short-corpus fail-closed and the below-N paraphrase one pair cannot.
check-lesson-disposition
Invariant: every ## Lessons Learned entry present at HEAD and absent from the worktree leaves a well-formed disposition stamp in LIFECYCLE_KIT_LESSON_EVIDENCE_FILE, so a lesson cannot be cleared without a recorded rule/task/harvest/discard call. The evidence home is a stamped file, not the commit body: the battery runs at pre-commit, when no commit message exists yet, so only a file is mechanically decidable (the check-stage-evidence fail-closed precedent). Each data line is:
<iteration> lesson <rule <file> | task <slug> | harvest <tag> | discard <reason>> — <lead-line prefix>
The — separates the disposition from the lead-line prefix joining it to the removed entry. A stored prefix matches a removed entry when it is a leading substring of that entry’s normalized lead line. Both the grammar (each line well-formed, a known disposition kind) and the per-entry match (every removal has a stamp) hold, and an unreadable evidence surface is fail-closed (exit 2). Validation is shape-only: harvest <tag> is not checked against QUEUE_KIT_LESSON_TAGS, which would cross-couple the kits’ configs. The close skill and check-tag-lead-line hold the vocabulary.
Calibration: diffing HEAD against the worktree is fixture-hostile, since a committed fixture has HEAD == worktree and the removal case has no static representation (the check-task-conservation precedent). So the gate takes optional [queue-head] [queue-worktree] [evidence-file] override args, and its good/bad pair drives all three hermetically (the check-trajectory-fresh synthetic-args precedent). gate-tests/check-lesson-disposition.test.sh covers the malformed grammar, still-present-not-removed and prefix-join cases one pair cannot. The # graph: couples the queue file and the evidence file at tier=precommit. A queue keeping no Lessons section at HEAD has nothing to disposition and the gate is clean, over the header-only evidence file the kit’s built-ins keep. So is a tree outside a repository, while one git refuses exits 2 (gate-sdk/SPEC.md §The crate’s crosser).
check-merge-attrs
Invariant, over two derived sets:
- Iteration-scoped, bidirectional. The derived supersede set (
stages::supersede_set, §The stage-machine adapters) equals the paths carryingmerge=iteration-scopedin the consumer’s.gitattributes(default.gitattributes; override with the first argument). Forward, a supersede-set path with no such line is an unmechanized rule: a merge would silently take the wrong side on that surface. Reverse is the safety edge: the attribute on a path outside the derived set silently discards merge content on a real surface, so a smuggled line is red, not config. The gate scans everymerge=iteration-scopedline in the file, inside the installer’s marker block or not, so the reverse edge holds against a hand-added line anywhere. - Union, forward-only by design. Every member of the derived union set (
stages::union_set, the gap and consult inboxes) carriesmerge=union, or a filed gap would be silently dropped at a concurrent merge. Amerge=unionline outside the set is not flagged: it is a git-native driver a consumer’s own tracked append log legitimately carries (§Multi-operator semantics), so there is no smuggling to catch.
A missing .gitattributes reports every derived surface as unmechanized (exit 1, the install remedy). Fail-closed (exit 2) are an unreadable one, an empty supersede set and an empty union set. A lifecycle always owns at least its state file and its two kit-owned built-ins (the lesson-evidence file and the survey record) and at least its gap inbox, so neither set is legitimately empty.
The four gate-sdk contracts (gate-sdk/SPEC.md §The gate model):
- Output: the single
MERGE-ATTRS: cleanline and ahelp:remedy on the finding path. - Fail-closed: exit 2 on an unreadable target or an empty derived set.
- Fixture pair: a
good/+bad/pair undergate-tests/, the good case the default state and lesson attribution plus the two inboxes’ union lines, the bad case a smuggled reverse-edge line.gate-tests/check-merge-attrs.test.shadds the iteration-scoped forward-missing and missing-file findings, the union forward-missing finding and the union no-reverse-edge case, amerge=unionline outside the set staying clean. It also runs a real two-branch merge in a sandbox repo, asserting the keep-ours driver resolves an attributed surface to the arriving side. - Self-lint: registration in this repo’s
gates.list, where its own.gitattributesis the scan target.
Its # graph: couples .gitattributes, the kit’s table and this repo’s scripts/lifecycle-config.knobs (the config the supersede set derives from) at tier=precommit. A reshaped LIFECYCLE_KIT_BOUNDARY_TRUNCATE in the consumer config is backstopped by the whole-tree run-gates.sh battery, on the check-stage-skill-coverage precedent.
check-survey-record
Invariant: every block in the survey record (§The survey record) carries a whole witness. Over each ## block:
- all five keys are present, in order, one per line, with no sixth key and no stray line;
corpusparses by the grammar §The survey record states, and in bare mode each of its pathspecs matches a path inrev’s tree;oracleandinferredare non-empty, where the literalnoneis legal and the honest form, and an empty value is the silent form and is refused;revis a full 40-hex sha naming a commit that exists, which catches the short-sha and wrong-rev cases and is why the field is machine-stamped rather than author-supplied;- every git-object-shaped token in the other four fields names an object that exists (§The survey record), unless the block carries the valve.
inferred is asserted on oracle’s two footings because it answers the same question in its own dimension. An absent key and an empty value are both the silent form of “no claim was sorted”, and the field exists so a citing session can tell that apart from a survey that inferred nothing.
The widened arm reuses the rev arm’s probe, git cat-file -e, over a wider input: every field but rev, inferred included, since an inferred claim that pastes a sha is a citation like any other. Its two asymmetries with the rev arm are deliberate:
- It accepts any object type, where
revdemands^{commit}, because a sha naming a blob or a tree is a real citation. - Its token shape requires at least one
a-f, whererevtakes any 40-hex string. That is false-positive control: a 7-plus run of bare digits in prose is a count, a compact date or a byte figure far more often than a sha. Its honest limit is that an all-digit short sha is not probed.
rev itself is excluded from the widened scan, since it has the stricter arm and reporting it twice would say one thing in two voices. The heading is not scanned either: it is a question, not a field.
The corpus stops at this record. A survey block’s fields are short and structured, so a word-bounded hex run in one is a citation far more often than an accident. Over the queue or every governed prose surface, the arm would meet ordinary hex-looking English and fixture data, and a gate that cries wolf trains its readers to bypass it (gate-sdk/SPEC.md §When a gate earns its place).
The valve is a markdown comment on a surface check-comment-tier does not scan, so the survey-token-exempt spelling is not registered on canon-kit’s directive roster. A registration would be dead configuration, and would couple one kit’s gate to another kit’s block grammar. A consumer widening the comment-tier corpus to include the record has the universal comment-tier-exempt: escape already. This gate’s own parser does know the spelling, since it would otherwise read the valve as the stray line its block grammar forbids. That half needs no probe, so the hermetic fixture pair asserts it.
An absent record, and a record truncated to its header, are clean and counted inert: the surface is optional, and a consumer that never files a survey must not carry a red gate.
Bare drives the configured record with the full assertion set. An explicit file argument drives it hermetically, grammar only, with neither the rev-existence nor the corpus-match probe. A fixture’s rev names no commit in the tree the fixture is copied into, so a pair asserting existence would go red in every consumer vendoring the kit. Bare also degrades to grammar-only outside a git repository, where there is nothing to resolve a sha against, and exits 2 in one git refuses. The clean line names which mode ran, so a grammar-only pass never reads as a verified one.
The corpus-match probe is the capture arm’s filing probe repeated against the commit the block names. It runs only once rev has resolved, so a block whose rev names nothing reports that finding alone. It probes at rev and never at HEAD, because a witness over a file a later commit deleted is still sound: git diff reports the deletion. It diffs rev against the empty tree once per pathspec with git diff-tree. ls-tree takes no pathspec magic, and an exclude or glob probed beside a matching pathspec would ride its neighbour’s match.
The four gate-sdk contracts (gate-sdk/SPEC.md §The gate model):
- Output: the single
SURVEY-RECORD: cleanline, and ahelp:remedy naming the grammar and the--emit file-surveyarm on the finding path. - Fail-closed: exit 2 on an unreadable or explicitly-named-but-missing record and on a failed parse.
- Fixture pair: a
good/+bad/pair undergate-tests/, driven through the hermetic argument. The good case is a four-block record carrying anoracle: nonenote, a valved block, acorpus: noneblock and quoted glob pathspecs, every block withinferred: none. The bad case carries a short sha, an empty oracle, a corpus with an unquoted parenthetical, a block missing itsoracleline and a reasonless valve. It also carries an emptyinferredand a block whoseinferredline is missing: different findings with different remedies, one “writenone” and one “the grammar grew a field”.gate-tests/check-survey-record.test.shholds what the probes decide, which the pair cannot. That is both arms of each probe in a sandbox repo: rev-existence (a rev naming a real commit, and a well-formed 40-hex rev naming nothing), corpus-match (a pathspec matching a path atrev, and one matching none) and the widened token probe. It also covers the valve exempting its block, a reasonless valve that reds and still probes, and the two inert shapes. - Self-lint: registration in this repo’s
gates.list, where its scan target is this repo’s own record.
Its # graph: couples the record at tier=precommit.
Not gated: whether a finding is true, and whether a session holding a fresh record read it. The first is unmechanizable. The second leaves no tracked artifact, the class delegation-kit/SPEC.md §Operative residency rules no gate is owed for. The entry report’s read trigger (§bin/enter-stage.sh) is the affordance in its place, weaker than an oracle by construction.
check-audit-roster
Four assertions, over each block of the configured roster (§The audit roster). (A) The keys are present, in order, one per line, with no stray line: last: never closes a block after four keys and every other last: after seven. Every value is non-empty, declined taking the literal none; hits is a decimal integer; last is never or <iteration> <stage>, with <stage> a member of LIFECYCLE_KIT_STAGES. (B) No line exceeds LIFECYCLE_KIT_AUDIT_ROSTER_LINE_CAP bytes, skipped at off. (C) class slugs are unique. (D) A last whose <iteration> equals the queue header’s iteration names a stage the state file stamped for that iteration.
A key found later in the block than expected reports every key dropped before it and keeps its own line, so a lost due: reads as the missing field it is rather than as a cascade of misplaced ones. A corpus value opening surfaces: must name at least one surface.
An empty knob, an absent file, or a file holding its header alone is clean and counted inert. Bare mode drives the configured roster with all four assertions; an explicit file argument drives it hermetically with A, B and C, because a fixture’s iteration names nothing in the host’s state file. The clean line names which mode ran, on §check-survey-record’s precedent. D reads nothing for a past iteration’s last, because the boundary truncates the state file.
The four gate-sdk contracts (gate-sdk/SPEC.md §The gate model):
- Output: the single
AUDIT-ROSTER: cleanline and ahelp:naming the grammar section on the finding path. - Fail-closed: exit 2 on an unreadable roster, queue or state file, an explicitly-named-but-missing roster, an unresolvable knob or a non-positive cap.
- Fixture pair: a
good/+bad/pair undergate-tests/, driven through the hermetic argument. The bad case is a droppeddue:line, an over-cap line, a duplicate class, alast:naming an unconfigured stage and an emptydeclined:. The good case is aneverblock, asurfaces:block and a command block.gate-tests/check-audit-roster.test.shcovers assertion D and the inert shapes. - Self-lint: registration in this repo’s
gates.list.
Its # graph: couples the knob-named roster, the queue file and the state file at tier=precommit.
Not gated: whether a sweep read its corpus faithfully, and whether a hits count is true. Both are session acts with no tracked residue.
check-scratch-citation
Invariant: no surface in LIFECYCLE_KIT_PERMANENT_SURFACE_GLOBS carries a retrieval pointer to a path in the derived boundary-truncated set. The rule and why inlining is the right close are §The survey record’s; this section owns the red condition.
The forbidden-target set is derived, never maintained: it is crate::stages::supersede_set (§The stage-machine adapters states the derivation), the one derivation behind the .gitattributes block the --install-lifecycle arm writes and check-merge-attrs’s parity check. So a consumer widening its truncate configuration gets citation enforcement for free.
Retrieval-pointer position is the red condition. A supersede-set path is a pointer when it is either:
- (a) in markdown link-target position, the parenthesized half of a
[text]-plus-parentheses pair; or - (b) preceded by a colon and whitespace and followed by nothing but a closing quote, comma, period, bracket or end of paragraph, as in “Full finding and its two-command witness” followed by the path.
A bare path elsewhere in prose is a mention and is clean. That is the whole calibration, and why the rule is a position rather than a pattern.
The scan joins a paragraph’s wrapped continuation lines before testing (b), or it is blind on its own worked example. A queue file wraps prose to a budget, so a colon and the path it introduces routinely land on different physical lines. The join is check-spec-pointer’s prose-extraction shape (canon-kit/SPEC.md §check-spec-pointer): concatenate a blank-line-delimited paragraph, match over the join, and map a hit back to its physical line through per-line start offsets. So the bad/ case carries a wrapped instance beside the single-line and markdown-link forms.
Per-line escape hatch: a scratch-citation-exempt: tag on the line above the hit, for a surface that must quote a dead citation verbatim in order to describe it. Its reader is this gate’s scanner, once it has matched a pointer and is deciding whether to record a finding. The class is permanent, since any entry documenting a citation defect must quote one, even while no live surface uses it. The bad/+good/ fixture pair exercises the hatch.
The four gate-sdk contracts:
- Output: the single
SCRATCH-CITATION: cleanline naming how many surfaces were scanned, and ahelp:remedy naming the--emit cite-surveyarm and the exempt tag on the finding path. - Fail-closed: exit 2 on an empty derived target set and on a failed parse.
- Fixture pair: a
good/+bad/pair whose good case copies the mention forms live entries use: a path opening a wrapped line after a full stop, a prepositional mention, a colon-introduced pointer at a non-member path, and a tagged dead citation. A rule that reds any of those is wrong, and the fixture is what says so. - Self-lint: registration in this repo’s
gates.list, where its surfaces are this repo’s own queue and spec set.
Not gated: whether an inlined finding is faithful to the block it came from. Nothing tracked relates the two once the record is truncated, and inventing a provenance field would re-open the per-iteration lifetime the record’s design turns on.
check-gap-inbox-neutrality
Invariant: the gap inbox (§The committed gap inbox) records observations, not conclusions. Two assertions over every non-blank line below the # contract: header:
- A — grammar. Every line is
- <YYYY-MM-DD> — <prose>with non-empty prose, since the raw append is a legal fallback that would otherwise let a malformed bullet wait for the drain. - B — no interposed verdict. No bullet’s prose opens with the retired
recurrence of `<slug>`:form. This is the class assertion: it catches a conclusion reaching the capture surface by any producer, a stale vendored affordance or a hand append copying an older bullet’s shape, not only the affordance this kit ships. Thehelp:line teaches the rule rather than the regex: say why you believe the bullet re-files an entry, in the prose, and let the drain judge.
An absent inbox is clean, not fail-closed: never having filed a gap is a legal state for a fresh consumer, unlike §check-lifecycle-registration’s missing agent file, which is an install that did not finish. Fail-closed applies to the scanner’s own exit status, per gate-sdk’s contract.
Bare drives the configured inbox; an explicit file argument drives it hermetically, the §check-survey-record precedent, which lets the fixture pair run against a copied inbox.
The tool-side contract is pinned separately. This gate reads the surface and cannot see which producer wrote a line, so it cannot hold the affordance to its own contract; gate-tests/file-gap-recurrence.test.sh does. The fixture-runner catches the producer regressing, and the gate catches a verdict arriving by any other route.
The four gate-sdk contracts (gate-sdk/SPEC.md §The gate model):
- Output: the single
GAP-INBOX-NEUTRALITY: cleanline and ahelp:remedy on the finding path. - Fail-closed: exit 2 on an unreadable or explicitly-named-but-missing inbox and on a failed parse.
- Fixture pair: a
good/+bad/pair undergate-tests/, driven through the hermetic argument. The good case is a record whose prose both re-files and disclaims an entry in words, the bad case a retired marker beside two broken bullets. - Self-lint: registration in this repo’s
gates.list, where its scan target is this repo’s own inbox.
Its # graph: couples the inbox at tier=precommit.
Not gated: whether a bullet’s prose states the recurrence claim at all, and whether the claim is right. Assertion B removes the one shape that pre-empts the judge. It cannot compel a filer to answer the advisory, and the judgment itself is the drain’s (§templates/stages/).
check-disposed-findings
Invariant: every non-blank line below the # contract: header of the disposed-findings record (§The disposed-findings record) is a record in its grammar: a calendar-valid date, at least one backticked term with no empty span, and non-empty prose. The arm’s matcher skips a malformed record silently, so a malformed record turns a discard’s protection off with nothing printed, and this gate is what tells. The header’s presence and form are check-workflow-tiering’s.
It is install: on-surface (gate-sdk/SPEC.md §The install disposition). The record is a surface only a stage session writes, the analogue of the stage attestation that disposition names, so the gate arms when the adopter registers it rather than at install. lifecycle-kit’s own smoke registers it beside its on-surface siblings, seeding the consumer’s record with its contract header and one well-formed record before the battery runs, so the registration exercises the grammar rather than the absent-record clean pass.
Bare drives the configured record; an explicit file argument drives it hermetically, the §check-gap-inbox-neutrality precedent.
The four gate-sdk contracts (gate-sdk/SPEC.md §The gate model):
- Output: the single
DISPOSED-FINDINGS: clean (<n> record(s) …)line, and per finding the line and what it lacks, with ahelp:naming the grammar. - Fail-closed: exit 2 on an unreadable record or an explicitly named missing one. An absent record at the configured path is clean, since a consumer that never discarded a finding is in a legal state.
- Fixture pair: a
good/+bad/pair undergate-tests/, driven through the hermetic argument. The good case is two records, one with two terms. The bad case is a record with no term, one with an empty span, one with no prose and one with an unparseable date. The module’s unit tests pin the absent-record clean line, which only the bare form reaches, since a named missing file is exit 2. - Self-lint: registration in this repo’s
gates.list, where its scan target is this repo’s own record.
Its # graph: couples knob:LIFECYCLE_KIT_DISPOSED_FILE at tier=precommit.
Not gated: whether a record’s ground still holds, and whether its terms are the words a re-filing would spell. Both are the discarding or re-verifying drain’s judgment.
templates/stages/
The stage-skill templates (scope/align/build/validate/close) carry the generic stage spine, with named slots where the consumer’s rule content goes:
- the stamp first step, performed by invoking
bash gate-sdk/bin/run-gates.sh --enter-stage <stage>and stating in one line what it does; - each stage’s trigger and ordering rules, and its stage-local doctrine;
- the instruction-surface read in
build, the one stage every template edit passes; - a pointer to doctrine-kit’s Enforcement-first body at
build’s andclose’s discovery steps, because the digest line omits the rule’s stop signal and a stage session loads its template, not the doctrine file; - the successor read and the resume-journal last step.
The templates are the owned surface: this section states the contract a consumer skill must satisfy and never restates what a template carries.
The last step mirrors the first, and its placement is the residency doctrine applied. delegation-kit/SPEC.md §Operative residency puts a rule on the surface its bound actor loads, and every stage session loads its stage template, under a supervisor or without one. The step points and does not restate: the journal’s contract is delegation-kit’s and its path is §The state machine’s. It names one act, appending DONE as the file’s last line before reporting, and cites the two owners, which keeps it far below LIFECYCLE_KIT_SHIM_NGRAM’s bar. That act discharges the whole obligation: the marker means something only as the last line, so appending it entails that the file exists and that the session wrote into it. A consumer’s binding does not restate the step. One that did would be check-shim-restatement’s subject, and check-stage-skill-coverage’s third direction holds every configured stage’s executed surface to carrying it.
The step instructs a marker the entry does not assert. §bin/enter-stage.sh’s first narrowing, existence and non-emptiness, never the DONE marker, stands on its own ground that a stage entry is not a cold read. Nor is DONE the self-asserted completion marker §The stamp protocol refuses: that refusal is scoped to the cursor, and no state surface reads this marker. DONE is delegation-kit’s own contract clause; the step instructs a session to discharge it and mints nothing.
The successor read precedes the last step, on the same residency ground. A refusal of the next stage’s entry is found by the session whose work caused it only if that session reads the entry itself. A lead’s dispatch read finds it one report later, and without a lead nothing does until the next session enters. The read is --simulate, which writes nothing and runs the entry’s own checks, so no second detector is minted. It runs after the last commit, because it reads the working tree. It runs before the DONE append, because the journal it asserts is the reading session’s own and DONE must stay its last line. Each template names its successor, the one fact only it knows: a trigger-gated successor is the one the session recommended, and a build batch that leaves work active has no successor to read.
A template whose stage admits a foreign reading carries one line pointing at the host protocol, for a session whose dispatch names a foreign adapter (§The host protocol).
The directory holds the stage-class template set, not any one consumer’s roster. It ships six templates while LIFECYCLE_KIT_STAGES defaults to five, because spec.md serves the split-authoring roster alone. Nothing derives a roster from this listing.
Adopting a template
A consumer skill adopts a template in one of two modes. Either way the executed skill states in one line what the stamp step does and supplies every slot’s content.
- Consume-by-reference (the default). The consumer skill is a thin binding shim: one directive line,
Execute the template at <repo-relative path>, applying the bindings below., then a## Bindingssection with exactly one entry per template slot. The template stays the executed surface, so generic doctrine has one owner and a re-vendor reaches it.check-skill-bindingandcheck-shim-restatementhold the shim to a thin reference. - Copy-and-specialize (the sanctioned fork). The template is copied into the consumer’s skills dir and each slot overwritten in place, so the skill stands alone (gate-sdk’s check-skeleton shape). The consumer owns the copied prose: a re-vendor does not reach it and the shim gates do not cover it. The fork stays as the escape hatch for structural divergence (different stages, a reshaped machine) and as the harness-agnostic floor the upgrade smoke assumes. Without it, forks become edits of the vendored template, which break Phase-A upgrade determinism with no gate to catch them.
A consumer forking to express prose divergence rather than structural divergence signals that the slot vocabulary is too thin. The fix is richer slots that pull those cases back under shim protection, not more copying. No gate watches for it: which mode a consumer picks is their tree.
Named slots (template grammar). Each consumer placeholder is a named slot *<slot-name: guidance>*. slot-name matches [a-z][a-z0-9-]*, is unique within its template, and precedes the : and the guidance. A copy-and-specialize consumer overwrites the whole *<…>* span. A shim binds the slot by name in a ## Bindings entry **slot-name** — <consumer content>, multi-line content indented under its lead line, and carries nothing else. check-skill-binding holds the shim↔template slot parity.
Authoring rule: a binding shim binds residue, cites procedure, restates nothing. A binding carries only what is local to this consumer: which surfaces to sweep, which config knobs, which log sinks. Procedure and always-loaded fact that a kit template or the consumer’s CLAUDE.md already owns are cited by a path plus a §<heading>, never copied. A shim is loaded on every stage invocation, so a restatement there is a per-session token tax on a fact with an owner, and it drifts the moment the owner changes. check-shim-restatement is the tripwire for the copy shape; the tier judgment (residue or owned fact) stays the author’s.
The spec.md template
spec.md is an optional amendment-authoring stage template, the generative half of design split out of scope: scope bounds the units, the authoring stage authors the amendments, and the audit stage independently verifies them. It is a full stage that invokes --enter-stage and stamps, trigger-gated like the audit stage:
- it runs only when an iteration promotes a feature to author;
- it appends rather than resets, since only the first stage resets the evidence file;
- it takes
scopeas its predecessor without being any stage’s mandatory predecessor (§check-stage-entry’s trigger-gated-stage calibration).
The kit default roster does not bind it, the split being demand-gated and non-breaking; a consumer activates it through LIFECYCLE_KIT_STAGES / LIFECYCLE_KIT_PREDECESSOR (§Layout and configuration). The generic authoring how-to spec.md single-sources, causal completeness and canon-kit’s bidirectional queue pairing, is also what a default-roster scope’s conditional authoring step points at.
A delta’s work-class label is written inline, not rostered. A roster is a second copy that drifts the moment a delta is split or reordered, while its one reader, the lead at batch-cut, scans the deltas themselves. The label records the delta’s demand and never a model name: a baked name drifts against a churning roster, and a spec-time recommendation would attach to a batch not yet cut. The authoring stage judges it because it knows what each delta demands, where the lead knows only what the queue entry says.
A user-facing delta is marked and settled at authoring, because the work-class tag measures another axis. The tag records the judgment executing a delta demands. A choice changing what a user meets can demand none, so it rides a {mechanical} delta and reaches no operator. Build’s triage stops on a user-facing change outside the envelope, but a choice the amendment makes reads to build as inside it, so the authoring stage settles it and the mark records the settling direction. The mark’s value is that direction, because a bare flag would record the question and drop its answer. Honest limit: whether a delta is user-facing is semantic, so no gate reads the mark, and a delta both its author and the audit misjudge ships unsurfaced.
The Definition-of-Done placement clause is derived, not a new rule. §check-stage-entry assertion B refuses any untagged active entry at the drain stage’s entry and any active entry at every successor’s. So an entry’s Done move or demotion happens before the drain stage, where canon-kit’s merge step 4 already puts it, or at it for a [drain-exempt:] entry. A DoD naming the drain stage for an untagged entry is as unexecutable as one naming a successor.
- A clause and not an assertion. A DoD item is prose, and canon-kit’s amendment template gives it no stage grammar. An assertion over the amendment glob would guess from prose, false-positive on every stage word, or mint a stage field in a second kit’s grammar to catch one authoring slip.
- The backstop already exists. The build stage’s successor read refuses this case at the batch that drains the queue, and the lead’s pre-completion
--enter-stage --simulate <next stage>read (§templates/lead.md) backstops it, provided it runs before the push.
The authoring-exit pass runs each delta’s own claims before the amendment is committed. The stage that wrote a claim is the cheapest place to run it. Otherwise an amendment reaches build carrying claims nobody ran, and a survey’s inferred claim restated in an amendment loses its flag at the restatement. The pass sorts what a delta asserts into three classes, whose costs differ by an order of magnitude:
- A spent delta is an instruction whose predicate the authoring commit already satisfied. The pass catches it outright, the one class fully visible from the commit that wrote it. Its hazard is inverted: a build session trusting it hunts a discrepancy that does not exist, or “corrects” a correct figure.
- A premise is load-bearing: a ruling’s ground, or a contract sentence cited to a section. The pass reaches every premise its author recognizes as one, by running it or by marking it. What stays uncaught is a premise the author believed verified, a class strictly smaller than without the pass: the survey record’s
inferredargument, applied to amendments. - An illustration moves no oracle row, and its delta’s ruling stands on its own derivation. It is exempt, because probing it buys nothing a reader acts on.
Refused: probing every tree fact the amendment asserts. That is open-ended and most asserted facts are illustrations, so the pass is bounded by the deltas’ predicates and the premises their rulings name.
The inferred marker carries a premise the pass could not run, in one of two forms, each opening its own line:
**Inferred, not run:** <claim> — <command>— the command is mandatory, since the marker exists to hand a later stage something to run.**Inferred, cannot run before build:** <claim> — <reason>— for a claim whose subject does not exist until build lands something; the reason is mandatory.
A marker opens its line, after optional indentation and one optional - or > lead, outside a fence. A mention of a spelling is written in a code span. The bold spelling anywhere else is a misplaced marker, because nothing tells it apart from a marker written in the wrong place. A marker whose command or reason is empty is malformed. §check-stage-entry assertion E refuses both at the commit that writes them. The spellings are kit constants, not config, on canon-kit’s precedent that a kit-shipped template’s own headings are constants. The marker sits on the passage and not only in the survey record: the record is boundary-truncated and holds a claim per block, not per passage, and a restatement drops the record’s flag. So the marker sits where the next reader reads, as the Not yet applied marker does.
A queue entry carries the marker too, written by every session that writes a new deferred entry (§The committed gap inbox), as a body paragraph of its own. In the deferred pool and the icebox no gate reads it. It is dropped with the body at an eviction and recovered with it (queue-kit/SPEC.md §The icebox tier), and it counts toward the entry cap like any body line (queue-kit/SPEC.md §check-queue-entry-budget).
Who runs the markers.
- The promoting stage runs an entry’s not-run markers before it promotes the entry: scope for debt, the authoring stage that pairs the entry for a feature. It corrects the entry and deletes the marker, or rewrites it to the cannot-run form. Scope owes debt this and not only assertion D, because a debt entry promoted straight into an active section meets no stage between scope and the audit-entry stage, so without it the first reader of the marker is the refusal.
- Align runs every not-run marker and build runs the cannot-run ones first, in an amendment or an active queue entry. §check-stage-entry assertion D already refuses the build entry on an unrun marker. Align is trigger-gated, so D holds the obligation when align is skipped, and align’s sentence keeps the refusal from being the first notice when it runs.
A delta whose act depends on a sibling unit sharing the build batch states both acts. The author writes the delta before the lead cuts the batches, so a delta written for one case is wrong for the other. The session that builds it has only the amendment to read, and the merged text outlives any prompt that corrected it. So the author states both acts on the passage, and the batch cut picks the one that applies. No check reads it: whether an act depends on the batch is semantic, and a check deciding it from prose would be a second, weaker judge of what the author already judges.
The scope template
The recurrence override decides the collision rather than resolving it in the theme’s favour. An automatic promotion would be a second intake path around scope-gated intake. The failure the override answers was a collision that reached nobody, never a ruling that went the wrong way. Its split rule is safe only against the pool total, because a split scatters an entry’s weight across siblings that only the sum puts back together.
The override counts two things for two decisions. The dates on [recurrence:] decide when an entry is proposed; the declines of that proposal decide who may refuse it. Lead discretion is bounded at two declines, so the party a counted recurrence was escalated to cannot defer it forever: a third routes to the operator. Declines are prose on the entry, with date and ruling party, not a declaration with a grammar, because the scope session composing the next escalation is their only reader and it already reads the entry’s grounds. The honest limit: the operator-routed mark travels on the message channel, so no gate observes that a lead relayed it rather than ruled it.
A unit set is weighed against the pool’s refill rate, because a set draining no more than inflow leaves the pool unchanged however well it ranks. drift-kit’s queue-flow arm derives the inflow figure from the pool’s slug sets at iteration-start commits (drift-kit/SPEC.md §The queue-flow arm), not from filing dates, which miss an entry filed and drained inside the window. Like the rest of the Composition line, the figure travels on the message channel, read by the party ruling on the set and by no gate. The widened third-tier fill is gated on it. Its cost basis is the lead’s surface batching: a further surface costs one more dispatch batch, where a further iteration costs another stage walk (templates/lead.md §Economics). The arm is a cross-kit citation, not a dependency: a consumer without drift-kit gets no figure, and the line says so.
The shortlist is tested for supersession because the inbound-edge read sees only citations, and one entry can moot another without citing it: they share a mechanism one replaces and the other patches. The pass is keyed on the mechanism’s names for that reason, and bounded by the shortlist because the ranking already reads those bodies and no others. Its line travels beside the Composition: line and is unconditional on the same ground: a none line names the searched terms, so a thin search shows as thin. The honest limit: mooting is semantic, so no gate judges the verdict, and a search whose names miss the shared mechanism reports none. The close stage’s moot sweep catches a mooting that lands, and the pool-wide triage the rest.
A unit set’s push need is counted because a push is an iteration’s scarcest spend, and without the count an overrun is visible only at close, as an operator ask the ruling on the set could have answered. The count is the closing push plus one per unit whose completion reads a remote run:
- an
[observed-by:]entry, whose push the placement rule already places (§The state machine); - a unit whose change a remote run executes first, whose red otherwise spends a watched push on code no local run reached.
Ruling the set rules the need, so an overrun costs no second interrupt. The ruling lands on each push-needing entry, because the landing session reads its entry; the closing push takes none, because every count includes it. A budget-exempt measurement-push class in the consumer’s binding is refused: it spends pushes nobody ruled on. The honest limit: the line travels on the message channel and no arm observes a push, so a count that misses a remote-only unit surfaces at that unit’s build as an escalation, never as a gated red.
The align template
The surviving-surface rule and the read-site verification each carry a ground. An amendment is deleted at merge, so a correction written into one and removed by the commit that lands it leaves no trace a later reader can recover. Authoring a producer silently asserts a consumer read side that rarely already matches.
Claim checks are scoped by subject and ungated. A behaviour claim about a program is neither a modelled grammar nor a seam-crossing literal, so align checks it against the program, invocation first: claim verification, not the verdict prediction Oracle-first forbids. A keeps list is the author’s claim, read as negative claims, never as scope. No scanner decides whether prose describes a program, so the cadence is align itself (§check-stage-entry, assertion C). The user-facing read is the second look at the authoring stage’s own judgment, and the last before build reads the mark as settled.
The build template
One fresh session per task, because a fresh session rehydrates the governing docs and queue state at full fidelity from disk, where an in-session summary erodes the approved plan and the re-derived premises.
An amendment’s roster is a floor the build re-derives, because a roster measured at authoring undercounts in a way reading cannot see. Two hit shapes produce the misses:
- a hit built by interpolation (a date or a name held in a shell variable) returns nothing to a literal, field-count or length sweep;
- a semantic reader (prose restating what a component does, a script reading a value it never names) spells no moved name at all.
Both misses are consequential, so “the tests still pass” reads true until the battery reaches the site. Such misses are caught by the battery and by the landing session’s own reading, never by the authoring stage’s roster or the audit reading it, so the obligation sits with the stage that runs the battery against the changed tree. Dropping the roster is refused: it would delete the discriminator canon-kit/SPEC.md §check-amendment-retired-spelling arm B reconciles survivors against. So is an oracle grepping an assertion idiom instead of a literal: it still spells a name, and a semantic reader spells none. The honest limit: no gate observes that the re-derivation ran. The battery reds on what it reaches, and a site no gate reaches is found only by the landing session’s reading, which the template obliges and does not verify.
The same re-derivation meets the over-count, judged by the delta’s need, never by an edit site. A rostered target with nothing to update tempts a fabricated edit or a silent drop, so the empty result is a stated finding. Its terms are the citing delta’s need and the passage that meets it, because edit-site terms can be true while the target’s purpose goes unmet: a declination on “no line to widen” can be confirmed and the need still be missing. A later reader who confirms a declination then confirms it against the author’s need. The honest limit: no gate reads a commit message’s declination, so one stated on edit-site terms is caught only by the reader who re-reads it. Narrowing rosters at authoring, when the roster was right, and a coverage assertion on every roster-shaped surface, a new oracle per surface, were both refused as heavier than one measured instance warrants.
The validate template
Validate carries the valve’s arming step, and close its disposition step. §bin/enter-stage.sh owns the valve’s contract, and both templates point at it. A validate that ends on a deliberately accepted red, a suite whose failure is understood and reproduces at the iteration-start commit, arms the valve for the closing stage and commits the ledger, rather than stopping for an operator round-trip.
Arming is not a queue write, which is what makes it validate’s move. A validate may not pre-empt the closing stage by writing the queue: a mid-iteration queue edit is what the gap inbox exists to prevent. Arming is an evidence-adjacent record on a surface validate already writes at this point, beside the evidence manifest and the baseline diff. The move the machine lacked was a hand-off, and the ledger carries it. The reason field is the hand-off’s payload, so validate writes what close needs: which suite, what the red is, why it is accepted rather than fixed, and the commit it reproduces at, which the row close lands must record. Close reads it as the input to the task it is about to file.
Validate commits a repair before the suite roster starts or resumes. A suite asserting a clean worktree against the real checkout reads an uncommitted repair as the failure of the leg it guards. That failure names the leg, so it reads as a regression from the iteration’s diff.
The close template
The lesson channel is consumer-optional, because the gap inbox carries what it carries. A lesson’s four dispositions each have a route without it. A →rule is the drain’s →fix when it adds no governed name and its →promote otherwise, a →task is →promote, and a →discard is →discard (§The committed gap inbox). A →harvest needs no section either, since the lesson-sink arm takes its body on stdin (queue-kit/SPEC.md §The lesson-sink arm), and a consumer may harvest its session record instead. The loss is [attend], a lesson’s injection into every later session of the same iteration, which only a live lead’s dispatch prompts carry without it. So the kit ships the channel on and a consumer turns it off by leaving the section out (queue-kit/SPEC.md §The tag algebra), and close’s step 1 and the boundary’s Lessons check each read an absent section as an empty one.
A used line is a close-stage obligation, not a free pass. For every used line in the closing iteration, close files the blocking task and lands the baseline row that names it, in that session. That is what makes the valve one-shot in substance and not merely in mechanism: the entry it bought is spent making the next iteration’s pre-flight pass without one. A close that enters through the valve and files nothing has moved the deadlock forward one iteration at the cost of a record. The filed task inlines the finding rather than pointing at the ledger, which the next boundary truncates, as the retrieval-pointer rule requires on its own grounds (§check-scratch-citation). A residual armed line is dispositioned in the same step and with the same weight: a session expected a refusal that never came, or armed the wrong stage, and either is worth one line rather than a file the boundary quietly truncates. The horizon is the iteration, because the ledger is truncated at the boundary, so “how many times did we reach for the valve” has a committed answer for exactly as long as anyone can act on it.
The ruling-record repair step sits with the staleness read, never with the brevity pass. Close reads the record for aged facts and fired discharge conditions, and repairs what the record’s own contract puts in a session’s hands.
- The record is named generically, and the step points at its contract for the boundary rather than restating it: write authority on a ruling record is not a kit template’s to grant (§templates/consult.md), so the kit adds obligation and the record grants authority.
- The placement is forced. A repair asks is it still true?, which is the staleness step. The brevity pass’s licence is compression, and a correction made under it would be a deletion wearing a compression’s clothes.
- A repair is fix-shaped by the drain’s existing litmus. It adds no governed name and lands test-and-doc-complete in the closing session’s own commit, so a two-word correction is never routed through a deferred entry.
- The obligation is derived. The record is a declared roster row (§The close-surface roster), so an unconfigured consumer sees the step skip.
- One act is a recording, not a repair. An operator directive discharged by falsification, its premise measured false rather than the work done, is recorded on the record as a closed-ruling line. Such a discharge leaves no landed unit and no queue trace, so a later session reading only the directive would re-derive the investigation that falsified it.
The moot sweep runs ahead of the Clear-Done step. It moves the live entries the iteration’s landings mooted to the done section, the exit queue-kit/SPEC.md §The icebox tier sanctions for them. Without it an obsolete entry lingers until a whole-pool triage reads it. It sits at close and not at build, on four grounds:
- close holds the iteration’s whole exit set and commit range, where a build batch sees only its own landing, so a moot two batches produce jointly is visible to neither;
- the done section still holds the exits in one place until close clears it;
- close already writes the deferred pool through its gap drain and its eviction, while build’s queue authority is the Done move for its own units;
- a build step repeats per batch over the same pool, where close sweeps it once.
The sweep’s candidates are derived, each source chosen for what it reaches. Entries citing a done slug cost no second run: the drain step’s retired-citation read already invokes queue-kit’s inbound-citation arm, whose retired block lists each cited done slug above its citing entries. The drain asks whether a citing line is a stale pointer, the sweep whether the citing entry is still open. A path the range deleted is taken without exclusion. A removed heading is read only from files the range modified, because a deleted file’s headings are an amendment’s template scaffolding, which no entry cites. The honest limit: the set reaches a referent removed at path or heading granularity, and an entry citing landed work by slug. A referent edited away below a heading, or a supersession landed outside the range, is left to the pool-wide triage. Whether an entry is mooted is semantic, so no gate judges the sweep; the battery, run before its commit, judges only that a move stranded no pointer.
The roadmap-motion read sits beside the moot sweep because both read the iteration’s exits against its range. A [roadmap:] tag states direction publicly, and nothing else notices when the iteration removes one. A Done move drops every tag, the roadmap arm prints an empty horizon as information (queue-kit/SPEC.md §The roadmap arm), and check-roadmap-fresh compares the projection with the tags, never the tags with direction. So close compares the arm’s output at the range’s start with its output now, and reads the first configured horizon (the first QUEUE_KIT_HORIZONS member) for vacancy. It asks the operator each finding, as the drain asks a direction question (§The consult inbox), since new direction is the operator’s, and skips one the drain already asked, so a standing vacancy is one question per close. Where no horizon is configured the arm exits 2 and the read is skipped. A gate reddening a vacant horizon is refused: a vacancy is a true state of the queue until the operator answers.
A settled horizon is re-tagged, not filed. An entry whose own recorded horizon condition the range met moves to the horizon it names, since asking would spend an operator question on an answer already on record. The lag read covers what the motion read cannot see. The motion read reports a slug leaving the projection and a vacant first horizon, and an entry left outside the first horizon while its work lands changes neither. A landed split slice is the mechanical half of that lag, so an arm reports it and the session judges the rest (queue-kit/SPEC.md §The roadmap-lag arm). Honest limit: a slice recorded only in its parent’s prose, and a met condition of any other shape, are found by reading, and nothing gates that any of the three reads ran.
The release-disposition step. Every close dispositions the iteration at the release boundary: it reads the consumer’s release-policy slot and either executes its release procedure or stamps an explicit no-release line into the consumer-named disposition-evidence file, <iteration> release <version|none|deferred:<version>> — <basis> (the check-lesson-disposition contract shape at the release boundary).
deferred:<version>carries a release the criteria earn but an operator ruling holds back, whichnone(“nothing to release”) cannot express. It carries a version because the earned bump level must survive: a baredeferreddrops it, and the next release re-derives which floor it inherits. The producer derives<version>as the version the criteria would have shipped as had the release not been held: the bump the note’s upgrade-contract sections floor, applied over the newest already-released note, never the next version the project happens to reach. That rule keeps the field mechanically derivable; without a defined scale the discharge comparison has none. The criteria are not fields on the line: until a release ships they accumulate on the release declaration surface (gate-sdk/SPEC.md §upgrade-smoke), and the note composed from it owns them after. The line carries the level.- Outstanding-deferral is derived, never tracked. A
deferred:<version>line is outstanding until a later line dispositions a release at or above<version>; that later line discharges it. The release happening is the discharge, so there is no second state to drift. That keeps a consumer’s gate over the value low-false-positive: a deferral cannot linger past the release that consumes it, and one not yet consumed should keep firing. - A reader deriving the outstanding set reads the file as history ∪ live, neither replacement nor fallback: the reader drift-kit/SPEC.md §The stage-economics meter applies to the stage stamps and drift-kit’s
trajectoryemit arm already ships. The file is typically aLIFECYCLE_KIT_BOUNDARY_TRUNCATEmember, so a carrying line survives only in committed history and a live-only reader sees nothing. A history-only reader is blind at the moment that matters most for a precommit gate: the pre-commit of the close commit writing thedeferred:line. The live arm covers the uncommitted tail, the history arm what truncation took, and the union costs nothing, since a line in both arms is one line. Truncation-immunity is a property of the reader, and every reader of a truncated evidence file needs it.
The kit wires no gate over the value, as with the release-sweep stamp file: the disposition file is operator evidence riding the release commit. The kit defines the value and the outstanding/discharged derivation, and a consumer may gate it. That split is the provenance seam: the grammar is generic lifecycle mechanism, the bump criteria are consumer release policy, so the kit ships no list of criteria. The step runs after the surface-mutating close steps and before the brevity pass, since the note is itself such a write; silence is not a disposition. The release-policy slot carries the consumer’s procedure and criteria by citation, the disposition-evidence path, and any boundary-only sub-procedure such as a major-only deprecation sweep. A consumer with no release process binds a plain none-every-iteration line. The disposition line’s mechanical reader is --enter-stage’s boundary require-check (§bin/enter-stage.sh, LIFECYCLE_KIT_BOUNDARY_REQUIRE) when a consumer wires the file into that knob.
The gap drain opens with a first read of its inputs: the surfaces the consumer’s drain-inputs slot names, skipping an empty or absent row (§The close-surfaces emit arm), before any bullet is dispositioned, the read recorded in the drain’s commit message. The kit owns the read’s placement and its record. Which surfaces it covers is consumer content. Not every capture surface bears on a bullet’s claim (a raw machine log does not). The kit depends on no other kit, so it cannot name another kit’s log. And the reader is a session rather than a gate, which makes the value a slot and not a knob. A first read and not a reorder: moving the roster sweep ahead of the drain would reorder one kit’s step against another kit’s triage template, renumber steps a binding cites by number, and sweep the drain’s own forced= row ahead of that drain. The honest limit: nothing gates that the read ran; the commit-message line makes a skip visible, not impossible, the posture the drain’s re-verification record already takes.
The push-identity precondition is per push, not per session. An identity selected at a session’s start is no evidence about the moment a later push happens. No gate substitutes for it, since an identity check grades that a login exists and never which one is selected. It binds a mid-iteration pusher too (§The state machine). The same step carries the push-budget slot, a slot and not a knob on the drain-inputs ground: its readers are sessions, the pushing session and the scope stage stating a set’s push need.
templates/release-sweep.md
A boundary skill, not a stage, so it sits at templates/ root beside lead.md: it invokes no --enter-stage and stamps no state, so check-stage-skill-coverage never reads it (it governs only the configured stage set). It is the deprecation disposition walk at a major, invoked from close’s release-disposition step when the derived bump is a major. It forces every marker on the CANON_KIT_DEPRECATION_MARKERS roster to a stamped disposition (decommission, carry-forward, or un-deprecate), the check-lesson-disposition contract shape at a release boundary. canon-kit’s check-deprecation-task holds each marker bound to a live task between majors, and this sweep forces the standing inventory to a decision at the boundary the deprecations were promised against. It carries named slots (inventory-command, evidence-gate), so it adopts the binding-shim grammar (§templates/stages/) and check-skill-binding holds the slot pairing. The stamp file is operator evidence riding the release commit; the kit wires no gate over it, though a consumer may, through evidence-gate.
templates/upgrade.md
upgrade.md is a boundary skill too, at templates/ root: it invokes no --enter-stage and stamps no state, so check-stage-skill-coverage never reads it. It is the phase-B disposition walk a consumer runs when moving their vendored kits from one release to the next. That is the judgment half of the two-phase upgrade contract, whose deterministic half (the wholesale kit-sync) and executable proof live in gate-sdk (gate-sdk/SPEC.md §upgrade-smoke), against the release-note grammar installer/SPEC.md §The upgrade contract owns. Its ritual has three steps:
- It registers the target note’s newly-declared gates, a new gate’s only delivery channel to an upgrading consumer, since the phase-A sync never re-runs the installer.
- It dispositions each red gate: fix-the-tree or exempt-with-cause, never a weakened gate.
- It closes on the semantic-residual audit. The upgrade is the cadence at which a consumer judges the ungateable third of a template-slot change: the shim fill that clears both
check-skill-binding(slot-set drift) andcheck-shim-restatement(verbatim copy) yet duplicates what the new slot now means to own.
It carries named slots (gates-list, disposition-evidence), so it adopts the binding-shim grammar (§templates/stages/) and check-skill-binding holds the slot pairing when a consumer binds it. This repo binds no command for it: as the kit source it is never a vendored consumer, so it never upgrades itself. The template ships for consumers, and the upgrade smoke exercises the mechanics it narrates.
templates/lead.md
The iteration lead template: an optional live session that dispatches an iteration’s stage sessions as background agents and answers their escalations, so a blocked stage resumes in place instead of stopping to surface to the user cold (§The state machine). Like release-sweep.md it is a boundary skill, not a stage. It stamps nothing through --enter-stage, running only the non-stamping --simulate, --dispatch, --dispatch-withdraw and --open-lead-journal forms, and it joins no stage set, so check-stage-skill-coverage never reads it. Like release-sweep it carries named slots, so it adopts the binding-shim grammar (§templates/stages/): a consumer copies-and-specializes it or binds it through a thin shim, and check-skill-binding holds the slot pairing either way (this repo’s .claude/commands/lead.md shim). Consumer residue stays in those slots:
open-authorization-channel— the one channel an open’s grant arrives on, and its cardinality;ruling-config— the tracked agent definitions the dispatch names, the stage-session type and a hotfix type where the consumer has one. Each carries its standing dispatch policy: the ruling-class roster, the posture, the tier assignment, and everything else true of every dispatch rather than improvised per prompt;escalation-guard— whether the consumer wires the optional escalation-shape guard (guard-kit/SPEC.md §wakeup-guard) or leaves it inert;consult-dispatch— the agent type a lead dispatches a consultation as, and the batching threshold on the consult inbox, or that the lead dispatches none.
An operator-ruled hotfix is a dispatch, never a track. The refused parallel hotfix track (§Deviation transitions) is a standing lane that contends on live stage surfaces. Doctrine-kit’s valve is one ruled fix, and the lead dispatches it only while no stage session is live, the condition a consultation dispatch takes. A hotfix and a consultation are never live together either: a dispatched consultation checks its tier by the newest transcript under the lead (§templates/consult.md), and a live hotfix would out-date it. Its type is never one LIFECYCLE_KIT_STAGE_SESSION_TYPES lists, whose stamp-before-write rule refuses a session that stamps nothing (§check-dispatch-entry). Its standing policy is the hotfix type’s definition, never a prompt, on lifecycle-kit/templates/lead.md §Policy is config, not prose.
The hotfix policy is: not a stage, one commit, the battery and every reached suite, stop on a design question. A hotfix landing after the closing push also pushes and watches every run it triggers to green, within the push budget’s hotfix allowance (§templates/stages/). That push is the hotfix’s because nothing else is left to spend it: the lead pushes nothing, and the closing stage has exited. The type is declared mutating, because isolation refuses a crate-source commit (gate-sdk/SPEC.md §check-crate-arms) and a gap filing (§The committed gap inbox), both of which a hotfix may need. The type is named in the ruling-config slot rather than a slot of its own, since check-skill-binding pairs slots by name and a new one would red every adopter’s shim for a type most consumers never dispatch.
Mid-iteration intake is a routing test, because wording is not shape. An operator asking for work mid-iteration names a timing, never a route, and a lead reading the timing as the route starts work no stage owns. The test’s order is the doctrine’s (doctrine-kit/DOCTRINE.md, Scope-gated intake): a question is answered where it stands, a failure is offered to the hotfix ruling, debt rides the drain that already fixes debt inline, and the rest is filed for scope. A refusal to take work into the current iteration is the anything-else route said aloud, not a further route: the lead files and carries the wish, and the next scope decides. The routes end where the lifecycle’s capture channels already do: a question at a live lead, work at the gap inbox. So an item another intake turns away has a destination here without a route of its own. Honest limit: shape is judged by the lead, and no gate reads a request.
The template owns the orchestration protocol whole. It carries the two postures: unified, where the scope session stays live as the lead, and split, where a routing-tier lead dispatches scope on the judgment tier and keeps it resumable as the iteration’s intent oracle. It carries the lead model and its dispatch precondition, and opening and closing an iteration. It carries the escalation protocol and its four-header block (Question / Options / Recommendation / Evidence), mid-iteration intake, the channel design, the rules for a running session, policy-as-config, the stamps-authoritative invariant, and the economics of batching, tiering and compaction. This section carries the grounds the template does not. The completion-notification dispatch precondition is standing lead policy under the policy-is-config rule; its honest limit, and the ground for naming the prompt-answered trap, are §The state machine’s.
Whether an iteration opens at all is the operator’s decision, and closing is that rule’s consequence. An open commits a scale of spend the operator chooses, even where the work’s content plainly routes through the lifecycle. That is why an instruction to fix filed work is no authorization, and why the template’s whole-authority sentence excludes opening.
- One channel, the one the consumer’s binding names. Explicit and separate are distinct requirements, and only the first is general.
- A cardinality, one by default, spent rather than renewed.
- The undirected path is scoped, not deleted: it is an authorized iteration carrying no theme. A lead-relayed directive is no evidence of authorization, so the surveying stage is asked for no check it holds less information to make.
- Closing is the same rule met at the boundary. The lead stops at the final stage’s completion and reports what it believes is owed, because the grant that paid for the iteration is spent. One rule seated on the grant covers both postures, since the unified posture’s lead is the previous scope session, sitting on preserved notes at exactly that moment. The honest limit is §The state machine’s.
The lead never selects the unit set. Selection is scope’s contract, and a lead-authored menu pre-empts scope’s premise re-verification. The lead relays the operator’s standing directive verbatim: a theme bounding scope’s survey, never a slug list. It routes scope’s proposed set back as an ordinary escalation once the set carries scope’s composition verdict and push need. That is a presence check and not a grading, because scoring the verdict’s argument would be selection by another route, and one no gate reads.
One class the lead never rules, under either posture. Reversing, demoting or re-scoping a recorded operator ruling or a stated objective is operator-class, and the lead relays it. The class is carved out of the rule that the lead rules what the governed surfaces answer, because a session holding contrary evidence reads the surface carrying the ruling as stale rather than as closed.
The lead drains the consult inbox by dispatch, in a quiet tree. A consultation writes the ruling record and the queue, so it runs only while no stage session is live, and the lead dispatches no stage beside it. Its agent type is never one LIFECYCLE_KIT_STAGE_SESSION_TYPES lists, whose stamp-before-write rule refuses a session that stamps nothing (§check-dispatch-entry). The batching threshold and the type are a slot rather than knobs, since no mechanism reads either; off is the default, and a consumer that wants fewer dispatches sets a count or an age. An operator-started consultation stays interactive and unchanged. Honest limit: whether a stage session is live is a fact about a session no read of the tree settles (§The state machine), so the quiet-tree rule is prose like the lead’s write bar.
A stage whose reading runs foreign is dispatched as an ordinary stage session, its prompt naming the adapter (§The host protocol). A lead that ran the foreign run itself would hold a report whose writes it may not perform.
The lead reads the executor binding before it dispatches a stage. The read is --emit knob-values LIFECYCLE_KIT_STAGE_EXECUTOR, the generic knob read, so no arm is minted. Where an element names the stage about to be dispatched, the lead names that adapter in the dispatch prompt, the one channel a stage session already reads it from, and tiers the host by what its writes demand (§The host protocol). The stage session’s side is unchanged: it follows the protocol because its prompt names an adapter, and never reads the knob. The binding is the default and the lead may withhold it for a cause. The adapter’s keyed verdict is read first with --foreign-run <adapter> --budget, by its verdict word and never its exit: a stale or unbudgeted reading exits non-zero and still dispatches foreign. On a PAUSE the lead dispatches the stage on the master harness and journals the line. A binding that always won would stall an iteration on another vendor’s window, and the host’s own fallback would discover the pause one dispatch later, at a tier chosen for a foreign reading. A dispatch may also name an adapter for a stage the knob leaves unbound.
A stage journal’s path is derived per stage. A per-batch filename empties that derivation, and the refusal lands cold on the next same-stage session. So the lead names no path in a dispatch, the entry report being the path’s one source.
Batching and tiering carry grounds of their own.
- The roster is every unit the iteration promoted, never the amendment set, which a debt unit carries no
[spec:]ref to join. - A shared surface groups a batch without ordering one. A cut across a producer/consumer edge between deltas dispatches the consumer against an input that does not exist yet. A push rider is such a consumer: batched after the push it rides, it buys a push of its own.
- Judgment is what the tier buys. Downgrading a design-bearing batch trades a large correctness risk for a small window saving. A stage-uniform class is a collapsed default, not a bound roster.
- An intra-stage batch split is N sibling stage sessions the lead dispatches and verifies, each a same-stage re-entry (§The state machine). A stage session never dispatches a sibling stage session, and that is a ban rather than a preference: a stage that sub-dispatches its own batches nests a second supervisor at the lead’s tier, hidden from the lead’s budget and context accounting. That is the redundancy the split posture exists to remove, reintroduced where nothing watches for it.
- The split posture’s saving is a trade, not a pure saving. Splitting converts the escalations a lead rules alone off a governed surface into relays. The template ships the method by which a consumer measures that trade; the threshold and every count taken stay the consumer’s.
The escalate-on-discovery transition sits on the tiering rule, not on any one stage. Every cheaper-tier dispatch rests on the premise that its work is mechanical, a stage-uniform cheaper default among them, and an all-mechanical build batch can still turn out to need a repair designed. The trigger is a fix the session would have to author: code, a contract, a fixture, or a diagnosis that crosses components. Running an oracle, editing a grammar-governed record and filing a finding are not triggers. The session commits whatever is complete and unrelated, and its escalation’s Evidence names the journal heading the re-dispatched session reads at entry, so the diagnosis is not bought twice. The lead rules it alone, because a batch’s tier is already its assignment. It re-dispatches rather than answering because a resume keeps the paused session’s tier: one lead turn and one session start, against a grind on the wrong tier nobody sees. A consumer that bound a stage-uniform cheaper tier keeps it, since one re-dispatch fits inside the binding. Honest limit: nothing detects a session that does not recognize its discovery. The transition makes the right move cheap and named, and a grinding session stays unseen.
The stamps-authoritative invariant is carried from §The state machine as the design’s load-bearing rule. The lead writes no lifecycle state: no stamp, no queue write, no evidence file, no push. A batch directed not to stamp spends the per-session audit trail unrepairably, and backdating falsifies the trail rather than restoring it. Two corollaries follow:
- The lead never hand-derives prior-stage completeness from WORKFLOW-STATE or the git log. It trusts
--enter-stage’s fail-closed refusal, gates each dispatch with the--simulateread, and reads that same drain-entry verdict before declaring a stage’s batches complete rather than after (§bin/enter-stage.sh). - A ruling whose acting session is not imminent is filed to a durable governed surface in the moment it is made.
Refused: a second assertion over the drain assertion’s own population. A batching roster has dropped a promoted unit in practice, and check-stage-entry named it at the refused entry. So the defect is detected late, at the price of one wasted dispatch, rather than undetected, and a duplicate reading of one fact would buy only the timing the simulate read already buys for free.
Relay-never-assert and the checked-figure rule rest on one asymmetry. A stage session writes lifecycle state under oracle-first, fixture pairs and a validate battery. The lead writes none and carries no verification discipline, yet its rulings steer what stage sessions land. Relaying an unverified duration or count into a dispatch therefore converts one session’s guess into standing instruction for every session after it, and nothing reds. The relay rule names four ways authority is added in transit:
- a claim sent above its tier;
- a grant sent as unspent after it was spent;
- a mechanism sent where only the constraint was the lead’s to set;
- a rule sent to a role its owning surface does not bind.
The lead caught none of the attested instances before the relay, and the receiving session declining to trust the prompt caught most of them, which is why the consumer’s agent definition carries the receiving half. A grant takes a spent state rather than a tier because the attested one was a fact gone stale between act and relay: a tier would have marked it correctly and still carried it. The honest limit: no gate reads a dispatch, and a tier or spent marker is written by the party it constrains, so the rule is prose on both ends. A role-scope scanner would first have to resolve a relayed sentence to its owning surface, which is semantic.
The lead’s first step writes the session-role marker, opens its journal and reads the model roster. The marker is what context-kit’s hook reads (context-kit/SPEC.md §The session-context hook). The journal is opened through --enter-stage --open-lead-journal (§bin/enter-stage.sh), whose keyed heading exempts the live lead’s segment from the boundary’s undisposed advisory. The roster read is drift-kit’s --price-coverage arm, a cross-kit citation rather than a dependency: an id the consumer’s price table has never priced is the roster churn templates/lead.md §Economics’ re-judge rule turns on, read before the first dispatch is tiered.
Both scratch artifacts outlive the iteration the boundary wipe reclaims, and that is one fact rather than two exceptions. A lead session is live at the boundary, filing a judgment there for the entering session’s intake, so both have a live session’s lifetime. Their protection sits at different tiers, deliberately. The journal’s is a kit invariant (LIFECYCLE_KIT_LEAD_JOURNAL_FILE, §bin/enter-stage.sh), because the kit mints the artifact and instructs the lead to store state in it. The marker’s lifetime is context-kit’s, so a consumer that wants it kept names it on LIFECYCLE_KIT_BOUNDARY_PRESERVE, which is that consumer’s declaration to make. Protection is not permanence: the lead’s disposition step and the boundary’s undisposed advisory keep the journal from becoming an accumulator nobody reads.
A keep-list candidate is judged by where its content is discharged, not by which session class wrote it. A consult session may span the boundary too, yet takes no preserve entry. Its rulings are discharged into a commit as each closes, so a preserved journal would outlive the session that can interpret it. A stage session’s journal takes none either, being spent by the boundary that reclaims it. A lead’s journal is the case discharged nowhere yet, the channel still being read.
Dispatch safety is inherited by citation, not re-owned. delegation-kit/SPEC.md §The delegation model owns background dispatch, the per-dispatch budget guard and the verify after any agent commit. One lifecycle instance is the template’s, because the generic rule would have to name a stage to state it. When the dispatched stage produces the evidence, the lead’s verify reads the committed evidence manifest and never re-runs its producer, since a battery re-run is inert and a producer re-run destructive. delegation-kit/SPEC.md §Verify after every agent commit owns the generic rule and that split; the instance’s honest limit is §The state machine’s.
While a stage session is live the lead writes nothing into the checkout, scratch included, and it reads a worktree’s provenance before calling it stale. A live stage keeps evidence in scratch too: its resume journal and its producers’ liveness records. A lead’s scratch write can also stop the stage, since a liveness record under a scratch directory blocks every session’s git writes while its pid lives (guard-kit/SPEC.md §The generic ruleset, rule git_mutation_under_producer). The lead keeps three writes: its journal, its session-role marker, and what the arms it runs write for it; the dispatch arms are among them because a second batch may be dispatched while a first is live. The provenance read is owed because the attested harm was a description: a lead called a live stage’s worktrees obsolete without reading where they came from, and they were removed on that description. Honest limit: no gate holds either rule. Whether a stage session is live is a fact about a session that no read of the tree settles (§The state machine), and in one checkout the shell guard cannot tell a lead’s write from the stage’s.
templates/consult.md
The operator strategy session template, and a boundary skill of the class §Layout and configuration enumerates: like lead.md it invokes no --enter-stage, stamps nothing and joins no stage set, so check-stage-skill-coverage never reads it. The classification is what makes it cost no new mechanism. A consultation may run before an iteration opens, between stages, or across an iteration boundary, and a template with no cursor is at odds with none of those. It carries named slots, so it adopts the binding-shim grammar (§templates/stages/) and check-skill-binding holds the slot pairing.
It is not a seventh stage. A stage needs a predecessor entry in LIFECYCLE_KIT_STAGES, a fixed position in the walk and a stamp, and a session that may precede or span an iteration has none of them. A consult stage would also make check-stage-entry assertion A demand a consult stamp on every iteration that holds no consultation.
What the template owns is a landing contract. The session’s exit condition has four parts. Every ruling the operator closed has reached a governed surface, and every request for advice the session took up is answered. Every refused alternative is recorded with its grounds, and every always-loaded surface those rulings stale is corrected, flagged or filed. It prevents a strategy session whose conclusions the next session re-derives from memory or never recovers. The contract reaches the consult inbox (§The consult inbox): an item the session takes up leaves the inbox only by a disposition landed in the same commit, so the inbox holds exactly what is still owed. An answer is not a ruling. A request for advice is answered in the session, and only the change it leads to lands, through the workflow that lands any change. So the ruling record stays an override ledger and never fills with recommendations nothing needed protecting from.
A dispatched consultation decides only what needs no operator, and advice needs none. A lead dispatches one for inbox items (§templates/lead.md), and no operator sits in that session. So it re-classes and discards alone, and answers a request for advice alone, because a judgment-tier recommendation is what the request asks for and the operator’s acceptance can follow it. What it lands carries the advice mark, so a later session tells model advice from operator direction. A ruling to reconsider waits for the operator: a relayed answer lands as a direction (§The steering vocabulary), never as a ruling.
Two mechanisms in the ritual make the contract reachable:
- A ruling lands at the moment it closes, not at exit, so an interrupted session loses at most the ruling in flight rather than its whole output.
- The session journals while it cannot commit, on the durability rule at its owning tier (delegation-kit/SPEC.md §Resume journal — agent writes, scratch reset sweeps). That turns a shared index held by a live stage session from data loss into latency.
The journal’s reclaim is the existing boundary wipe of the scratch dir (§bin/enter-stage.sh), and no LIFECYCLE_KIT_BOUNDARY_PRESERVE entry is added. A preserved journal would outlive the session that can interpret it, its content having been discharged into a commit at exit.
Write authority is not this template’s to grant. The consumer’s ruling record states who may record a ruling on it, at the widest tier true for every reader. Every session that closes a ruling with the operator faces the question, not only a consult session. The skill adds obligation: elsewhere recording a closed ruling is permitted, and here a consultation that did not record one has not exited.
Dispatch safety is inherited by citation rather than re-owned (delegation-kit/templates/agent-execution.md), on the rule §templates/lead.md follows: a copied protocol is a second content tier that drifts. The template states tier selection in its own right, because the hazard is sharper here than for a stage session. An unselected dispatch inherits the dispatcher’s tier, and a consultation runs at the judgment tier while most of what it dispatches is read-only research. So an unselected fan-out buys the most expensive tier for the cheapest work.
The template declares its own floor and checks it at entry. A consultation’s rulings are the tree’s most expensive answers to get wrong. A consultation run on a cheaper model leaves rulings indistinguishable from one run on the model the skill was built for, since a thin consultation reads as a short one. So the template names the floor as the class LIFECYCLE_KIT_CONSULT_CLASS holds, default judgment, and never as a model: the consumer’s tier binding owns the model (delegation-kit/SPEC.md §The tier binding), and the kit ships no model name (gate-sdk/SPEC.md §The provenance seam). This is a knob and still not a template slot. The refusal of a slot stands on its own ground, a residue every adopter’s shim would have to fill; a knob with a default asks nothing of an adopter who wants the default. An unverifiable tier proceeds and says so, on usage-verdict’s fail-soft rule: no reading is not a wrong reading. A lead dispatches a consultation only while no stage or hotfix session is live, and no hotfix beside it (§templates/lead.md). The check runs first, before the consultation dispatches anything. Those two facts make the bare verdict’s pick the consultation’s own transcript.
A consultation may commission an outside critique of the project, and only on the operator’s request in that session. The critique is an audit-mode foreign run (delegation-kit/SPEC.md §The foreign-vendor run) whose prompt is the shipped frame templates/frames/project-critique.md with the operator’s focus appended. The operator’s words are the run’s whole grant: a foreign run spends a window the iteration’s budget does not price, so no session starts one unasked. A consultation asked to refresh the consumer’s strategic assessment may offer one where LIFECYCLE_KIT_CRITIQUE_ADAPTER names an adapter (§Layout and configuration), and waits for the answer. A dispatched consultation runs none, since no operator sits in it to ask.
The consultation is both dispatcher and reflection pass, in one process with two shapes.
- Driven. It writes the frame and the focus to a prompt file in the scratch dir, reads the adapter’s budget and runs the unit under a key it names. Where the adapter resumes, it puts each clarifying question with
--foreign-resumeand closes the session when done. - Saved. The report of an earlier run is a file,
report.txtunder that run’s key or any path the operator hands over. The consultation reads it as it would the report of a run it drove, and asks nothing where the session is closed or the adapter is one-shot.
The executor already returns its work as a durable file, and a consultation already dispatches under the delegation protocol, so neither shape mints a channel. A separate saved-report store is refused. It would need a home that outlives the scratch dir, which the work-unit boundary wipes, and it would reach the consultation with no way to question its author. A report the operator wants kept across that boundary is theirs to copy out.
The frame asks for strategic weaknesses rather than defects. It tells its reader that it is in a clone at a committed revision, reading for a session that will check its work, that it writes nothing and runs only commands that change nothing. It asks four things: what the project claims that the tree does not back, where a prospective adopter would stop, what the project depends on that it does not control, and what its stated direction needs that is unbuilt. And it orders the report: findings with their evidence and whether each was measured or inferred, what the clone could not show, and the three findings to act on first. The frame names no vendor, no competitor and no assessment format, so it ships. A consumer wanting its own wording copies this template and cites its own frame, the fork mode every template has. A frame-path knob is refused, since its only reader would be the sentence that cites the file.
The reflection lands through the landing contract above, so no write path is minted. A critique’s findings are a child’s report, each checked against the tree or the consumer’s own record before it is built on. What survives is advice, and lands as advice does: a reply stays a reply, a tracked change is the deferred entry or gap bullet the operator names, and an edit to the strategic assessment lands on whichever entry-reading surface holds it. Nothing a critique proposes starts as work: it is filed, and scope decides (doctrine-kit/DOCTRINE.md, Scope-gated intake). A finding the consultation rejects is recorded with its grounds, as a refused alternative is.
Nothing private reaches the prompt unasked. The clone shows the foreign agent committed state alone. The consultation appends nothing read from a file the repository ignores unless the operator, in that session, names the content to send.
The step’s cases are crate tests: the validator’s refusal, and the shipped frame handed to an audit-mode run under a stub adapter with one stubbed resume turn. Honest limits:
- Nothing verifies the critic’s model. The run names the consultation’s class and the executor passes that class’s pair, which no vendor stream is parsed to confirm (delegation-kit/SPEC.md §The foreign-vendor run).
- A follow-up needs a resumable adapter. On a one-shot adapter the critique is one turn, and a question becomes a new run that has forgotten the first.
- The critic reads committed, tracked state. A strategic fact the consumer keeps outside the tree is invisible to it, so its report is one input to the assessment and never the assessment.
- The step is prose. No gate reads a consultation, as none reads a request (§templates/lead.md).
Consumer residue stays in exactly two named slots, split on the provenance seam. The critique step binds through a knob, which reds no adopter’s shim:
entry-reading, the surfaces a consultation reads on entry. A consumer’s entry set reaches its private context, and a kit literal naming it would publish a private surface.landing-surfaces, the surfaces a closed ruling may land on, by ruling class. A consumer’s landing set names its own governance layout, and a kit shipping those names would ship one project’s layout as everyone’s.
The kit keeps the shape: a consultation has an entry read-set and an exit landing-set, and every closed ruling reaches the landing set before the session ends. The entry step reads the first slot and the exit check the second, and check-skill-binding requires the shim to bind exactly that set, so an unbound slot or an orphan binding is caught mechanically.