Mission transition gates — declarative, asset-backed, trust-gated
Status: the gate mechanism is design (Proposed); the structural decision it builds on is Accepted. The declarative gate-mechanism ADRs (2026-08-13-2 … -6) are Proposed — this page is the design of record for them, hardened before implementation, not a description of shipped behaviour. The structural decision underneath it — the built-in mission subtree stays nested and the legacy
MissionStepContractsurface 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), invoked by the gate's entrypoint oneliner. |
-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 (release-signed target); org/project packs are trust-on-first-use, keyed on operator coordinate + content-hash. | -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. |
-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, theentrypoint, theexecutionTarget, the timeout, and the fail disposition. - 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 resolves the pack's trust (operator coordinate + content-hash). An untrusted pack is a could-not-run outcome carrying a severity — it is not a silent skip.
- If trusted, it resolves the referenced asset to a path and runs
interpreter+argsin the resolved context under a bounded, network-denied sandbox. The structured outcome (pass / failed / could-not-run) carries a severity; the operator's error-handling strategy (block_above(threshold)) maps that severity to the CLI effect (block vs proceed-degraded).
Invariants
- Gates are deterministic and side-effect-free — pure checks, never mutate mission state. This must be enforced by the sandbox (network-denied, bounded), not merely asserted.
- 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.
- There is one disposition model: every outcome (pass / failed / could-not-run, the last including untrusted) carries a severity, and the operator's error-handling strategy decides the effect uniformly — so a security gate fails closed by default regardless of why it did not pass, and a missing CI trust seed blocks loudly rather than silently no-ops.
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.