Contracts

global-state-allowlist.md

Contract: manual global-state mutation census gate

Detector: tests/architectural/_global_state_scan.py. Gate: tests/architectural/test_no_manual_global_state_mutation.py (the only module that reads the allowlist and compares; it does not import _home_pin_scan). Allowlist: tests/architectural/global_state_allowlist/*.yaml.

Site

(file: repo-relative posix path, qualname: enclosing qualname via specify_cli.contracts.anchoring, kind: cwd|sys.path|sys.modules|os.environ|sys.argv) — line numbers are never part of a key.

Allowlist row (YAML)

# round-trip: skip: illustrative allowlist row shape (plain YAML data, not a Pydantic contract example)
- file: tests/conftest.py
  qualname: pytest_configure
  kind: os.environ
  count: 3                 # exact number of sites with this key
  class: process-bootstrap # transitional-sweep | process-bootstrap | subprocess-entry | leak-sentinel | deferred-01M3EW3Z
  reason: "Process-wide HOME isolation must exist before any fixture runs."

Shard files (permanent): S1.yaml … S8.yaml — each shard holds rows only for the files listed for it in the mission's research/sweep_shards.tsv: transitional-sweep rows (drained to zero by that shard's sweep WP) plus any justified rows (process-bootstrap / subprocess-entry / leak-sentinel) for its files. deferred-01M3EW3Z.yaml holds the deferred-01M3EW3Z rows (files owned by mission 01M3EW3Z). Non-transitional rows require a non-empty reason.

Verdicts (all fail the gate)

1. Unallowlisted site — a key with no row → message: file, qualname, kind, and the scoped replacement for that kind. 2. Over-count — actual > count. 3. Under-count / stale — actual < count (incl. 0) → "shrink the allowlist" naming the row (shrink-only; staleness is an error). 4. Wrong shard — a transitional row whose file is not owned by that shard. 5. Parse failure — any *.py under tests/ outside the explicit fixture-data exclusion list that fails to parse. 6. Vacuity floor — fewer than 3000 files scanned. 7. Sealed invariants (separate test tests/architectural/test_global_state_allowlist_sealed.py, landed by the Seal WP) — no transitional-sweep row remains in any shard, and sites per class do not exceed the frozen per-class caps (set from the actual post-sweep counts, never above the plan projection: process-bootstrap 6, subprocess-entry 13, leak-sentinel 2, deferred-01M3EW3Z 16).

Scoped replacements (message text per kind)

  • cwd → monkeypatch.chdir(...) or with contextlib.chdir(...) for block scope
  • os.environ → monkeypatch.setenv/delenv(...) or mock.patch.dict(os.environ, ...)
  • sys.path → monkeypatch.syspath_prepend(...) or remove (rootdir + pythonpath=src already cover repo/src)
  • sys.modules → monkeypatch.setitem/delitem(sys.modules, ..., raising=False) (+ parent-package attribute)
  • sys.argv → monkeypatch.setattr(sys, "argv", [...]) or mock.patch.object(sys, "argv", [...])

merge-driver-body.md

Contract: merge-driver body (specify_cli.merge.drivers)

Owner: merge domain. Consumers: cli/commands/merge_driver.py (subprocess entrypoint shell) and merge/git_probes.py (in-process driver replay). Both MUST call the same body.

Types

class MergeDriverError(Exception):
    """Unresolvable merge-driver conflict or invalid input. str(exc) is the exact stderr text."""

@dataclass(frozen=True)
class MergeDriverOutcome:
    notice: str | None = None   # stdout notice (review-cycle only today)

MergeDriverBody = Callable[[str, str, str], MergeDriverOutcome]   # (base, ours, theirs)

MERGE_DRIVER_BODIES: Mapping[str, MergeDriverBody]   # key = registered command name

Registered bodies (6)

KeyBodyWritesErrors raised as MergeDriverError
merge-driver-event-logrun_event_log_driverours via status.merge_event_log_filesEventLogMergeError, path hardening
merge-driver-metarun_meta_driverours, _META_JSON_KWARGS + "\n"JSONDecodeError, EventLogMergeError, path hardening
merge-driver-tracesrun_traces_driverours, union text as returnedpath hardening (UnicodeDecodeError propagates, as today)
merge-driver-issue-matrixrun_issue_matrix_driverours, indent=2, sort_keys=True + "\n"RowMatrixMergeError, AcceptanceMatrixParseError, path hardening
merge-driver-acceptance-matrixrun_acceptance_matrix_driverours, indent=2 + "\n" (no sort_keys)same as issue-matrix
merge-driver-review-cyclerun_review_cycle_driverours (identical → ours; else conflict markers)path hardening; returns notice on collision

(Body names are indicative; the table keys are binding and must equal the command names derived from lanes/merge.py::_MERGE_DRIVERS and the merge-driver-* keys of cli/commands/__init__.py::_COMMAND_REGISTRARS.)

Invariants

1. A body never calls sys.exit / raises typer.Exit and never imports specify_cli.cli.*. 2. Every body runs the path hardening first (replay is hardened like the subprocess path). 3. Foreign errors are wrapped MergeDriverError(str(exc)) from exc — stderr stays byte-identical. 4. Shell: MergeDriverError → typer.echo(str(e), err=True) + typer.Exit(1); outcome.notice → stdout; otherwise exit 0. Command names, argument order and exit codes are unchanged (C-001). 5. Resolver: MERGE_DRIVER_BODIES.get(command_name) via a function-local import; unresolvable → existing fail-closed GitProbeError path.