Transition gates are declarative, asset-backed, first-class doctrine artefacts
Filename: 2026-08-13-2-gates-are-declarative-asset-backed-doctrine-artefacts.md
Status: Proposed
Date: 2026-08-13
Deciders: Operator (ATDD)
Technical Story: Follows ADR 2026-08-13-1,
which retires the legacy MissionStepContract surface that currently carries gate bindings.
Gate semantics must be re-homed onto the unified MissionStep model as part of that removal.
Post-dialectics revision (2026-08-13)
Hardened after the dialectics pass (work/gate-design-dialectics/, 99-COHERENCE.md). Settled deltas:
- KEEP — gate is a first-class
ArtifactKind(operator decision, over the dialectic's boilerplate objection). Accept the ~8 totality-map edits as the cost of a uniform kind model. - CHANGE — no shell
entrypoint. Invocation is a structuredinterpreter+argslist with{asset}as one argv element, no shell string (removes injection + thesys.executable-lacks-deps trap). - CHANGE — outcome is a structured JSON verdict carrying a severity, not exit-code + a fail-closed boolean. Block-vs-proceed is decided by the operator's error-handling strategy over the severity ladder — see ADR 2026-08-13-6. The "fail-closed by default" language below is superseded by that model.
- CLARIFY —
spec-kitty-pre-reviewstays a grandfathered code gate (its inputs are an injectedScopeSource+ baseline + changed-files SSOT, not cwd+exit-code). The dispatcher is "one generic declarative dispatcher plus a few code gates," not a full collapse. - ADD — determinism must be enforced by a sandbox (network-denied, bounded), not asserted.
Context and Problem Statement
Today a transition gate is a binding in a step-contract YAML — on_transition: "in_progress->for_review", handler: "spec-kitty-pre-review", fail_open: true — where
handler is a name into GATE_REGISTRY (src/specify_cli/review/gate_registry.py), a
Python dict whose run: Callable[[TransitionGateContext], GateVerdict] is compiled code.
The binding is data, but the check logic is Python registered in-process. There is
exactly one handler, and it is fail-open.
Two problems: (1) adding a gate requires a code deploy (a new registered handler), not a
doctrine edit, so gates cannot ship as part of a mission-type's doctrine pack; (2) retiring
MissionStepContract (ADR 2026-08-13-1) removes the surface the gate binding lives on, so
gate semantics must move to the unified MissionStep model regardless.
Gates are deterministic — a simple code check or a script (documentation linting, consistency checks, structural validation). That determinism is the lever: the check can be declared as data and shipped in the pack rather than compiled into the CLI.
Decision Drivers
- Gates should ship with the mission-type doctrine that needs them, per-type where relevant.
- Single canonical authority — one step model (
MissionStep), one gate mechanism. - Reuse the existing
assetkind (the sanctioned way to ship executable logic downstream) rather than invent a second code-shipping mechanism. - Determinism must be preserved and enforced (gates are pure checks, never mutate state).
Considered Options
- A — Keep the Python handler registry, add handlers as needed (status quo, code-deploy per gate).
- B — Declarative
gateartefact kind, check logic referenced from anasset, invoked via an entrypoint. - C — Inline command strings in the gate/step YAML (no asset), executed directly.
Decision Outcome
Chosen option: "B". A gate is a first-class doctrine ArtifactKind (gate), tiered
and DRG-participating like every other kind, reusable by id.
- Code ships as an
asset(unchanged loose-contract inert blob,mime+path, resolved to a path, never auto-executed). The gate references the asset by id. - The gate declares how to run it via an
entrypoint(a Docker-style oneliner, e.g.python {asset} --strict, where{asset}resolves to the asset path). Option C (inline command strings) is rejected: it creates an unsandboxed arbitrary-shell surface inside the pack for negligible convenience over an asset. - Fail-closed by default (
disposition: fail_closed) — a flip from today's lone fail-open gate. Exit0= pass; non-zero = fail; captured stderr becomes theGateVerdictreason. - Binding lives on the
MissionStep(gates: [{on_transition, gate: <id>, ...}]) — definition (what the check is) and binding (when it fires) are cleanly separated. This keeps the edge-scoped transition model andTransitionGateContext. GATE_REGISTRYcollapses to one generic dispatcher (declarative-gate) that reads the gate, runs the entrypoint against the referenced asset, and returns aGateVerdict. Named per-gate Python handlers are retired; the existingspec-kitty-pre-reviewgate is re-expressed declaratively (or kept as a single grandfathered code gate — see open Q).
Consequences
Positive
- Adding/changing a gate is a doctrine edit, shippable per mission-type in the pack.
- One code-shipping mechanism (assets); the asset kind keeps its inert loose contract.
- Determinism is enforceable (pure check, exit-code contract).
Negative
- "Execute an asset" is net-new machinery — assets are resolved to a path today and never executed (see ADR 2026-08-13-3 for where it runs and ADR 2026-08-13-4 for whether it may run).
- Fail-closed-by-default is a behaviour change; existing flows must be audited for gates that were relying on fail-open.
Neutral
- A new
gateArtifactKindis added shortly aftermission_step_contractis removed — net kind count roughly unchanged, but the new kind is declarative-data, not a code registry.
Open questions (for the dialectics squad)
- Does
spec-kitty-pre-reviewbecome a declarative gate, or stay a single grandfathered code gate? - One asset per gate, or may a gate carry its own blob (collapsing asset+gate for non-reused checks)?
- Gate→asset DRG edge: reuse
requires, or a dedicated relation? - Where do gate inputs come from (env vars vs args vs cwd contents)? (Depth handled in ADR -3.)