Contracts

gate-binding-schema.md

Contract: Gate-binding YAML schema

Traces: FR-005, FR-006, FR-007, FR-008, C-001, C-002, NFR-004, US3 Home: contract-level gates: list[GateBinding] on MissionStepContract (doctrine/missions/step_contracts.py:86), authored in built_in_step_contracts/review.step-contract.yaml

A gate binding is a field on the review step-contract (a relationship/configuration on an existing artefact — the content-vs-relationship principle, C-001), NOT a standalone activatable artefact. It is contract/action-level (a gate binds an action's transition, not an individual step). It resolves through the existing mission_step_contract kind (FR-006) — the runtime-wired surface the executor consumes (executor.py:22-24,160,188) — reconciling decisions #1 and #2. No new gate kind; the unified MissionStep is not the home (MissionType.steps has no gate-time reader).

Schema

# round-trip: skip: illustrative GateBinding schema sketch — the executable GateBinding Pydantic round-trip (extra=forbid, byte-stable provenance/handler_kind) lives in tests/specify_cli/mission_step_contracts/test_gate_binding_schema.py
# authored in review.step-contract.yaml (action: review), alongside the contract's steps
gates:
  - on_transition: "in_progress->for_review"   # required
    handler: "spec-kitty-pre-review"           # required
    handler_kind: "mission_step_contract"      # default; inert-in-half-A discriminator
    schema_version: "1.0"                       # required
    fail_open: true                             # default true
    provenance: "built-in"                      # optional
FieldTypeDefaultRule
on_transitionstrrequired"<from_lane>-><to_lane>"; both sides valid lanes
handlerstrrequiredGATE_REGISTRY key only (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 but is never executed (C-002)
schema_versionstrrequiredversioned; unversioned ⇒ reject
fail_openbooltrueunresolved/inactive binding ⇒ advisory (never hard-fail)
provenance`str \None`None

Validation contract (model_config = frozen + extra="forbid")

inert (no attempt to execute an asset). This is the forward-compatible seam so #2599 (half B, executable assets) needs no breaking schema_version bump on this frozen model (FR-005, C-002, NFR-004, US3 AS4).

  • Unknown key ⇒ loud reject at load (US3 AS3), never a silent drop.
  • Missing schema_version ⇒ reject — an unversioned binding is invalid.
  • handler_kind: asset in half A — validated, round-tripped byte-stable, and treated as
  • provenance round-trips byte-stable (NFR-004).

Valid example (accepted)

# round-trip: skip: illustrative valid-instance sketch — accepted-input coverage lives in test_gate_binding_schema.py
gates:
  - on_transition: "in_progress->for_review"
    handler: "spec-kitty-pre-review"
    schema_version: "1.0"

Accepted: handler_kind defaults to mission_step_contract, fail_open to true.

Rejection examples

# round-trip: skip: illustrative rejection cases — the executable reject-on-load coverage (unknown key / missing schema_version / invalid handler_kind) lives in test_gate_binding_schema.py
# REJECTED — unknown key (extra="forbid")
- on_transition: "in_progress->for_review"
  handler: "spec-kitty-pre-review"
  schema_version: "1.0"
  retries: 3                    # <- not in schema

# REJECTED — missing schema_version
- on_transition: "in_progress->for_review"
  handler: "spec-kitty-pre-review"

# REJECTED — invalid handler_kind
- on_transition: "in_progress->for_review"
  handler: "spec-kitty-pre-review"
  handler_kind: "webhook"       # <- not in the Literal set
  schema_version: "1.0"

Half-B round-trip example (accepted, inert)

# round-trip: skip: illustrative half-B inert-asset sketch — the executable asset-kind byte-stable round-trip lives in test_gate_binding_schema.py
# ACCEPTED in half A; loaded, re-serialized byte-stable, NEVER executed
- on_transition: "in_progress->for_review"
  handler: "third-party-scanner"
  handler_kind: "asset"
  schema_version: "1.0"
  provenance: "org:acme-security-pack"

Resolution contract (how a binding fires) — FR-007, FR-008

1. The hook maps the lane edge to its owning action/contract (in_progress->for_review → the review action's review.step-contract.yaml; see data-model.md §6) and reads that contract's gates via load_gate_bindings(repo_root, mission, action)MissionStepContractRepository.get_by_action(mission, action) (step_contracts.py:160); the mission axis is resolved from st.mission_slugmeta.json. No (mission, review) contract or no for_review binding → a distinguishable NO_COVERAGE warn (§ no-contract path), not a silent vanish. 2. It computes the activated mission_step_contract URN set via filter_graph_by_activation (charter/drg.py:433). The gate is the owning review contract's URN (canonical form mission_step_contract:<mission-type>/<id>, e.g. mission_step_contract:software-dev/review, drg.py:271) — its presence in the activated set is what gates whether the contract's bindings fire at all. 3. A binding is active iff its on_transition matches the current edge AND the owning-contract URN ∈ activated set. The handler is then resolved by a plain GATE_REGISTRY[b.handler] dict lookup at dispatch (KeyError = misconfig); it is NOT matched against a DRG URN — a handler name is a registry key, and _candidate_urn(handler) would return None, permanently emptying active. 4. Contract-URN not activated (or no contract/binding) ⇒ fail_open advisory / distinguishable NO_COVERAGE warn (US3 AS2): the handler does not run, and the non-resolution is detectable (negative-control arm, NFR-003), not silent.

Precedence (FR-008)

Multiple activated bindings on one edge all fire (no last-wins). Dispatch order is a stable sort by (declaration_index_within_step, handler); verdicts aggregate per data-model.md §7.

Save round-trip / byte-stability (NFR-004)

gates: list[GateBinding] = Field(default_factory=list) — an absent gates key loads cleanly to [] (no author burden on the 15 existing contracts). But MissionStepContract.save() does model_dump(mode="json", exclude_none=True) (step_contracts.py:206), and [] is not None, so a naive re-save would inject a spurious gates: [] into previously-clean contracts. The save path MUST NOT reintroduce gates: [] into contracts that never declared it — use exclude_defaults=True (or an equivalent empty-omit) so a default-empty gates is omitted on serialize. A round-trip byte-stability test (load → save an unrelated contract → assert byte-identical, no gates: [] appears) guards this.

No new schema/doctor surface. The field needs no src/doctrine/schemas/*.schema.yaml and no doctor update: step-contracts are pydantic extra="forbid" self-validating, so the model IS the schema.

scope-source-port.md

Contract: ScopeSource port

Traces: FR-001, FR-002, FR-003, FR-009, FR-010, FR-011, FR-012, NFR-004 Home: src/specify_cli/review/scope_source.py (new) · Pattern: doctrine/sources/protocol.py:53

Interface

ScopeSource is a @runtime_checkable typing.Protocol covering only the concerns that vary by repo shape. "Which files changed" is NOT on the port — it is the shared canonical merge-base+diff input, passed to the gate.

@runtime_checkable
class ScopeSource(Protocol):
    def test_command(self) -> list[str] | None: ...
    def file_to_scope(self, path: str) -> tuple[str, ...]: ...
    def parse_results(self, raw: RawRunResult) -> tuple[BaselineFailure, ...]: ...

RawRunResult (new, scope_source.py) is the unparsed run the engine produces by executing test_command() without interpreting it:

@dataclass(frozen=True)
class RawRunResult:
    returncode: int
    stdout: str
    stderr: str
    output_artifact_path: Path | None

test_command() -> list[str] | None

The runnable argv the gate executes at head. None ⇒ the repo declares no command → the gate is a visible NO_COVERAGE warn (FR-012), never a crash and never a silent green.

file_to_scope(path) -> tuple[str, ...]

Map ONE changed file to zero-or-more test targets. () = "contributes no scope" (NOT an error). Called once per element of the shared changed_files input.

parse_results(raw: RawRunResult) -> tuple[BaselineFailure, ...]

Turn a completed head run into per-failure identities (not a bare pass/fail bit). Load- bearing (squad F1): without it the portable path is decorative — a failing non-pytest suite collapses to NO_COVERAGE instead of a blocking NEW_FAILURES. Exit code alone is insufficient identity: the diff (diff_baseline) needs stable per-failure identities to classify pre-existing vs new. A non-zero exit with unparseable output ⇒ the whole run counts as failing (surfaced, never swallowed).

Baseline-relative, NOT absolute. NEW_FAILURES means new vs baseline, preserving the incumbent meaning. The portable path captures its baseline by running the same declared command through the same port and persisting the parsed identities, then the gate diffs head vs that baseline. A parse_results = returncode != 0 (ANY_FAILURES) is forbidden — it would block a consumer with a pre-existing red suite on every transition. Both impls therefore feed baseline capture AND the head run from one authority (FR-011): baseline↔head symmetry is structural, not per-side.

Port-wide obligations

1. Never raise for environmental problems. Surface them via return values (the OrgDoctrineSource discipline, protocol.py:16-17). test_command() -> None is the no-config signal, not an exception. 2. changed_files is shared, not per-impl. The gate passes the merge-base+diff SSOT (core.vcs.git.merge_base_changed_files, via tasks_move_task.py:927) into the evaluation; neither impl re-derives it. This forbids the two impls diverging on the changed-file SSOT (FR-001). 3. Sole test-command authority (FR-011). The port is consumed by BOTH baseline capture (today review/baseline.py:124 _get_test_command) and the head run (today the hardcoded review/_interpreter.py:32 resolve_pytest_command). The third key review.pre_review_test_command (tasks_move_task.py:785) is re-pointed at the port or deprecated.

Implementation obligations

GateCoverageScopeSource (internal, behaviour-preserving) — FR-002, FR-009

Spec-Kitty tree — zero behaviour change (NFR-001).

pre_review_gate.py:656).

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

"tests.architectural._gate_coverage") (pre_review_gate.py:167,185) and the demoted _is_spec_kitty_source_repo (:153`) are private internals of this class only. The import is unreachable unless this impl is selected by activation (FR-009).

  • Reproduces today's exact scope derivation, pytest invocation, and JUnit parsing on the
  • Injects --junitxml/-q inside this impl (moved off the shared runner,
  • parse_results(raw) parses JUnit XML from raw.output_artifact_path via _parse_junit_xml
  • Owns the internal import. _load_gate_coverage_module / `import_module(

DeclaredCommandScopeSource (portable default) — FR-003, FR-010

suite** (layout-agnostic; does not relocate #2330).

blocking-capable NEW_FAILURES (NFR-004).

  • test_command() = shlex.split(review.test_command) or None.
  • file_to_scope(path) = () always — no narrowing; the declared command runs the **whole
  • parse_results(raw) parses the declared command's real pass/fail so a failing suite yields
  • Never imports _gate_coverage; never assumes pytest or a src/specify_cli/ layout.

No-config behaviour (FR-012)

test_command() -> None → the gate does not run and does not crash → GateOutcome.NO_COVERAGE visible warn (mirrors baseline.capture_baseline "No review.test_command configured; skipping", baseline.py:244). A block only ever fires on NEW_FAILURES (layered above by the hook), so absent config can never hard-fail a consumer. "Empty is never clean" (evaluate_with_scope L797-798).

Selection contract

The impl is chosen by charter activation, never by _is_spec_kitty_source_repo (FR-009). GateCoverageScopeSource is reachable only when the Spec-Kitty pre-review handler is the activated handler; otherwise DeclaredCommandScopeSource (or no gate at all) applies.

transition-gate-hook.md

Contract: inverted transition-gate hook

Traces: FR-004, FR-007, FR-008, FR-009, FR-013, FR-014, NFR-001, NFR-002, NFR-005, C-003 Home: _mt_run_transition_gates(st) — generalizes _mt_run_pre_review_gate (tasks_move_task.py:1160), same _do_move_task slot.

The hook inverts the relationship: instead of a hardcoded call to evaluate_pre_review_gate, it resolves which named handlers the repo's active doctrine binds to the current lane edge, dispatches each with per-handler fail-open, and aggregates to a verdict.

Behavioural pipeline

_mt_run_transition_gates(st):
  edge = (st.from_lane -> st.target_lane)                # e.g. in_progress->for_review
  mission = resolve_mission_type(st.mission_slug -> meta.json)   # FR-008 mission-type axis
  action = map_edge_to_owning_action(edge)               # FR-008 table (review action)
  pack = PackContext.from_config(repo_root)              # fail-CLOSED on env/escape (executor.py:275)
  graph = filter_graph_by_activation(load_validated_graph(repo_root), pack)   # NFR-005: 1 load
  contract = get_by_action(mission, action)               # review contract (+ owning URN)
                                                            # no contract / no for_review binding
                                                            #   -> distinguishable NO_COVERAGE warn
  active = resolve_active_gate_bindings(activated_msc_urns,                   # PURE fn
             owning_contract_urn=contract.urn, bindings=contract.gates, edge_key=edge_key(edge))
             #  retain iff on_transition == edge_key AND owning_contract_urn in activated_msc_urns
  verdicts = []
  for b in stable_sort(active, key=(decl_index, handler)):                    # FR-008 order
      try:
          verdicts.append(get_gate_handler(b.handler).run(ctx))              # FR-004 dispatch (dict lookup)
      except KeyboardInterrupt:
          verdicts.append(cancelled_verdict())                               # terminal (C-003 #2)
      except Exception:                                                       # FR-013 fail-open
          verdicts.append(unverified_warn_verdict(b.handler))                # NO_COVERAGE warn
  aggregate_verdicts(verdicts, block_enabled=..., force=st.force)             # PURE fn — FR-014 / §7

Resolution invariants (FR-007, NFR-003, NFR-005)

load_gate_bindings(repo_root, mission, action) reads the review contract's gates via MissionStepContractRepository.get_by_action(mission, action) (step_contracts.py:160), the runtime-wired surface the executor already uses. The mission axis is resolved from st.mission_slugmeta.json; a (mission, review) with no contract or no for_review binding → a distinguishable NO_COVERAGE warn (worded separately from "handler not activated"), never a silent vanish (FR-008, FR-012).

resolve_active_gate_bindings(...) are standalone pure functions with their own outcome × precedence unit tests; the hook only orchestrates (complexity ≤ 15, NFR-006). The multi-handler paths are a seam exercised by synthetic handlers in tests only — half A ships one real binding.

survivors by set-membership, no per-node re-resolution (NFR-005).

owning_contract_urn ∈ activated_msc_urns (canonical mission_step_contract:<mission-type>/<id>, drg.py:271). The handler is a GATE_REGISTRY name resolved by dict lookup, never a DRG candidate — gating on it would return None from _candidate_urn and empty active permanently (a decorative gate).

detectable (negative control: contract-URN activated → fires; deactivated → does not), never silently invisible, and not greenable by a self-fulfilling mock (NFR-003).

(copied _resolve_pack_context semantics, executor.py:275-280); this is distinct from the fail-open handler-execution rule.

  • Named loader is mandatory — the DRG carries no binding payload (drg/models.py:292-311);
  • Aggregation is a pure functionaggregate_verdicts(verdicts, *, block_enabled, force) and
  • Bounded cost — one graph load + one filter + one contract-bindings load per transition;
  • Activation keys on the owning-contract URN, NOT the handler — the retain predicate is
  • Non-vacuous — a binding on a contract whose URN is not activated is absent from active and
  • Fail-CLOSED on pack-context misconfig — an unset org-pack env var or subdir escape raises

Dispatch + aggregation invariants (FR-013, FR-014, C-003)

"unverified" NO_COVERAGE warn; KeyboardInterrupt maps to the terminal CANCELLED (mirrors the incumbent three-catch, tasks_move_task.py:1241/1248).

(TIMED_OUT/CANCELLEDtransition_applied=False, Exit(1)) > block (block_enabled AND any NEW_FAILURES AND not forceExit(1)) > warn/pass. Terminal is checked before the block, preserving the incumbent order (:1285 before :1298).

handler's NEW_FAILURES from the block computation (US4 AS3).

  • Fail-open per handler — every handler execution error degrades to exactly one visible
  • Deterministic aggregation precedence (highest first): terminal interruption
  • ≤1 warning per handler; no cross-suppression — a faulting handler never removes another
  • Two hard-stops only (C-003) — no new hard-stop may be introduced.

CLI-observable invariants

1. Spec-Kitty behaviour unchanged (NFR-001, SC-002). On the Spec-Kitty repo, the refactored hook produces identical (outcome, scope, transition metadata, block/exit, console) to the pre-refactor path across all six GateOutcome members and both hard-stops — proven through _mt_run_transition_gates by a golden comparison, not against the engine in isolation. Oracle provenance (anti-circular). The golden's expected tuples MUST be captured from base commit e4ef6e850 against the incumbent _mt_run_pre_review_gate before the refactor — a committed fixture, authored red-first against the OLD function. The oracle is NEVER regenerated from the new code (a self-referential oracle would prove nothing). The parity test is red until the refactored hook reproduces the base-captured tuples. 2. Consumer never imports _gate_coverage (FR-009, SC-001) — even under erroneous activation of the Spec-Kitty handler. When the handler is not activated, the import is structurally unreachable; when it is (erroneously) activated but the module is absent, the handler's own GateAuthoritiesUnavailable degrades to a NO_COVERAGE warn — the internal module import never succeeds. 3. No gate bindings ⇒ no gate — a repo whose active doctrine binds nothing to the edge transitions with no gate and no internal-path reference (US1 AS3). 4. Doctrine toggle, no Python edit (US3, SC-003) — activating/deactivating the binding's handler in doctrine flips whether the gate fires, with no code change between states.

What the hook does NOT change

_GUARDS) — the hook remains purely additive, after the guard sequence, before emit (_mt_run_pre_review_gate docstring, tasks_move_task.py:1163-1169).

action-step delegations, a different call path.

  • FSM edge adjacency and the pre-existing move-task guard sequence (tasks_transition_core.py
  • The changed-files SSOT (_mt_pre_review_changed_files, :927) — reused, not re-derived.
  • StepContractExecutor.execute() is mirrored, not called (D-09) — the executor resolves