Contracts

baseline-identity-contract.md

Contract: baseline source/parse-mode identity + lifecycle + #2874 read seam

Traces: FR-008, FR-009, FR-010, FR-012, NFR-005, C-005 Homes: baseline.py (BaselineTestResult, capture), pre_review_gate.py:851-909 (head compare), tasks_move_task.py:1282-1303 (#2874 kind-aware read)

The identity field

# round-trip: skip: illustrative field add — executable round-trip in tests/review/test_baseline.py
@dataclass(frozen=True)
class BaselineTestResult:
    ...                                   # existing fields, baseline.py:66-75
    source_identity: str = "unknown"      # NEW: "<SourceClass>/<parse_mode>"
AspectValue
Format"<SourceClassName>/<parse_mode>"
parse_modejunit_xml, text, none, unknown
Producerscope_source_identity(scope_source, raw) (new, scope_source.py) — ONE helper, BOTH sides
to_dictadd "source_identity": self.source_identity (baseline.py:77-89)
from_dictsource_identity=data.get("source_identity", "unknown") (baseline.py:91-105)

Parse-mode derivation (mirrors parse_results, scope_source.py:518-540):

Condition on rawparse_mode
raw.output_artifact_path existsjunit_xml
no artifact, FAIL-lines present in stdout/stderrtext
no artifact, non-zero exit, nothing parseablenone

Backward-compat obligation (US1 AS4 / FR-009)

# round-trip: skip: illustrative degradation table, not a fixture
legacy_artifact_without_field:
  from_dict: source_identity -> "unknown"        # no KeyError
  head_diff_behaviour: UNVERIFIED_BASELINE        # never SOURCE_MISMATCH, never a crash
sentinel_baseline (failed == -1):
  unchanged: diff_baseline returns everything-new  # baseline.py:561-562, untouched

Parse-mode matching at diff time (FR-009)

Runs in the injected-ScopeSource head path ONLY (_evaluate_via_scope_source, pre_review_gate.py:851-909) — NEVER the FR-004 shared-override tier (evaluate_with_scope(scope_source=None), pre_review_gate.py:952-985; no injected source, no recorded identity).

# round-trip: skip: illustrative diff-time gate — executable coverage in tests/review/test_pre_review_gate_engine.py
head_identity = scope_source_identity(scope_source, raw)          # after the head run
if baseline is not None and baseline.source_identity != "unknown" and head_identity != baseline.source_identity:
    return GateVerdict(outcome=GateOutcome.SOURCE_MISMATCH, scope=scope,
                       reason=f"baseline captured under {baseline.source_identity}; "
                              f"head ran under {head_identity} — failure identities are not comparable")
failures = scope_source.parse_results(raw)
return _classify_current_failures(failures, scope=scope, baseline=baseline)
baseline statehead identityverdict
NoneanyUNVERIFIED_BASELINE (existing, _classify_current_failures :785-791)
source_identity == "unknown"anyUNVERIFIED_BASELINE (legacy degrade)
known, equalequalnormal NO_NEW_FAILURES / NEW_FAILURES diff
known, differsdiffersSOURCE_MISMATCH (warn, fail-open)

Artifact lifecycle (FR-008 / B1) — read/relocate BEFORE teardown

# round-trip: skip: illustrative before/after — executable coverage in tests/review/test_baseline.py (FR-010 parity)
# _capture_baseline_via_scope_source, baseline.py:491-536
with _baseline_worktree(repo_root, base_branch) as tmp_worktree:
    if tmp_worktree is None:
        return _make_sentinel(...)
    raw = _run_command_for_baseline(command, cwd=tmp_worktree)
    failures = tuple(scope_source.parse_results(raw))                 # MOVED INSIDE the with (was :522, post-teardown)
    identity = scope_source_identity(scope_source, raw)              # recorded at the same point
# ... build BaselineTestResult(..., source_identity=identity, failures=failures)

Invariant. For DeclaredCommandScopeSource writing a worktree-relative --junitxml, the artifact is parsed while the worktree still exists → baseline identities share the head run's namespace. For GateCoverageScopeSource (absolute tempfile JUnit, scope_source.py:429-433) behaviour is unchanged.

FR-010 parity test matrix

Baseline and head MUST land in the same failure-identity namespace across all three:

SourceArtifactZero-new-failure change → must NOT be NEW_FAILURES
GateCoverageScopeSourceabsolute JUnit tempfile
DeclaredCommandScopeSourceworktree-relative JUnit (--junitxml)✓ (the B1 case)
DeclaredCommandScopeSourceFAIL-text convention

Plus a parity assertion: baseline source_identity == head identity in each case (NFR-005).

#2874 kind-aware read seam (FR-009 / D-6)

The diff-time load MUST consume the already-merged seam, not reconstruct feature_dir:

# round-trip: skip: illustrative — merged code at tasks_move_task.py:1296-1303
baseline_read_dir = _resolve_workflow_read_dir(              # workflow.py:573-587
    repo_root=st.main_repo_root, mission_slug=st.mission_slug,
    kind=MissionArtifactKind.WORK_PACKAGE_TASK,             # C-008, coord-topology safe
)
return BaselineTestResult.load(baseline_read_dir / "tasks" / wp_slug / "baseline-tests.json")

The new source_identity rides on the loaded object — the read site needs no change. Reconstructing a feature_dir here is a regression (reopens the coord-husk bug #2874 closed).

Anti-narrowing guard (FR-012 / C-005)

A focused test asserts the baseline command is run WITHOUT head's per-file scope.test_targets appended. Command authority is unified; scope legitimately differs (baseline broad, head narrowed). The head path appends targets (pre_review_gate.py:889[command, scope.test_targets]) but the baseline path (_run_command_for_baseline, baseline.py:454-488) runs the bare command — the guard pins that a future refactor cannot silently narrow the baseline. </content>

gate-outcome-contract.md

Contract: GateOutcome.SOURCE_MISMATCH — warn-shaped, fail-open by construction

Traces: FR-011, SC-004, C-003 (no new hard-stop) Homes: pre_review_gate.py:748-756 (member), verdict_aggregation.py:58-138 (allowlists), tasks_move_task.py:1156-1184 (console ladder)

The member

# round-trip: skip: illustrative enum add — executable coverage in tests/review/test_pre_review_gate_engine.py
class GateOutcome(StrEnum):
    NO_COVERAGE = "no_coverage"
    NO_NEW_FAILURES = "no_new_failures"
    NEW_FAILURES = "new_failures"
    UNVERIFIED_BASELINE = "unverified_baseline"
    TIMED_OUT = "timed_out"
    CANCELLED = "cancelled"
    SOURCE_MISMATCH = "source_mismatch"   # NEW — warn-shaped, distinct reason
PropertyValue
Verdict shapewarn: transition_applied=True, run_state=COMPLETED, distinct reason
Emitted by_evaluate_via_scope_source (pre_review_gate.py:851-909), at diff time, when head identity ≠ a known baseline.source_identity
NOTNO_COVERAGE (empty-scope/no-config) and NOT NEW_FAILURES (block) — never overload either
reasonnames both identities, e.g. "baseline captured under GateCoverageScopeSource/junit_xml; head ran under DeclaredCommandScopeSource/text — failure identities are not comparable"

Fail-open-by-allowlist proof obligation

Both hard-stop paths are member-explicit allowlists, so a new member fails open automatically. The mission MUST assert this with a test and MUST NOT edit the filters (FR-011).

PathSiteFilterSOURCE_MISMATCH present?
Terminal (hard-stop)verdict_aggregation.py:58-60 (_TERMINAL_OUTCOMES), consumed :99-104frozenset({TIMED_OUT, CANCELLED})no → never terminal
Block (hard-stop)verdict_aggregation.py:138, predicate _should_block :107-111v.outcome is GateOutcome.NEW_FAILURESno → never blocks
Warn / proceedverdict_aggregation.py:148-154fall-through defaultyes → WARN_PROCEED

Assertion (SC-004): feed aggregate_verdicts([GateVerdict(outcome=SOURCE_MISMATCH, …)], block_enabled=True, force=False)decision == WARN_PROCEED, should_exit is False, transition_applied is True. Do the same via a full for_review transition and assert the move completes (no Exit(1)), a SOURCE_MISMATCH console warn is shown, and it is neither a silent pass nor a NO_COVERAGE.

Console-ladder invariant (the ONE live edit)

_mt_pre_review_gate_console_warning (tasks_move_task.py:1156-1184) today ends in an unconditional fall-through (:1184return "[dim]…no new failures[/dim]"). Any non-handled member renders as a clean pass. Rewrite:

# round-trip: skip: illustrative ladder shape — executable coverage in tests/.../test_tasks_move_task*.py
    if outcome is GateOutcome.NEW_FAILURES:
        ...                                              # unchanged (:1166-1173)
    if outcome in (GateOutcome.NO_COVERAGE, GateOutcome.UNVERIFIED_BASELINE):
        ...                                              # unchanged (:1174-1181)
    if outcome in (GateOutcome.TIMED_OUT, GateOutcome.CANCELLED):
        ...                                              # unchanged (:1182-1183)
    if outcome is GateOutcome.SOURCE_MISMATCH:           # NEW explicit branch
        return f"[yellow]Pre-review regression gate: source/parse-mode mismatch — {verdict.reason}[/yellow]"
    if outcome is GateOutcome.NO_NEW_FAILURES:           # was the implicit fall-through
        return "[dim]Pre-review regression gate: no new failures[/dim]"
    return f"[dim]Pre-review regression gate: {outcome.value}[/dim]"   # DEFENSIVE else — never a green pass

Invariant: after this change no GateOutcome member renders as "no new failures" unless it is NO_NEW_FAILURES. A test enumerates every member through the ladder and asserts none but NO_NEW_FAILURES yields the clean-pass string; the defensive else renders outcome.value.

What this contract does NOT change

member-explicitness IS the fail-open proof.

</content>

  • _TERMINAL_OUTCOMES and the NEW_FAILURES block filter — left exactly as-is (C-003); their
  • aggregate_verdicts precedence / AggregateDecision (verdict_aggregation.py:63-154).
  • GateVerdict dataclass (pre_review_gate.py:759-768).

scope-source-contract.md

Contract: ScopeSource predicates, factory port, and breakdown mixin

Traces: FR-003, FR-005, FR-006, FR-007, NFR-005 Home: src/specify_cli/review/scope_source.py · Consumers: pre_review_gate.py, tasks_move_task.py, baseline.py

This contract replaces the single welded isinstance(scope_source, ScopeBreakdownSource) decision (two sites: pre_review_gate.py:881 and :1013) with two independently-evaluable predicates, hoists the source factory to one shared home, and makes file_to_scope a default projection over scope_breakdown via an ABC/mixin.

Two independent predicates (FR-005)

Each predicate reads a different signal, so a source may satisfy one without the other.

PredicateSignatureBacking signalGateCoverageScopeSourceDeclaredCommandScopeSourceSynthetic split
exposes_scope_breakdown(source: ScopeSource) -> boolisinstance(source, ScopeBreakdownSource) (has scope_breakdown)TrueFalsemay be True while policy False
empty_scope_is_coverage_gap(source: ScopeSource) -> boolgetattr(source, "treats_empty_scope_as_coverage_gap", False) (ClassVar marker)TrueFalsemay be True while capability False

Invariant (weld removed, not renamed). The two predicates MUST NOT share a signal. A test constructs a synthetic source that satisfies exactly one (US3 AS3) and asserts each predicate returns its declared value independently.

Consumer rewrite:

# round-trip: skip: illustrative call-site rewrite — executable behaviour in tests/review/test_pre_review_gate_engine.py
# pre_review_gate.py:881  (policy site — empty derived scope ⇒ NO_COVERAGE)
if empty_scope_is_coverage_gap(scope_source) and scope.is_empty:
    return GateVerdict(outcome=GateOutcome.NO_COVERAGE, scope=scope, reason=scope.describe_empty_reason())

# pre_review_gate.py:1013  (capability site — build the full breakdown metadata)
if exposes_scope_breakdown(scope_source):
    return _scope_result_from_breakdown(scope_source, changed_files)

Behaviour preservation (FR-005/FR-007). For the two shipped sources the verdict is byte-identical to the pre-split isinstance behaviour: GateCoverageScopeSource → both True; DeclaredCommandScopeSource → both False.

file_to_scope as a default projection (FR-006)

An ABC/mixin (proposed ScopeBreakdownMixin) — NOT a Protocol default, which never reaches a structural implementer:

# round-trip: skip: illustrative mixin shape — executable behaviour in tests/review/test_scope_source.py
class ScopeBreakdownMixin(abc.ABC):
    treats_empty_scope_as_coverage_gap: ClassVar[bool] = True

    @abc.abstractmethod
    def scope_breakdown(self, path: str) -> FileScopeBreakdown: ...

    def file_to_scope(self, path: str) -> tuple[str, ...]:
        return self.scope_breakdown(path).test_targets
ClassInherits mixinDefines scope_breakdownDefines file_to_scopetreats_empty_scope_as_coverage_gap
GateCoverageScopeSourceyesyesinherited (drops hand-written :355-362)True
DeclaredCommandScopeSourceno (structural ScopeSource)no() always (:508-516)False (default)

Obligation. GateCoverageScopeSource still satisfies both ScopeSource and ScopeBreakdownSource structurally; DeclaredCommandScopeSource still satisfies ScopeSource structurally with no scope_breakdown. @runtime_checkable isinstance checks are unchanged.

Shared factory port (FR-003 / NFR-005)

# round-trip: skip: illustrative factory signature — executable behaviour in tests/review/test_scope_source_factory.py
def resolve_scope_source(
    repo_root: Path,
    *,
    filter_groups_override: Mapping[str, tuple[str, ...]] | None = None,
    composite_routing_override: Mapping[str, _CompositeRoute] | None = None,
) -> ScopeSource:
    """The single authority both baseline capture and the head hook resolve through."""
    return GateCoverageScopeSource(
        repo_root=repo_root,
        filter_groups_override=filter_groups_override,
        composite_routing_override=composite_routing_override,
    )
CallerHomeOverride argsResult
_mt_resolve_scope_source (head hook)tasks_move_task.py:1250_pre_review_gate_filter_groups() / _pre_review_gate_composite_routing() (both None in prod)wrapper threads the test seams; no import back into tasks_move_task
implement_capture_baseline (baseline)workflow_executor.py:1153none (prod → None)capture_baseline(..., scope_source=resolve_scope_source(main_repo_root))

No-import-cycle obligation (FR-003). resolve_scope_source lives in scope_source.py, already imported by both baseline.py:30 and pre_review_gate.py:65-71; it MUST NOT import tasks_move_task. The seams are passed as parameters, never imported into the factory.

Equivalence obligation (NFR-005). For the same repo_root and config, the source resolved at implement-time (baseline) and at for_review (head) is equivalent = equal test_command() output AND equal parse-mode/identity (scope_source_identity, see baseline-identity-contract.md). A test pins this so the baseline↔head split cannot re-open.

What this contract does NOT change

file_to_scope's provenance on GateCoverageScopeSource moves to the mixin.

untouched (C-002).

</content>

  • The ScopeSource / ScopeBreakdownSource port shapes (scope_source.py:85-165) — only
  • The private census derivation inside scope_source.py (:195-411) — it is the live derivation,
  • RawRunResult / FileScopeBreakdown dataclasses (:66-83, :126-145).