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(...)orwith contextlib.chdir(...)for block scope - os.environ →
monkeypatch.setenv/delenv(...)ormock.patch.dict(os.environ, ...) - sys.path →
monkeypatch.syspath_prepend(...)or remove (rootdir +pythonpath=srcalready cover repo/src) - sys.modules →
monkeypatch.setitem/delitem(sys.modules, ..., raising=False)(+ parent-package attribute) - sys.argv →
monkeypatch.setattr(sys, "argv", [...])ormock.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)
| Key | Body | Writes | Errors raised as MergeDriverError |
|---|---|---|---|
merge-driver-event-log | run_event_log_driver | ours via status.merge_event_log_files | EventLogMergeError, path hardening |
merge-driver-meta | run_meta_driver | ours, _META_JSON_KWARGS + "\n" | JSONDecodeError, EventLogMergeError, path hardening |
merge-driver-traces | run_traces_driver | ours, union text as returned | path hardening (UnicodeDecodeError propagates, as today) |
merge-driver-issue-matrix | run_issue_matrix_driver | ours, indent=2, sort_keys=True + "\n" | RowMatrixMergeError, AcceptanceMatrixParseError, path hardening |
merge-driver-acceptance-matrix | run_acceptance_matrix_driver | ours, indent=2 + "\n" (no sort_keys) | same as issue-matrix |
merge-driver-review-cycle | run_review_cycle_driver | ours (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.