Data Model: Doctrine-Controlled Transition Gates (half A, epic #2535)

Phase: 1 · Traces: spec.md FR/NFR/C · Companion: research.md, contracts/

Every concrete surface below is named with its module home, fields, invariants, and transitions, and traced to FR IDs. Signatures are design intent (Phase 1), not committed code.


1. ScopeSource (port) — src/specify_cli/review/scope_source.py (new)

A typing.Protocol (@runtime_checkable, mirroring doctrine/sources/protocol.py:53) covering only the repo-shape-varying concerns. "Which files changed" is not on the port; it is the shared canonical merge-base+diff input, passed to the gate. (FR-001)

@runtime_checkable
class ScopeSource(Protocol):
    def test_command(self) -> list[str] | None:
        """Runnable argv the gate executes at head, or None when the repo
        declares no command (no-config → NO_COVERAGE warn, never a crash)."""

    def file_to_scope(self, path: str) -> tuple[str, ...]:
        """Map ONE changed file → zero+ test targets. () = contributes no
        scope (not an error). The internal impl narrows; the portable impl
        returns () (no narrowing → whole declared suite)."""

    def parse_results(self, raw: RawRunResult) -> tuple[BaselineFailure, ...]:
        """Turn a RAW (unparsed) run into per-failure identities. Internal impl
        parses JUnit XML from raw.output_artifact_path; portable impl parses the
        declared command's own stdout/stderr. RESTORED after the squad F1
        finding — without it the portable path is decorative (a failing suite
        collapses to NO_COVERAGE)."""

RawRunResult (new) — the port's parse input, produced by the engine running test_command() without parsing (home: src/specify_cli/review/scope_source.py):

@dataclass(frozen=True)
class RawRunResult:
    returncode: int
    stdout: str
    stderr: str
    output_artifact_path: Path | None   # e.g. the JUnit XML the internal impl wrote

Why not HeadRunResult. HeadRunResult (pre_review_gate.py:398-408) is already parsed (it carries current_failures and has no raw-output field), so feeding it to parse_results leaves the portable impl nothing to parse → it collapses to NO_COVERAGE (the exact decorative-gate regression this mission kills). The port consumes RawRunResult (pre-parse); the engine builds HeadRunResult from parse_results(raw) output, not the other way round.

Invariants.

discipline, protocol.py:16-17). test_command() -> None is the no-config signal, not an exception.

core.vcs.git.merge_base_changed_files via tasks_move_task.py:927), never a port method — the two impls cannot diverge on it. (FR-001)

