Story S1-09 — ProbeContext.image_digest_resolver extension + contract-freeze regen¶
Step: Step 1 — Plant new domain primitives, kernel contracts, and the nine new ADRs
Status: Done (GREEN 2026-05-15, all 17 ACs satisfied — see _attempts/S1-09.md)
Effort: M
Depends on: S1-08
ADRs honored: 02-ADR-0004, 00-ADR-0007 (probe-contract-freeze), 01-ADR-0002 (precedent)
Validation notes (2026-05-15)¶
This story was hardened by phase-story-validator before execution. Changes:
localv2.md §4update added as a load-bearing AC. The original story omitted it; 00-ADR-0007 says "change code to match doc, never the inverse," andtest_probe_contract_doc_fingerprint_matches_snapshot(Tier 1) fails unless the doc is updated first. Without this AC the executor would commit a snapshot whosedoc_fingerprintwas regenerated from an out-of-date doc — a silent contract drift the regen script cannot itself catch.- Reconciled with existing test infrastructure. The story originally invented a parallel
_ALLOWED_PROBE_CONTEXT_FIELDSset insidetests/unit/test_probe_contract.py. The actual file already carries_ADR_0002_ALLOWED_PROBE_CONTEXT_FIELDS(Tier 7) with a hard-coded 7-field tuple, plus atest_probe_context_sentinel_fires_on_synthetic_third_fieldin-test synthetic-dataclass mutation-killer. Per Rule 11 (match the codebase's conventions), this story now extends the existing sentinel by renaming the tuple to_ALLOWED_PROBE_CONTEXT_FIELDS(no ADR suffix; multi-ADR moving forward) and adds a second ADR-0004-specific synthetic-third-field mutation killer mirroring the Phase-1 one — so the failure message routes contributors to ADR-0004 §Reversibility, not (only) ADR-0002. - Corrected the regen script path. Story said
scripts/regen_probe_contract.py; the file in tree isscripts/regen_probe_contract_snapshot.py. Updated everywhere. - Replaced pseudo-code subprocess test with the codebase's in-test synthetic-dataclass pattern. The story's
tests/unit/test_probe_contract_regen_script.pywas a sketch (Pseudocode — the implementer wires this). Per Rule 9 (tests verify intent concretely) and Rule 11, mirrortest_probe_context_sentinel_fires_on_synthetic_third_fieldinstead — build a synthetic 8-field dataclass, assert the existing sentinel raises with regex match forADR-0004. No subprocess required; deterministic. - Added type-annotation pin (mutation-killer). A new AC asserts
image_digest_resolver's annotationrepr()contains"Callable","Path", and"str | None"(mirrors the existingtest_probe_context_new_field_annotations_pinned). Catches silent retypes likeCallable[[Path], str](dropping| Nonefrom the return) orCallable[[str], str | None](droppingPath). - Added a
localv2.md §4doc-grep AC. Mirrorstest_localv2_section_4_shows_phase1_probe_context_fields. Names what drifted when the Tier 1 doc_fingerprint check fires — without it, the failure is opaque. - CODEOWNERS scope narrowed. AC-7 originally proposed
@phase2-architectsas the team alias. There is no such GitHub team and the file already carriesTODO(S5-02): CODEOWNERS entry required(seesrc/codegenie/probes/base.pyheader). Resolved: this story does NOT create CODEOWNERS speculatively (Rule 2); the TODO already exists; AC-7 reworded to verify the TODO is still present (regression-resistance) and the actual CODEOWNERS file is filed under S5-02 / S8-04 follow-up. - AC-2 (Probe ABC unchanged) strengthened via the existing structural snapshot. The story tried to add bespoke
hasattr(Probe, ...)checks; the existing Tier 4test_structural_signature_captures_required_probe_class_attributes+test_structural_signature_preserves_probe_class_attribute_orderalready pin the ABC's class-attribute set and order. AC-2 now asserts those Tier 4 tests continue to pass (regression-style) rather than adding parallelhasattrspot-checks. - Notes-for-implementer: queued kernel extraction.
_ALLOWED_PROBE_CONTEXT_FIELDSis the second precedent (ADR-0002 + ADR-0004). If a Phase 3+ ADR adds a third optional callable, the rule-of-three is reached and a_ALLOWED_PROBE_CONTEXT_FIELDS_BY_ADR: Mapping[ADRId, frozenset[str]]registry (Open/Closed at the file boundary) should be extracted — Phase 3 owns that decision; do NOT introduce it speculatively here (Rule 2). - Removed the regen-script subprocess test file.
tests/unit/test_probe_contract_regen_script.pyfrom the original TDD plan was redundant once the existing in-test synthetic-dataclass pattern is reused. Files-to-touch trimmed.
Verdict: HARDENED (no structural problems with the story's goal or scope; concrete fixes to ACs, TDD plan, and file list).
Context¶
RuntimeTraceProbe (S5-02) needs the image digest as part of its declared_inputs so a rebuilt-image-with-same-Dockerfile invalidates the cache (image-digest drift adversarial — tests/adv/phase02/test_image_digest_drift.py in S5-05). The image isn't visible at RepoSnapshot construction time (the image is built mid-gather). The architect's solution — mirroring Phase 1 ADR-0002's parsed_manifest precedent — is to add one optional field to ProbeContext: a Callable[[Path], str | None] the coordinator binds. This is the only Phase-0-contract amendment in all of Phase 2, and the contract-freeze snapshot regeneration must encode the allowed-field list so a third field fails CI with the ADR-0004 pointer.
References — where to look¶
- Architecture:
../phase-arch-design.md §"Component design" #6 — RuntimeTraceProbe (cache key)— describes theimage-digest:<resolved>declared-input token and how the resolver is consumed.../phase-arch-design.md §"Data model"(lines# ---------- [contract — additive] codegenie/probes/base.py (Phase 0 + 1 frozen) ----------) — the exact additive field signature.../phase-arch-design.md §"Tradeoffs (consolidated)"row "Image digest as a declared-input token" — Phase 0declared_inputsdiscipline survives.- Phase ADRs (rules this story must honor):
../ADRs/0004-image-digest-as-declared-input-token.md— 02-ADR-0004 — the decision; mirrors Phase 1 ADR-0002'sparsed_manifestprecedent;image_digest_resolveris the one Phase-2 ProbeContext field.- Production ADRs (if applicable):
../../../production/adrs/0007-probe-contract-freeze.md— Phase 0 ADR-0007 — contract-freeze snapshot test; this story regenerates it with one documented addition.- Source design:
../final-design.md §6 — Image digest as a declared-input token— the synthesis ledger row.- Existing code:
src/codegenie/probes/base.py— currentProbeContextfields (Phase 0 + Phase 1'sparsed_manifest+input_snapshot). Phase 2 adds exactly one field.tests/unit/test_probe_contract.py(Phase 0) — the snapshot test that hashes theProbeABC +ProbeContextshape; this story regenerates the snapshot.tests/snapshots/probe_contract.v1.json(likely path; verify at impl time) — the committed snapshot file.- External docs (only if directly relevant):
- None.
Goal¶
Extend src/codegenie/probes/base.py ProbeContext with one additive optional field — image_digest_resolver: Callable[[Path], str | None] | None = None — and regenerate tests/unit/test_probe_contract.py's snapshot so that only this documented amendment is admitted and any future widening fails CI with an ADR-0004 pointer in the error message.
Acceptance criteria¶
- [ ] AC-1.
src/codegenie/probes/base.pyProbeContextgains exactly one field, appended afterinput_snapshot:The field is optional with default# Phase 2 ADR-0004 — the one additive amendment in Phase 2. image_digest_resolver: Callable[[Path], str | None] | None = NoneNone. No other field is added or modified. TheCallableandPathimports are already present (Phase 1 ADR-0002 widenedALLOWED_BASE_PY_IMPORTSto admitcollections.abc.CallableandMapping); no import edits are needed. - [ ] AC-2. The
ProbeABC class itself is unchanged — verified by the existing Tier 4 contract-snapshot tests intests/unit/test_probe_contract.py: test_structural_signature_captures_required_probe_class_attributescontinues to pass (same nine class attributes, no additions).test_structural_signature_preserves_probe_class_attribute_ordercontinues to pass (same order).test_probe_class_has_no_version_attributecontinues to pass. No bespokehasattr(Probe, ...)spot-checks are added — the structural snapshot is the single chokepoint and adding parallel checks creates two sources of truth.- [ ] AC-3.
docs/localv2.md §4is updated to includeimage_digest_resolverin theProbeContextblock before the snapshot is regenerated. The added line is exactly:appended after# Phase 2 ADR-0004. No further extensions without ADR amendment. image_digest_resolver: Callable[[Path], str | None] | None = Noneinput_snapshot:inside theclass ProbeContext:block. This is load-bearing per 00-ADR-0007 ("change code to match doc, never the inverse") — without this edit,test_probe_contract_doc_fingerprint_matches_snapshot(Tier 1) fails with thetemplates/adr-amendment.mdpointer even after the regen script runs against the un-updated doc. - [ ] AC-4.
tests/snapshots/probe_contract.v1.jsonis regenerated viapython scripts/regen_probe_contract_snapshot.pyafter AC-1 and AC-3 land. The committed snapshot: - has a
doc_fingerprintvalue different from the pre-story value (asserts the doc edit actually changed the hash — guards against an executor who runs the script before editing the doc and commits an unchanged digest). - has a
structural_signature.ProbeContext.fieldslist whose last element is{"name": "image_digest_resolver", "type": "Callable[[Path], str | None] | None", "default": "None"}(or the renderer-equivalent — verify by reading the regen script's_stable_type_reproutput). snapshot_schema_versionis unchanged at1.- [ ] AC-5. The existing Tier 7 sentinel in
tests/unit/test_probe_contract.pyis updated: _ADR_0002_ALLOWED_PROBE_CONTEXT_FIELDSis renamed to_ALLOWED_PROBE_CONTEXT_FIELDS(no ADR suffix; the tuple now spans ≥2 ADRs).- The tuple becomes exactly
("cache_dir", "output_dir", "workspace", "logger", "config", "parsed_manifest", "input_snapshot", "image_digest_resolver"). test_probe_context_field_list_matches_adr_0002_amendmentis renamed totest_probe_context_field_list_matches_allowed(no per-ADR suffix; mention both ADRs in the failure message).- The failure message names BOTH ADR-0002 (parsed_manifest, input_snapshot) AND ADR-0004 (image_digest_resolver), and tells contributors to file a new Phase ADR amendment for any third addition.
- [ ] AC-6. A new mutation-killer test
test_probe_context_sentinel_fires_on_synthetic_phase_2_additionis added, mirroring the existingtest_probe_context_sentinel_fires_on_synthetic_third_field. It builds a synthetic 9-field dataclass (the 8 allowed plus one bogus addition) via@dataclasses.dataclass, asserts the equality check raisesAssertionError, and assertspytest.raises(..., match=r"ADR-0004")(regex specific to the new ADR). The existing..._synthetic_third_fieldtest is kept and updated so its match regex now includesr"ADR-0002|ADR-0004"(either-routes-to-an-amendment-ADR), or kept asADR-0002if the test message is updated to name both. - [ ] AC-7. A new mutation-killer test
test_image_digest_resolver_annotation_pinnedis added in the Tier 7 section, asserting: "Callable" in repr(ann["image_digest_resolver"])"Path" in repr(ann["image_digest_resolver"])"str | None" in repr(ann["image_digest_resolver"])Mirrorstest_probe_context_new_field_annotations_pinned. Catches silent retypes likeCallable[[Path], str](drops| Nonereturn),Callable[[str], str | None](dropsPath), ordict[Path, str](dropsCallableentirely).- [ ] AC-8. A new doc-grep test
test_localv2_section_4_shows_image_digest_resolveris added, mirroringtest_localv2_section_4_shows_phase1_probe_context_fields. Asserts thatimage_digest_resolver:appears in the extracted §4 body. This names what drifted when Tier 1 fires; without it the contributor sees an opaque hash mismatch. - [ ] AC-9. A new unit test
test_probe_context_image_digest_resolver_is_optional_with_none_defaultverifies the Phase 0/1 construction signature is preserved — constructingProbeContext(cache_dir=…, output_dir=…, workspace=…, logger=…, config={})without the new kwarg succeeds andctx.image_digest_resolver is None. Backward-compat smoke test for callers that predate Phase 2. - [ ] AC-10. A new unit test
test_probe_context_image_digest_resolver_accepts_callable_returning_noneverifies the type contract'sNonereturn arm: - Construct with a resolver that returns
None(e.g.,lambda _p: None); call it; assert the result isNone. - Construct with a resolver that returns a digest string (e.g.,
lambda _p: "sha256:cafef00d"); call it; assert the result is the string. Catches a future widener who silently broadens toCallable[[Path], str](drops theNonearm). - [ ] AC-11. The regen script
scripts/regen_probe_contract_snapshot.pyis idempotent: running it twice consecutively produces a byte-identicaltests/snapshots/probe_contract.v1.json. Verify by a new test (preferred) or by manual confirmation logged in the implementation notes. Naming convention:test_regen_script_is_idempotentin a newtests/unit/test_probe_contract_regen_script.py, or appended to the existingtest_probe_contract.py. The test runs the regen script in a subprocess against the live tree, captures the snapshot bytes before and after a second invocation, and asserts equality. - [ ] AC-12. The
parsed_manifestfield (Phase 1 ADR-0002) is NOT renamed or repositioned; its annotationCallable[[Path], Mapping[str, Any] | None] | None = Noneis byte-identical. Verified by the existingtest_probe_context_new_field_annotations_pinned. - [ ] AC-13. The Phase 0
forbidden-patternscheck (scripts/check_forbidden_patterns.py) continues to pass on the modifiedbase.py. Verified by running the script as part ofmake lint/pre-commit run --all-files; no new ban patterns are added or removed by this story. - [ ] AC-14. The
TODO(S5-02): CODEOWNERS entry required for src/codegenie/probes/base.py …comment at the top ofsrc/codegenie/probes/base.pyis preserved verbatim — the existingtest_base_py_carries_codeowners_todo_for_s5_02continues to pass. CODEOWNERS is NOT created speculatively in this story (Rule 2); the linkage is already audit-logged via the TODO and the follow-up is owned by S5-02 / S8-04. - [ ] AC-15. All Tier 1 anchoring tests pass:
test_probe_contract_doc_fingerprint_matches_snapshot— the newlocalv2.md §4body normalizes-and-hashes to the newdoc_fingerprint.test_probe_class_structural_signature_matches_snapshot— the newProbeContextshape matches the regeneratedstructural_signature.- [ ] AC-16.
ruff check,ruff format --check,mypy --strict src/codegenie tests/unit/test_probe_contract*.py, the Phase 0contract-freezeCI job (i.e.pytest tests/unit/test_probe_contract.py -v), andpytest tests/unit/test_probe_contract*.py -vall pass. - [ ] AC-17. The TDD plan's red tests exist on disk, were committed in a red-state commit before the implementation commit, and are now green. Confirmable via
git log -- tests/unit/test_probe_contract*.pyshowing two commits in this story's range with descriptive subject lines.
Implementation outline¶
Order matters — the doc edit must precede the code edit, and the snapshot regeneration must come last, otherwise the Tier 1 anchoring tests fail mid-flight.
- Red phase — write the failing tests first, against the current tree.
- Edit
tests/unit/test_probe_contract.py:- Rename
_ADR_0002_ALLOWED_PROBE_CONTEXT_FIELDS→_ALLOWED_PROBE_CONTEXT_FIELDSand extend the tuple by appending"image_digest_resolver". - Rename
test_probe_context_field_list_matches_adr_0002_amendment→test_probe_context_field_list_matches_allowed; update the failure message to name both ADR-0002 and ADR-0004. - Add the new tests:
test_probe_context_image_digest_resolver_is_optional_with_none_default(AC-9),test_probe_context_image_digest_resolver_accepts_callable_returning_none(AC-10),test_image_digest_resolver_annotation_pinned(AC-7),test_localv2_section_4_shows_image_digest_resolver(AC-8),test_probe_context_sentinel_fires_on_synthetic_phase_2_addition(AC-6).
- Rename
- Add (or extend)
tests/unit/test_probe_contract_regen_script.pywithtest_regen_script_is_idempotent(AC-11). If a single file is cleaner, append totest_probe_contract.py. - Run
pytest tests/unit/test_probe_contract*.py -v. The new tests fail (field doesn't exist; doc-grep test fails; sentinel tuple mismatch). The Tier 1 anchoring tests also fail (the sentinel tuple is the new 8-tuple butProbeContextis still 7-field). This is the expected red state. -
Commit the red state with subject
test(phase2/S1-09): RED — image_digest_resolver contract extension. -
Green phase — edit doc, then code, then regenerate snapshot.
- Step 2a. Edit
docs/localv2.md §4ProbeContextblock: append theimage_digest_resolverline exactly as in AC-3, preserving the leading 4-space indent and the# Phase 2 ADR-0004. No further extensions without ADR amendment.comment style mirroring Phase 1's. Do not run the regen script yet. - Step 2b. Edit
src/codegenie/probes/base.py:- Append the new field after
input_snapshotin theProbeContextdataclass with the same comment as in §4. - Update the module docstring with one sentence: "Phase 2 ADR-0004 adds one optional field,
image_digest_resolver, mirroring Phase 1 ADR-0002'sparsed_manifestprecedent. Adding a third future field requires a new phase ADR amendment; the sentinel test intests/unit/test_probe_contract.pyfails CI with an explicit pointer."
- Append the new field after
- Step 2c. Run
python scripts/regen_probe_contract_snapshot.py. Inspect the diff againsttests/snapshots/probe_contract.v1.json:doc_fingerprintchanges (it MUST — that proves AC-3 actually landed).structural_signature.ProbeContext.fieldsgrows by one entry at the end.- No other key changes.
- Step 2d. Run
pytest tests/unit/test_probe_contract*.py -v. All tests now green. If any Tier 1, Tier 4, or Tier 7 test fails, the doc edit, code edit, or snapshot regen is out of sync — diagnose; do NOT add escape hatches. -
Commit with subject
feat(phase2/S1-09): GREEN — ProbeContext.image_digest_resolver (ADR-0004). -
Refactor phase — verify idempotence, lint, type-check.
- Run the regen script a second time; confirm zero diff (AC-11).
- Run
ruff check,ruff format --check,mypy --strict src/codegenie tests/unit/test_probe_contract*.py,make lint(orpre-commit run --all-files). - Verify the AC-14
TODO(S5-02): CODEOWNERS entry requiredcomment is still present inbase.py. - Commit with subject
refactor(phase2/S1-09): REFACTOR — idempotence + lint clean.
TDD plan — red / green / refactor¶
Red — write the failing tests first¶
Edit tests/unit/test_probe_contract.py (extend the existing Phase 0/1 file; do NOT create a parallel test file). The codebase's idiom is in-test synthetic-dataclass mutation-killers — mirror it.
# In the Tier 7 section, rename the existing sentinel constant and tests:
_ALLOWED_PROBE_CONTEXT_FIELDS: tuple[str, ...] = (
"cache_dir",
"output_dir",
"workspace",
"logger",
"config",
"parsed_manifest", # Phase 1 ADR-0002
"input_snapshot", # Phase 1 ADR-0002
"image_digest_resolver", # Phase 2 ADR-0004
)
_ADR_0004_PATH = (
REPO_ROOT
/ "docs"
/ "phases"
/ "02-context-gather-layers-b-g"
/ "ADRs"
/ "0004-image-digest-as-declared-input-token.md"
)
def test_probe_context_field_list_matches_allowed() -> None:
# AC-5 — the allowed-field tuple now spans ADR-0002 and ADR-0004.
# Any third future addition requires a new phase ADR amendment.
import dataclasses
actual = tuple(f.name for f in dataclasses.fields(base.ProbeContext))
assert actual == _ALLOWED_PROBE_CONTEXT_FIELDS, (
f"ProbeContext field list {actual} does not match the allowed set. "
f"Current additions are gated by ADR-0002 (parsed_manifest, input_snapshot) "
f"and ADR-0004 (image_digest_resolver). Any new field requires a new "
f"Phase ADR amendment. See "
f"docs/phases/01-context-gather-layer-a-node/ADRs/0002-parsed-manifest-memo-on-probe-context.md "
f"and docs/phases/02-context-gather-layers-b-g/ADRs/"
f"0004-image-digest-as-declared-input-token.md."
)
def test_probe_context_image_digest_resolver_is_optional_with_none_default(tmp_path: Path) -> None:
# AC-9 — backward compatible with Phase 0/1 callers (no kwarg supplied).
import logging
ctx = base.ProbeContext(
cache_dir=tmp_path / "c",
output_dir=tmp_path / "o",
workspace=tmp_path / "w",
logger=logging.getLogger("test"),
config={},
)
assert ctx.image_digest_resolver is None
def test_probe_context_image_digest_resolver_accepts_callable_returning_none(tmp_path: Path) -> None:
# AC-10 — the `| None` return arm is part of the contract; the resolver
# may legitimately report "no image built yet".
import logging
def _none_resolver(_p: Path) -> str | None:
return None
def _digest_resolver(_p: Path) -> str | None:
return "sha256:cafef00d"
ctx_none = base.ProbeContext(
cache_dir=tmp_path / "c", output_dir=tmp_path / "o", workspace=tmp_path / "w",
logger=logging.getLogger("test"), config={},
image_digest_resolver=_none_resolver,
)
assert ctx_none.image_digest_resolver is _none_resolver
assert ctx_none.image_digest_resolver(tmp_path / "img") is None
ctx_dig = base.ProbeContext(
cache_dir=tmp_path / "c", output_dir=tmp_path / "o", workspace=tmp_path / "w",
logger=logging.getLogger("test"), config={},
image_digest_resolver=_digest_resolver,
)
assert ctx_dig.image_digest_resolver(tmp_path / "img") == "sha256:cafef00d"
def test_image_digest_resolver_annotation_pinned() -> None:
# AC-7 — catches silent retypes: drop `| None` return, drop `Path` arg,
# widen to `dict[Path, str]`, etc.
import inspect as _inspect
ann = _inspect.get_annotations(base.ProbeContext)
rendered = repr(ann["image_digest_resolver"])
assert "Callable" in rendered, rendered
assert "Path" in rendered, rendered
assert "str | None" in rendered, rendered
def test_localv2_section_4_shows_image_digest_resolver() -> None:
# AC-8 — code-matches-doc is the ADR-0007 discipline; this test names
# *what* drifted when Tier 1 fingerprint fires.
body = extract_section_4_body(LOCALV2_PATH.read_text(encoding="utf-8"))
assert "image_digest_resolver:" in body, (
"localv2.md §4 ProbeContext is missing image_digest_resolver (02-ADR-0004)"
)
def test_adr_0004_names_image_digest_resolver() -> None:
# AC-6 companion — the ADR text is the human-facing record; an ADR that
# doesn't name its own field is rot the moment a contributor reads it.
text = _ADR_0004_PATH.read_text(encoding="utf-8")
assert "image_digest_resolver" in text
def test_probe_context_sentinel_fires_on_synthetic_phase_2_addition() -> None:
# AC-6 — mirror `test_probe_context_sentinel_fires_on_synthetic_third_field`
# but for the ADR-0004 amendment. A synthetic dataclass with one MORE
# field than the allowed tuple stands in for the future amendment.
import dataclasses as _dc
@_dc.dataclass
class _SyntheticNineField:
cache_dir: Path
output_dir: Path
workspace: Path
logger: Any
config: dict[str, Any]
parsed_manifest: Any = None
input_snapshot: Any = None
image_digest_resolver: Any = None
future_extra_field: Any = None # the offending addition
actual = tuple(f.name for f in _dc.fields(_SyntheticNineField))
with pytest.raises(AssertionError, match=r"ADR-0004"):
assert actual == _ALLOWED_PROBE_CONTEXT_FIELDS, (
f"ProbeContext field list {actual} does not match the allowed set. "
f"Current additions are gated by ADR-0002 (parsed_manifest, input_snapshot) "
f"and ADR-0004 (image_digest_resolver). Any new field requires a new "
f"Phase ADR amendment."
)
Idempotence test (AC-11). Either append to test_probe_contract.py or place in a new tests/unit/test_probe_contract_regen_script.py:
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SNAPSHOT_PATH = REPO_ROOT / "tests" / "snapshots" / "probe_contract.v1.json"
REGEN_SCRIPT = REPO_ROOT / "scripts" / "regen_probe_contract_snapshot.py"
def test_regen_script_is_idempotent() -> None:
# AC-11 — golden-stability discipline (mirrors S7-03). Running the script
# twice must produce a byte-identical snapshot. The implicit contract:
# `_stable_type_repr` and `structural_signature` are deterministic across
# Python sub-processes on the same interpreter.
assert REGEN_SCRIPT.exists(), REGEN_SCRIPT
before = SNAPSHOT_PATH.read_bytes()
r1 = subprocess.run([sys.executable, str(REGEN_SCRIPT)], capture_output=True, text=True)
assert r1.returncode == 0, r1.stderr
after_first = SNAPSHOT_PATH.read_bytes()
r2 = subprocess.run([sys.executable, str(REGEN_SCRIPT)], capture_output=True, text=True)
assert r2.returncode == 0, r2.stderr
after_second = SNAPSHOT_PATH.read_bytes()
assert after_first == after_second, (
"regen script is not idempotent — running twice produced different snapshots"
)
# And the committed snapshot is up-to-date (no diff vs. before).
assert before == after_second, (
"committed snapshot is stale; run `python scripts/regen_probe_contract_snapshot.py`"
)
Run pytest tests/unit/test_probe_contract*.py -v. The new tests fail; also several existing Tier 1 + Tier 7 tests fail because the sentinel tuple now expects 8 fields but ProbeContext has 7. Commit the red state before proceeding.
Green — make the tests pass¶
Step 1 — update docs/localv2.md §4. Append image_digest_resolver to the ProbeContext block (AC-3 spec).
Step 2 — edit src/codegenie/probes/base.py.
@dataclass
class ProbeContext:
cache_dir: Path
output_dir: Path # where probe writes raw artifacts
workspace: Path # ephemeral workspace for the probe
logger: Logger
config: dict[str, Any]
# Phase 1 additions (ADR-0002). No further extensions without ADR amendment.
parsed_manifest: Callable[[Path], Mapping[str, Any] | None] | None = None
input_snapshot: frozenset["InputFingerprint"] | None = None
# Phase 2 ADR-0004. No further extensions without ADR amendment.
image_digest_resolver: Callable[[Path], str | None] | None = None
Add the one-line module-docstring note about the ADR-0004 amendment.
Step 3 — regenerate the snapshot. python scripts/regen_probe_contract_snapshot.py. Inspect the diff (one new entry in ProbeContext.fields; doc_fingerprint changed). Commit the snapshot.
Run pytest tests/unit/test_probe_contract*.py -v — all tests green.
Refactor — clean up + verification¶
- Run the regen script a second time → no diff (AC-11 confirmed at the human level too).
- Run
ruff check,ruff format --check,mypy --strict src/codegenie tests/unit/test_probe_contract*.py,make lint. - Confirm
test_base_py_carries_codeowners_todo_for_s5_02still passes (AC-14). - Confirm no other CI job in the Phase 0/1 fence broke (
pytest tests/ -k "not slow"or the project's full unit-test command).
Files to touch¶
| Path | Why |
|---|---|
docs/localv2.md |
§4 ProbeContext block gains the image_digest_resolver line. Must precede the code edit and the snapshot regen (00-ADR-0007 — "change code to match doc"). |
src/codegenie/probes/base.py |
Append image_digest_resolver: Callable[[Path], str \| None] \| None = None after input_snapshot. One-line module-docstring note about the ADR-0004 amendment. |
tests/unit/test_probe_contract.py |
Rename _ADR_0002_ALLOWED_PROBE_CONTEXT_FIELDS → _ALLOWED_PROBE_CONTEXT_FIELDS; extend tuple; rename and rewrite the field-list sentinel test; add the new ADR-0004 mutation-killer, annotation-pin, doc-grep, backward-compat, and | None-return tests (AC-5..AC-10). |
tests/unit/test_probe_contract_regen_script.py (new, optional) |
test_regen_script_is_idempotent (AC-11). May instead be appended to test_probe_contract.py if cleaner. |
tests/snapshots/probe_contract.v1.json |
Regenerated by scripts/regen_probe_contract_snapshot.py after the doc + code edits. doc_fingerprint changes; ProbeContext.fields grows by one entry. |
scripts/regen_probe_contract_snapshot.py |
Read-only — no edits required. The script is allowed-field-agnostic; it walks dataclasses.fields(ProbeContext) directly. The "allowed-list" gate lives in tests/unit/test_probe_contract.py, not in the script. |
Out-of-scope file deltas (DO NOT TOUCH in this story):
.github/CODEOWNERS/docs/CODEOWNERS— not created speculatively (Rule 2); theTODO(S5-02)inbase.pyalready records the linkage; the file lands under S5-02 or S8-04.src/codegenie/cache.py— token-recognizer dispatch forimage-digest:belongs to a Phase 0 cache layer change, scoped to the consumer story (RuntimeTraceProbe S5-02), not this contract-extension story.- Any probe file consuming the resolver — that's S5-02's surface.
Out of scope¶
- Phase 0
ProbeABC edits — strictly forbidden by 02-ADR-0003 + 02-ADR-0004. The ABC is frozen. RuntimeTraceProbe's consumption ofimage_digest_resolver— handled by S5-02; this story only ships the field.- The Phase 1
parsed_manifestsemantics — unchanged; reuse the precedent. InputFingerprintevolution — Phase 1 contract; not extended in Phase 2.heaviness/runs_lastdecorator-kwargs — handled by S1-08 (the previous story); this story does not editbase.pybeyond the one field.@register_index_freshness_checkconsumption — that's S4-01; this story is the data-only extension.
Notes for the implementer¶
- Three-source-of-truth alignment is the load-bearing invariant.
docs/localv2.md §4,src/codegenie/probes/base.py, andtests/snapshots/probe_contract.v1.jsonmust all agree on the new field at commit time. The Tier 1 anchoring tests (test_probe_contract_doc_fingerprint_matches_snapshot+test_probe_class_structural_signature_matches_snapshot) detect any drift across the three; the Tier 7 sentinel makes the failure message route to a concrete ADR. Edit in the order doc → code → snapshot regen. Skipping the doc edit will produce a snapshot whosedoc_fingerprintwas computed from a stale doc and the next contributor's regen will fail loudly. - Reconcile the field list with current Phase 0/1 reality before extending it. Phase 0 ships
ProbeContext(cache_dir, output_dir, workspace, logger, config); Phase 1 ADR-0002 addsparsed_manifestandinput_snapshot. The current_ADR_0002_ALLOWED_PROBE_CONTEXT_FIELDSalready encodes these seven. If your tree shows a different set, stop and ask — do not silently grow the allowed-field list. Callable[[Path], str | None] | None = None— every arm of the type matters.Path(positional arg): the resolver receives aPath— per arch design it is the analyzed-repo root, used by the coordinator to look up the recently-built image.str | None(return):Noneis the legitimate "no image built yet" path (phase-arch-design.md §"Edge cases" row 14). A future widener who drops| Nonefrom the return breaks the cache-fallback contract.- Outer
| None = None(the field): the resolver itself is optional so Phase 0/1 callers and probes that don't need it ignore it. AC-7 pins all three arms viarepr()substring assertions. logger: Loggerfield — Phase 0 uses stdliblogging, notstructlog. The architect's design usesstructlogelsewhere; theProbeContext.loggerfield is the Phase 0 contract and is unchanged. Coordinator wiresstructlogseparately at dispatch time.- Do NOT create a parallel
_ALLOWED_PROBE_CONTEXT_FIELDSset inside the regen script. The existingscripts/regen_probe_contract_snapshot.pyis allowed-field-agnostic — it walksdataclasses.fields(ProbeContext)directly. Encoding the allowed-list inside the script as well would create two sources of truth (test + script) that would have to be updated in lockstep. The test is the gate; that's enough. - Future Phase 3+ third-callable addition — queued kernel-extract trigger (Rule of Three). This story is the second precedent of "ProbeContext gains one optional callable per phase" (ADR-0002 + ADR-0004). When a Phase 3+ ADR adds a third optional callable, the rule-of-three trigger fires and the right shape becomes a
_ALLOWED_PROBE_CONTEXT_FIELDS_BY_ADR: Mapping[ADRId, frozenset[str]]registry — Open/Closed at the file boundary (a new ADR amendment is a new entry, not an edit to the existing tuple). Phase 3 owns that decision. Do NOT introduce the registry abstraction here (Rule 2 — Simplicity First; three similar lines beats premature abstraction at N=2). Just leave the tuple flat, with both ADRs cited in the failure message. - The Phase 0
forbidden-patternspre-commit is the structural backstop. It catchesmodel_construct, directsubprocess.run, and (per S1-11)model_constructunder the new Phase 2 packages. It does NOT catch arbitrary edits tobase.py; the contract-freeze snapshot is the defense there. AC-13 verifies the hook still passes after this story's edits. - The "fields-list tuple equality" assertion is strict (
==), not subset. Future contributors cannot silently dropparsed_manifest,input_snapshot, orimage_digest_resolvereither — the allowed-list is the exact tuple, and ordering matters (positional dataclass construction depends on it). - CODEOWNERS is operational, not blocking. The
TODO(S5-02): CODEOWNERS entry requiredcomment at the top ofbase.pyalready records the linkage. AC-14 verifies it is preserved. The actual CODEOWNERS file is owned by S5-02 / S8-04; do not create it here (Rule 2). - Why no subprocess-based sentinel test. The original draft prescribed a subprocess invocation of the regen script to validate rejection of a third field. The codebase's idiom (
test_probe_context_sentinel_fires_on_synthetic_third_field) is an in-test synthetic dataclass mutation-killer — deterministic, no subprocess overhead, no flakiness, mirrors the failure-message contract directly. Per Rule 11 (match conventions), this story mirrors that pattern.