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-review code gate, and every gate fault fails open. The structural decision underneath it — the built-in mission subtree stays nested and the legacy MissionStepContract surface 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)

Gate distribution & consumer value (ArchiMate)Operatorbuilt-in pack(trusted by position)org / project pack(TOFU)gate defs + assetsOperator machine(CLI - optional daemon)Declarative gate engineMission transitionDeterministic governed checksshipsshipsresolved onconfigures strategy (.kittify)guardsrealizes

Bounded contexts by layer (component)

Gate subsystem - bounded contexts by layerkerneldoctrinechartermission_runtimespecify_cliERROR_SEVERITY laddergate ArtifactKind - BC1MissionStep.gates - BC2executionTarget token setasset - inert blobactivation and trust promptplacement seam + execute_dir - BC4declarative-gate dispatcher - BC3stamped GateExecutionContexttrust TOFU - BC5error-handling policyreferencesbinds by idreadsruns inresolves viaparity ACLchecksoutcome to effectusesprompt at activation

Runtime flow + internal resolution (sequence)

Gate flows - runtime-charter-gate and internal resolutionRuntimeCharterDispatcherDoctrinePlacement seamTrustAsset runnerPolicyRuntimeCharterDispatcherDoctrinePlacement seamTrustAsset runnerPolicyRuntimeCharterDispatcherDoctrinePlacement seamTrustAsset runnerPolicy1. Runtime calls gates through the chartertransition WP on_transitiondispatch bound gatesgate id to definition and assettrusted? re-hash executable surfacealt[untrusted or tampered]could-not-run BLOCKING[trusted]executionTarget to execute_dirstamped GateExecutionContextallowlisted interpreter + args, confinedJSON verdict or could-not-run RECOVERABLEoutcome and severityeffect via block_above strategyallowed or blocked or degraded2. Internal resolution and filespacks/built-in/gates/ID.gate.yaml then asset blob then executionTarget tokentoken + MissionTopology then surface then workdir (stamped)user-home consent store: operator coord + executable-surface hash; repo may pin hashes only.kittify strategy then ERROR_SEVERITY ladder (kernel)

Definition vs binding

Two separated concerns:

  • Definition — the gate artefact says what the check is: which asset holds the code, the interpreter and args, the executionTarget, the timeout, and the severity of a detected violation.
  • Binding — the MissionStep says when it fires: an on_transition edge 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

  1. A lane transition fires; the generic declarative-gate dispatcher (alongside a few grandfathered code gates such as spec-kitty-pre-review) is invoked with a TransitionGateContext.
  2. The dispatcher resolves the gate's executionTarget selector through the placement seam, given the mission's MissionTopology, to a stamped GateExecutionContext (not a bare path — the stamp is what refuses a surface that cannot hold the artifact).
  3. 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.
  4. If trusted, it resolves the referenced asset to a path and runs the allowlisted interpreter with args in 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 at RECOVERABLE; a detected violation carries the gate's declared severity.
  5. The operator's error-handling strategy (block_above(threshold), default: block at BLOCKING only) maps that severity to the CLI effect (block vs proceed-degraded). Repository config cannot demote trust outcomes below BLOCKING, 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 asset kind 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 at RECOVERABLE, so they proceed degraded and on the record (this keeps C-003, "never break move-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.