head run (FR-011); it supersedes resolve_pytest_command (_interpreter.py:32) as a cross-repo concern and reconciles the third key review.pre_review_test_command (tasks_move_task.py:785).

  • Never raises for environmental problems — surfaces via return value (the OrgDoctrineSource
  • changed_files: tuple[str, ...] is passed in to the gate (shared SSOT from
  • The port is the sole test-command authority, consumed by baseline capture and the

1a. GateCoverageScopeSource (internal, behaviour-preserving) — FR-002

impl** (moved from the shared runner, pre_review_gate.py:656), not on the port.

_SRC_PACKAGE_PREFIX="src/specify_cli/" (L104) composite routing + _TESTS_PREFIX targets.

_parse_junit_xml (baseline.py:151, imported into pre_review_gate.py at :63), reused unchanged.

Spec-Kitty tree — zero behaviour change (NFR-001). Owns _load_gate_coverage_module (:167,185) and the demoted _is_spec_kitty_source_repo (:153) as private internals; the import_module lives ONLY here (FR-009).

  • test_command() → the incumbent pytest argv; injects --junitxml/-q **inside this
  • file_to_scope(path) → today's _gate_coverage census narrowing:
  • parse_results(raw) → parses JUnit XML from raw.output_artifact_path via
  • Obligation. Reproduces today's exact scope + pytest invocation + JUnit parsing on the

1b. DeclaredCommandScopeSource (portable default) — FR-003, FR-010

the whole suite (layout-agnostic; deliberately does not relocate #2330).

output_artifact_path) into per-failure BaselineFailure identities so a failing suite yields blocking-capable NEW_FAILURES (NFR-004), not NO_COVERAGE.

new vs baseline, not any failure. The portable path captures its baseline via the same declared command through the port (the same test_command() / parse_results()), persisted like the internal path's baseline-tests.json, and the gate diffs head vs that baseline. A naive parse_results = returncode != 0 (ANY_FAILURES) is rejected — it would block a consumer with a pre-existing red suite on every transition (a false-positive gate). A failure present at baseline → pre-existing → does NOT block; a failure absent at baseline but present at head → new → blocks.

optional output_artifact_path) — the unparsed run, NOT the post-parse HeadRunResult. Output = the set of failing test identities. Exit code alone is insufficient identity for the diff — the parser MUST yield per-failure identities so diff_baseline can classify pre-existing vs new. A non-zero exit with unparseable output → the whole run counts as failing (surfaced, never swallowed).

declared → test_command() -> None → gate is a visible NO_COVERAGE warn (FR-012).

baseline → transition NOT blocked; a newly-red test → transition blocked. This proves the baseline-relative semantics, not merely that the command ran.

  • test_command()shlex.split(review.test_command) or None when unset.
  • file_to_scope(path)() always — no per-file narrowing; the declared command runs
  • parse_results(raw) → parses the declared command's own raw.stdout/stderr (or its
  • Verdict is baseline-relative, NOT absolute. NEW_FAILURES keeps its incumbent meaning:
  • parse_results contract (explicit). Input = a RawRunResult (exit code + stdout/stderr +
  • Obligation. Never imports _gate_coverage; never assumes pytest/src/. No command
  • Regression fixture (NFR-004). A pre-existing-failure fixture: a suite already red at

2. GateOutcome — existing, pre_review_gate.py:742 (unchanged)

Six members, preserved verbatim (FR-013, C-003, NFR-001):

MemberShapeHard-stop?
NO_COVERAGEempty scope / run didn't complete / no-config / handler errorwarn
NO_NEW_FAILURESran, no new failures vs baselinewarn/pass
NEW_FAILURESran, ≥1 new failureblock iff block_enabled AND not force
UNVERIFIED_BASELINEbaseline uncomputablewarn
TIMED_OUThead run exceeded timeoutterminal interruption
CANCELLEDhead run interrupted (KeyboardInterrupt)terminal interruption

Fail-open-on-error mapping. Any handler execution exception (except KeyboardInterrupt, which maps to the terminal CANCELLED) → a NO_COVERAGE verdict with a visible "unverified" reason. The two hard-stops are the only non-completions (D-06).


3. GateBinding — contract-level schema on MissionStepContract.gates

New contract-level field gates: list[GateBinding] = Field(default_factory=list) on the legacy MissionStepContract (doctrine/missions/step_contracts.py:86, frozen + extra="forbid" + schema_version), authored in built_in_step_contracts/review.step-contract.yaml. It is contract/action-level, NOT per-MissionStepContractStep — a gate binds an action's transition, not an individual step. Adding to a frozen extra="forbid" model is a deliberate versioned evolution (FR-005). This home is the runtime-wired, activation-filtered mission_step_contract surface the executor consumes (executor.py:22-24,160,188), reconciling decisions #1 (attach to the review step-contract) and #2 (reuse mission_step_contract). The unified MissionStep (models.py:109) is not the home — MissionType.steps has no gate-time reader.

# authored in review.step-contract.yaml (action: review), alongside the contract's steps
gates:
  - on_transition: "in_progress->for_review"   # "<from_lane>-><to_lane>" edge key
    handler: "spec-kitty-pre-review"           # NAMED; a GATE_REGISTRY key only
                                               # (NOT a DRG candidate; activation keys on the contract URN)
    handler_kind: "mission_step_contract"      # default; inert-in-half-A discriminator
    schema_version: "1.0"                       # per-entry, versioned
    fail_open: true                             # default; unresolved/inactive => advisory
    provenance: "built-in"                      # optional
FieldTypeDefaultRule
on_transitionstrrequired"<from>-><to>" lane-edge key; both sides valid lanes
handlerstrrequiredGATE_REGISTRY key only, resolved by plain dict lookup at dispatch; NOT a DRG candidate (activation keys on the owning contract URN)
handler_kindLiteral["mission_step_contract","asset"]"mission_step_contract"inert in half A; asset round-trips byte-stable, never executed (FR-005, C-002, NFR-004)
schema_versionstrrequiredversioned; unversioned/malformed → loud reject
fail_openbooltrueunresolved/inactive binding → advisory (never hard-fail)
provenance`str \None`None

Validation invariants (FR-005, US3 AS3/AS4).

at load, never silently dropped.

  • model_config = ConfigDict(frozen=True, extra="forbid") — an unknown key is rejected loudly
  • schema_version required — an unversioned binding is rejected.
  • handler_kind: asset load→serialize is byte-stable and inert (no asset execution attempt).
  • provenance round-trips byte-stable (NFR-004).

4. GateHandler / GATE_REGISTRYsrc/specify_cli/review/gate_registry.py (new)

Mirrors mission_v1/guards.py:270 GUARD_REGISTRY (dict of name → entry). GateHandler is a frozen dataclass, NOT a bare callable, so dispatch is uniform across WP04/WP06/WP09:

@dataclass(frozen=True)
class GateHandler:
    name: str
    edge: str                                    # e.g. "in_progress->for_review"
    run: Callable[[TransitionGateContext], GateVerdict]

GATE_REGISTRY: dict[str, GateHandler] = {
    "spec-kitty-pre-review": GateHandler(
        name="spec-kitty-pre-review",
        edge="in_progress->for_review",
        run=_spec_kitty_pre_review_handler,      # first registered
    ),
}

def get_gate_handler(name: str) -> GateHandler: ...   # KeyError on miss = misconfig

GATE_REGISTRYname. WP06/WP09 must consume this exact shape, not invent a variant.

never redeclare it.

resolved ScopeSource, baseline, repo_root, force flag) and returns a GateVerdict (pre_review_gate.py:754). It must be pure-ish and self-contained; it never Exits — the hook owns exit/aggregation. (FR-004)

evaluate_pre_review_gate — after WP03 that function takes an injected ScopeSource (evaluate_pre_review_gate(scope_source, *, repo_root, baseline, ...)), NOT the pre-WP03 changed_files + filter_groups positional shape (pre_review_gate.py:853). WP04's handler and WP09's parity path bind to this one post-WP03 contract. Registry membership is the callable source; activation (on the owning contract URN) decides whether it runs (D-04). (FR-004)

  • Dispatch form. The hook calls get_gate_handler(b.handler).run(ctx) — never a bare
  • TransitionGateContext single home = gate_registry.py (WP04-owned); WP06/WP09 import it,
  • Named-handler contract. A run receives a TransitionGateContext (the changed-files SSOT,
  • First handler. _spec_kitty_pre_review_handler wraps the post-WP03

5. Binding resolution join — resolve_active_gate_bindings(...) (name)

Home: src/specify_cli/review/gate_bindings.py (new). Named the activation ⋈ binding join. (FR-007, NFR-005)

Pure-function extraction (complexity ≤ 15, NFR-006). resolve_active_gate_bindings( activated_msc_urns, owning_contract_urn, bindings, edge_key) -> list[GateBinding] is a standalone pure function — inputs in, active-binding list out, no I/O — with its own focused unit tests (positive + negative-control arms, NFR-003). The hook (_mt_run_transition_gates) stays a thin orchestrator: it performs the I/O (config read, graph load, contract load, dispatch) and delegates the join and the aggregation (§7) to pure functions it merely calls. This keeps each function at complexity ≤ 15 and testable in isolation.

The named binding loader. load_gate_bindings(repo_root, mission, action) -> list[GateBinding] reads the review contract's gates off the MissionStepContract model — the DRG carries no binding payload (drg/models.py:292-311). It delegates to MissionStepContractRepository.get_by_action(mission, action) (step_contracts.py:160) — the same repository the executor already uses (executor.py:160) — and returns that contract's gates. The mission param is required and load-bearing: get_by_action keys on (mission, action), and only software-dev ships a review action contract (research → gathering/methodology/output/ scoping/synthesis; documentation → accept/audit/design/discover/generate/publish/validate). The hook resolves the mission type from st.mission_slug → the mission's meta.json (mission type field), NOT hardcoded. This is the "named loader" FR-007 requires, on the runtime-wired legacy contract surface.

No-contract / no-binding path (mission-type-axis coupling guard). All missions share the 9-lane FSM and hit for_review, so a research / documentation / consumer WP resolves to no (mission, review) contract — or a software-dev contract with no for_review binding. This MUST NOT silently vanish the gate. It resolves to a visible NO_COVERAGE warn that is distinguishable from "handler not activated": the reason string names the missing-contract / no-binding cause (e.g. no gate binding for (<mission>, review→for_review)), NOT the generic inactive-advisory wording. The two conditions are separate branches, separately worded (FR-008, FR-012).

The join (two keys, FR-007). The activation gate is the owning review contract's URN, NOT the handler. The handler is a GATE_REGISTRY name (a plain dict key), never a DRG candidate. 1. Activated URN setPackContext.from_config(repo_root) (pack_context.py:184, fail-closed on OrgPackEnvVarUnsetError/OrgPackSubdirEscapeError per the copied _resolve_pack_context pattern, executor.py:275) → filter_graph_by_activation( load_validated_graph(repo_root), pack_context) (drg.py:433) → the surviving mission_step_contract node URNs. 2. Owning-contract URN — the URN of the contract get_by_action(mission, action) located, in the canonical form mission_step_contract:<mission-type>/<id> (e.g. mission_step_contract:software-dev/review, verified drg.py:271). This URN being among the survivors is what gates whether the review contract's bindings fire at all. 3. Retainresolve_active_gate_bindings keeps a binding iff b.on_transition == edge_key AND owning_contract_urn ∈ activated_msc_urns. The handler is then resolved by a plain GATE_REGISTRY[b.handler] dict lookup at dispatch (KeyError on miss = misconfig, surfaced) — it is not matched against a DRG URN.

> Why not gate on the handler URN. spec-kitty-pre-review is a registry name; there is no > mission_step_contract:.../spec-kitty-pre-review node, so _candidate_urn(handler) would return > None, membership would always fail, and active would be permanently empty → permanent > NO_COVERAGE (a decorative gate). Gating on the owning-contract URN is the only join that both > fires and satisfies the NFR-003 positive arm without a self-fulfilling mock.

Bounded cost (NFR-005). One graph load + one filter + one contract-bindings load per transition. The survivors are computed by a single set-membership test of the owning-contract URN — no per-node re-resolution, no per-candidate re-load.

Sequence (per transition).

target lane edge (from->to)
  └─ mission = resolve_mission_type(st.mission_slug -> meta.json)                # mission-type axis
  └─ map edge → owning action → (mission, review) contract (§6)                  # 1 table lookup
  └─ pack = PackContext.from_config(repo_root)           # fail-closed on env/escape
  └─ graph = filter_graph_by_activation(load_validated_graph(repo_root), pack)   # 1 load + 1 filter
  └─ contract = get_by_action(mission, action)                                   # review contract (+ its URN)
  │    (no contract / no for_review binding -> distinguishable NO_COVERAGE warn)
  └─ bindings = contract.gates                                                    # 1 load (review contract .gates)
  └─ active = resolve_active_gate_bindings(activated_msc_urns,                    # PURE fn
               owning_contract_urn=contract.urn, bindings=bindings, edge_key=f"{from}->{to}")
               #  retain iff on_transition == edge_key AND owning_contract_urn in activated_msc_urns
  └─ for b in active: GATE_REGISTRY[b.handler].run(ctx)  →  aggregate_verdicts (§7)   # plain dict lookup

Negative control (NFR-003). When the owning review contract's URN is not in the activated set (its mission type deactivated), every binding on it is absent from active — detectable, not silent. The resolution test carries a positive arm (contract URN activated → binding fires) and a negative-control arm (contract URN deactivated → binding does not fire); because the gate keys on a real graph URN (not the handler name), a test cannot be greened by a self-fulfilling mock — a test that would pass against an empty graph is rejected in review.


6. Lane-edge → (mission, action) → contract mapping (FR-008)

Explicit, deterministic table joining the WP-lane FSM edge to its owning (mission-type, action) and that action's step-contract (the binding home, D-01/D-05). The mission-type axis is required because get_by_action keys on (mission, action) and only software-dev ships a review contract:

Lane edge (on_transition)Owning (mission, action) → contractGated in half A?
in_progress->for_review(<resolved mission>, review)review.step-contract.yaml .gatesYes (reference cut)
for_review->in_review(<resolved mission>, review) → review contract .gatesschema-enabled; not required
in_review->approved(<resolved mission>, review) → review contract .gatesschema-enabled; not required

hardcoded. A (mission, review) that has no contract (research/documentation/consumer) or a contract with no matching for_review binding → distinguishable NO_COVERAGE warn (§5), not a silent vanish.

the other edges but the mission does not require gating them (Assumptions §).

(tasks_move_task.py), generalizing the existing in_progress->for_review special-case in coordination/status_transition.py::_prepare_event (L436-530).

  • <resolved mission> = mission type resolved from st.mission_slugmeta.json, never
  • Half A gates only in_progress->for_review (C-006). The schema (on_transition) admits
  • The lane FSM is status/wp_state.py (State-Pattern); the edge is observed at the CLI hook

Precedence / conflict rule (FR-008). When more than one activated binding matches the same on_transition edge on the same step, all fire — there is no last-wins override. Dispatch order is a stable sort by (declaration_index_within_step, handler) so the order is deterministic (NFR-001 requires a fixed dispatch order for parity). Their verdicts aggregate per §7; a conflict is a union, never a silent drop.


7. Verdict aggregation — aggregate_verdicts(verdicts, *, block_enabled, force) -> AggregateVerdict (FR-014, NFR-002, C-003)

A standalone pure function (home: src/specify_cli/review/gate_bindings.py), not logic inlined in the hook. It takes the ordered verdict list + block_enabled/force flags and returns the aggregate decision (terminal / block / warn-pass + the per-handler warnings). Its own focused unit tests cover the full outcome × precedence matrix (all six outcomes × terminal/block/warn × single/multi-handler), independent of any real handler.

Half-A reality (say it plainly). FR-014 multi-handler aggregation ships with only one production binding (the Spec-Kitty pre-review handler). The N-handler paths are therefore a seam exercised by synthetic handlers in tests onlyaggregate_verdicts and resolve_active_gate_bindings are unit-tested with fabricated verdicts/handlers; no second real handler exists in half A. This is deliberate forward-compatibility, not dead code, and the tests are the sole exercise of the multi-handler branches.

Given verdicts V = [v1..vn] gathered in the §6 dispatch order:

Deterministic precedence (highest first). 1. Terminal interruption — if any vi.outcome in {TIMED_OUT, CANCELLED}: hard-stop, set transition_applied=False, Exit(1) — preserving the incumbent order (checked before the block, tasks_move_task.py:1285 before :1298). 2. Block — else block iff block_enabled AND any(vi.outcome == NEW_FAILURES) AND not force. Exit(1). 3. Warn/pass — else the transition completes; each vi contributes at most one console warning.

Invariants.

(NFR-002).

handler's NEW_FAILURES from the block computation (US4 AS3). Aggregation reads every verdict.

KeyboardInterrupt → CANCELLED (terminal); any other Exception / GateAuthoritiesUnavailable → NO_COVERAGE "unverified" warn (mirrors the incumbent three-catch at tasks_move_task.py:1241/1248). (FR-013, NFR-002)

  • ≤1 warning per handler — a handler surfaces exactly one visible line, even on error
  • No cross-suppression — a faulting handler (degraded to NO_COVERAGE) never removes another
  • Fail-open per handler — each handler dispatch is wrapped in try/except:

8. TransitionGateContext — hook↔handler payload (home: gate_registry.py, WP04-owned)

Frozen dataclass passed to each GateHandler.run, carrying the shared, per-transition inputs so a handler resolves nothing itself (NFR-005). Single home = gate_registry.py; WP06/WP09 import it, never redeclare it:

FieldTypeSource
changed_filestuple[str, ...]merge-base+diff SSOT (tasks_move_task.py:927)
scope_sourceScopeSourceimpl chosen by activation, not by _is_spec_kitty_source_repo
baseline`BaselineTestResult \None`
repo_rootPathworktree or main repo root
forcebool--force flag
from_lane / to_laneLanethe edge being gated

FR trace summary

SurfaceFR/NFR/C
ScopeSource port (3 methods, shared changed_files)FR-001, FR-011
GateCoverageScopeSourceFR-002, FR-009, NFR-001
DeclaredCommandScopeSourceFR-003, FR-010, FR-012, NFR-004
GATE_REGISTRY / first handlerFR-004
GateBinding schema + handler_kindFR-005, C-002, NFR-004
Reuse mission_step_contract kindFR-006, C-001
resolve_active_gate_bindings (pure) + load_gate_bindings(repo_root, mission, action)FR-007, NFR-003, NFR-005
aggregate_verdicts (pure) — full outcome × precedence matrixFR-014, NFR-002, NFR-006
Lane→(mission,action)→contract mapping + precedence + distinguishable no-contract warnFR-008, FR-012
Retire dual selector / delete always-on importFR-009
Aggregation + two hard-stops + fail-openFR-013, FR-014, C-003, NFR-002
GateOutcome six members (unchanged)FR-013, NFR-001