Mission transition gates — declarative, asset-backed, trust-gated
Status: the gate mechanism is an accepted design, not yet shipped behaviour. The definition, trust and severity ADRs (2026-08-13-2, -4, -6) were amended and Accepted together on 2026-09-27; -3 and -5 keep their own status. This page is the design of record, not a description of shipped behaviour: today the only gate is the
spec-kitty-pre-reviewcode gate, and every gate fault fails open. The structural decision underneath it — the built-in mission subtree stays nested and the legacyMissionStepContractsurface is retired (ADR 2026-08-13-1) — is Accepted.
A transition gate is a deterministic check that runs when a work package crosses a lane
edge (e.g. in_progress → for_review) and returns a pass/fail verdict that can block the
transition. This page explains the declarative model that replaces the legacy
named-Python-handler registry.
The moving parts
| Concern | Answer | ADR |
|---|---|---|
| What a gate is | A first-class doctrine gate artefact kind (declarative YAML), reusable by id, tiered like every other kind. |
-2 |
| What the check is | Code shipped as an asset (inert blob, resolved by id), run as an allowlisted interpreter plus an args list (no shell string). |
-2 |
| Where it runs | An executionTarget surface selector (doctrine-owned token set) resolved through the existing stamped GateExecutionContext via the topology placement seam's execute_dir verb. |
-3 |
| Whether it may run | Trust: built-in trusted by install position; org/project packs are trust-on-first-use, keyed on the operator coordinate plus a locally computed hash of the executable surface. Consent lives in the user home; the repo may only pin hashes. Release signing is deferred. | -4 |
| What its outcome does | The outcome carries a typed severity (BLOCKING/RECOVERABLE/WARN/INFO); the operator's error-handling strategy (block_above(threshold) in .kittify) decides the CLI effect. The default blocks at BLOCKING only; trust/tamper outcomes are always BLOCKING. |
-6 |
| How fast (later) | Open. Parse-amortization is sound (content-addressed parse cache, no daemon); a deterministic verdict cache is contested and held open. | -5 |
Diagrams
Rendered locally by the no-egress PlantUML pipeline (
scripts/docs/plantuml_render.py,--network=none, SANDBOX profile). The distribution view uses PlantUML's native ArchiMate (bundled in the pinned jar — no!include, no egress).
Distribution & consumer value (ArchiMate)
Bounded contexts by layer (component)
Runtime flow + internal resolution (sequence)
Definition vs binding
Two separated concerns:
- Definition — the
gateartefact says what the check is: which asset holds the code, theinterpreterandargs, theexecutionTarget, the timeout, and the severity of a detected violation. - Binding — the
MissionStepsays when it fires: anon_transitionedge plus the gate id. Definition is reusable across steps and mission types; binding is per-step and ships in the per-type mission bundle.
# gate definition — packs/built-in/gates/docs-structural-lint.gate.yaml
id: docs-structural-lint
schema_version: "1.0"
description: "DIRECTIVE_042 structural lint for documentation missions"
asset: common-docs-structural-lint # code blob (asset kind, inert, resolved by id)
interpreter: python # structured invocation — no shell string
args: ["{asset}", "--strict"] # {asset} → resolved asset path, as one argv element
executionTarget: primary # doctrine-owned selector → placement seam execute_dir
timeout_seconds: 120
severity: RECOVERABLE # outcome severity; operator strategy decides the effect
# binding — packs/built-in/missions/mission-steps/documentation/validate/step.yaml
id: validate
# … unified MissionStep fields …
gates:
- on_transition: "in_progress->for_review"
gate: docs-structural-lint
Execution model
- A lane transition fires; the generic
declarative-gatedispatcher (alongside a few grandfathered code gates such asspec-kitty-pre-review) is invoked with aTransitionGateContext. - The dispatcher resolves the gate's
executionTargetselector through the placement seam, given the mission'sMissionTopology, to a stampedGateExecutionContext(not a bare path — the stamp is what refuses a surface that cannot hold the artifact). - It checks the pack's trust before resolving or running anything: built-in is trusted by
install position; any other pack needs a consent entry in the user-home trust store matching
the executable-surface hash, which is re-computed now, at dispatch. Untrusted, a hash
mismatch, or tampering is a could-not-run outcome at
BLOCKING— never a silent skip. - If trusted, it resolves the referenced asset to a path and runs the allowlisted
interpreterwithargsin the resolved context under the refuse-unconfinable baseline (env allowlist, process-group kill +setrlimit, path confinement, capped verdict channel). A host that cannot apply the baseline, a crash, a timeout, or a missing interpreter is a could-not-run outcome atRECOVERABLE; a detected violation carries the gate's declared severity. - The operator's error-handling strategy (
block_above(threshold), default: block atBLOCKINGonly) maps that severity to the CLI effect (block vs proceed-degraded). Repository config cannot demote trust outcomes belowBLOCKING, and every verdict records the effective strategy.
Invariants
- Gates are deterministic and side-effect-free — pure checks, never mutate mission state. This is enforced by the refuse-unconfinable baseline (#2540), not merely asserted; OS-level network denial is follow-on work (#2541).
- The
assetkind stays inert — resolved to a path, never self-executing. The gate is the only thing that turns a path into an invocation. - Trust is only ever consulted for executing code; inert doctrine needs no trust decision, and an edit to inert doctrine never changes the trust hash.
- Nothing is resolved or executed before the trust check passes. Trust is not containment: a trusted gate still runs under the baseline.
- There is one disposition model: every outcome (pass / failed / could-not-run) carries a
severity, and the operator's error-handling strategy decides the effect. Trust and tamper
failures are fixed at
BLOCKING, so a missing CI trust seed blocks loudly rather than silently no-ops; machinery faults are fixed atRECOVERABLE, so they proceed degraded and on the record (this keeps C-003, "never breakmove-task").
Relationship to the mission subtree
Gates that are mission-type dependent (documentation linting, per-type consistency checks)
ship inside the per-type bundle under packs/built-in/missions/<type>/; shared gates live at
the built-in tier and are referenced by id. This follows the nested, self-contained mission
subtree fixed by ADR 2026-08-13-1.