Story S5-01 — ScenarioResult + ScannerOutcome shared discriminated unions¶
Status: Done
Completed: 2026-05-17
Attempts: 1 (GREEN on first pass — see _attempts/S5-01.md)
Evidence:
- Files: src/codegenie/probes/_shared/__init__.py, src/codegenie/probes/_shared/scanner_outcome.py, src/codegenie/probes/layer_c/__init__.py, src/codegenie/probes/layer_c/scenario_result.py, scripts/check_forbidden_patterns.py (S5-01 path predicate + Rule row)
- Tests: tests/unit/probes/_shared/test_scanner_outcome.py (24 tests / 36 rows), tests/unit/probes/layer_c/test_scenario_result.py (22 tests / 33 rows), tests/property/test_sum_types_roundtrip.py (2 Hypothesis properties), tests/unit/pre_commit/test_forbidden_patterns_phase2_extension.py (+15 new cells: 12 positive S5-01 + 3 negative neighbours)
- Gates: pytest 2431 passed / 15 skipped / 2 xfailed (pre-existing); ruff check clean; ruff format --check clean (304 files); mypy --strict src/ clean (97 files); lint-imports 2 contracts kept / 0 broken; coverage 93.30% (above 85% floor)
- Commit: (pending push)
Step: Step 5 — Ship Layer C (runtime + container) probes
Original status (pre-execution): Ready — HARDENED (validated 2026-05-16)
Effort: S
Depends on: S1-07 (run_external_cli lands the ProcessResult shape ScannerFailed mirrors), S3-03 (writer signature tightening — ScannerOutcome flows through the redaction chokepoint)
ADRs honored: 02-ADR-0001 (Layer C/G binaries — outcome types model the failure modes), 02-ADR-0006 (sum-type discipline for state machines)
Validation notes (2026-05-16)¶
Story hardened by phase-story-validator (_validation/S5-01-scenario-scanner-outcome-types.md). This is the 2nd canonical sum-type story in Phase 2 (S1-01 IndexFreshness was the 1st); the precedent set there now applies symmetrically. Verdict: HARDENED. Twelve in-place edits applied:
- Discriminator-string pinning (new AC-12): exact
"ran"/"skipped"/"failed"(ScannerOutcome) and the matching strings for ScenarioResult / TraceFailureReason / TraceSkipReason variants — symmetrickindswaps would round-trip but break every downstream consumer. Mirrors S1-01 hardening F2. - Nested-type roundtrip preservation (AC-5 tightened):
type(decoded.reason) is type(instance.reason)for everyTraceScenarioFailed/TraceScenarioSkippedandFindingelement onScannerRan. Guards a regression that dropsField(discriminator="kind")from the innerAnnotatedwrapper (the same regression S1-01 F1/mutation #3 caught). - JSON-shape pin (new AC-13): literal
model_dump(mode="json")snapshot for one ScannerOutcome variant and one ScenarioResult variant — pins the cross-dockinddiscriminator-field name at the JSON boundary, not just the Python-object boundary. - Unknown-discriminator rejection (new AC-14):
TypeAdapter(<Union>).validate_python({"kind": "bogus"})raisesValidationErrorfor all four unions (ScannerOutcome,ScenarioResult,TraceFailureReason,TraceSkipReason). - Exhaustive
matchover inner unions (new AC-6a):TraceFailureReasonandTraceSkipReasonget their ownassert_neverconsumer helpers; rehearses the discipline at every level, not just the top. - Hypothesis property test (new AC-15): adds
tests/property/test_sum_types_roundtrip.pycovering both unions, mirroring S1-01's ADR-0006-§Consequences-anchored property test. Symmetry argument is load-bearing. Finding.metadataJSONValue round-trip (new AC-16): an arbitrary nestedJSONValuepayload round-trips byte-for-byte throughScannerRan(findings=[Finding(...)])— pins consumption of Phase 1's existingJSONValueand catches a regression todict[str, Any].- Frozen + extra=forbid mutation-resistance test (new AC-17):
inst.kind = "other"raisesValidationError(frozen);Model(..., extra_field=1)raisesValidationError(extra=forbid) — for every variant of both unions. Mirrors S1-01. __all__is pinned literally (new AC-18): regression test assertsset(module.__all__) == EXPECTEDto catch silent export drift.- AC-10 rewritten — repo-wide mypy flag (consistency fix): S1-11 validation confirmed
[tool.mypy] warn_unreachable = trueis already repo-wide (pyproject.tomlline 141, since Phase 0 S1-02). Per-module overrides for these two modules would be redundant noise; AC-10 now asserts the repo-wide flag is in force and the module is included in defaultmypy --strictruns. - AC-11 hedge dropped —
forbidden-patternsextension is required (consistency fix): inspection ofscripts/check_forbidden_patterns.pyconfirms_is_under_phase2_banned_packagecovers{indices, tccm, skills, conventions, adapters, depgraph, output}— does NOT coverprobes/_shared/orprobes/layer_c/. The script must be extended (new path predicate or extended package set) with a dedicated test parametrized over the fourmodel_constructsource-forms × the two new path scopes, mirroring S1-11's AC-2/AC-3 pattern. - Smart-constructor module constant (CF8 / DF2):
STDERR_TAIL_CAP_BYTES: Final[int] = 4096is exposed as a module-level constant, both thefield_validatorand the boundary tests import it (no magic number duplication); module docstring contrasts this per-outcome cap with the S3-03 writer's 64 MB cap.
Notes-for-implementer extended with four new paragraphs: variant-set extension is ADR-amendment-gated (NOT Open/Closed — mirrors S1-01); producer/consumer assert_never ladder discipline (this module is the producer; S5-02 / S5-04 / S6-06 / S6-07 / S6-08 are consumers); arch-doc drift note (phase-arch-design.md §"Data model" line 731 still pins layer_g/scanner_outcome.py — High-level-impl.md §172 is the authoritative location); scenario_name newtype deferral to S1-05.
Context¶
Layer C's RuntimeTraceProbe (S5-02) and Layer G's scanner family (SemgrepProbe, SyftProbe, GrypeProbe, GitleaksProbe; S5-04 + S6-06 + S6-07) both need typed outcomes. RuntimeTraceProbe runs 5 scenarios per gather; each scenario can complete, fail (timeout / docker-build error / strace-unavailable), or be skipped (no Dockerfile present, image-digest unresolved). Every Layer G scanner can run, be skipped (tool missing), or fail (non-zero exit, invalid-JSON stdout). The architecture (phase-arch-design.md §"Data model" + §"Component design" #5–#6) names two discriminated unions:
ScenarioResult = TraceScenarioCompleted | TraceScenarioFailed | TraceScenarioSkipped— Layer C only.ScannerOutcome = ScannerRan | ScannerSkipped | ScannerFailed— shared between Layer C (SyftProbe/GrypeProbein S5-04) and Layer G (S6-06/S6-07/S6-08). Both layers must import the same type, so the type lives undercodegenie/probes/_shared/per the manifest's pinned location.
This story plants both unions before any probe consumes them. ADR-0006's sum-type discipline (ADR-0033 §3, make-illegal-states-unrepresentable) applies: every variant carries a kind: Literal[…] discriminator, Pydantic frozen=True, extra="forbid", round-trip identity through model_dump_json / model_validate_json is asserted by test, and consumers match exhaustively with assert_never on the otherwise-reachable branch.
References¶
- phase-arch-design.md §"Component design" #5 (Layer G scanners) —
ScannerOutcomeshape. - phase-arch-design.md §"Component design" #6 (
RuntimeTraceProbe) —ScenarioResultshape. - phase-arch-design.md §"Data model" — explicit Pydantic class skeletons;
Field(discriminator="kind"). - phase-arch-design.md §"Agentic best practices" — "Typed state" — every state machine is a Pydantic discriminated union;
mypy --warn-unreachableenforcement. - phase-arch-design.md §"Edge cases" rows 2, 3, 5, 6 — failure paths that map to each variant.
- final-design.md §"Components" #5, #6 — synthesis-pinned shapes.
- localv2.md §5.3 C4 —
scenarios_run,scenarios_failed,trace_coverage_confidencesemantics. - 02-ADR-0006 (sum-type freshness location — sets the discipline).
- Phase 1 ADR-0011 (sum-type round-trip property) — same discipline pattern reused.
Goal¶
Land two pure-typing modules — src/codegenie/probes/layer_c/scenario_result.py and src/codegenie/probes/_shared/scanner_outcome.py — exporting Pydantic discriminated unions with kind discriminators, JSON round-trip identity, and exhaustive match enforced at the type level for downstream consumers. Zero probes consume these in this story; S5-02 / S5-04 / S6-06 / S6-07 / S6-08 are the consumers.
Acceptance criteria¶
- [x]
src/codegenie/probes/layer_c/scenario_result.pyexists and exportsTraceScenarioCompleted,TraceScenarioFailed,TraceScenarioSkipped,StraceUnavailable,ScenarioResult, and the variants'kindLiteral values;__all__is the authoritative export list. - [x]
src/codegenie/probes/_shared/__init__.pyandsrc/codegenie/probes/_shared/scanner_outcome.pyexist; exportsScannerRan,ScannerSkipped,ScannerFailed,ScannerOutcome; both Layer C (S5-04) and Layer G (S6-06/07/08) probes import from this location (no duplicate definitions). - [x] Every variant is a Pydantic
BaseModelwithmodel_config = ConfigDict(frozen=True, extra="forbid")and akind: Literal["..."]field with a unique value. - [x]
ScenarioResultandScannerOutcomeareAnnotated[Union[...], Field(discriminator="kind")](exactly as phase-arch-design.md §"Data model" prescribes). - [x] Round-trip identity test: for every variant of both unions,
parse(dump(v)) == vbyte-for-byte throughmodel_dump_json/model_validate_json(Hypothesis-friendly; the property test in S7-05 extends this). The roundtrip MUST additionally preserve nested discriminated-union types: for everyTraceScenarioFailed(reason=R)andTraceScenarioSkipped(reason=R)instance,type(decoded.reason) is type(instance.reason)ANDdecoded.reason == instance.reason; for everyScannerRan(findings=[Finding(...)])instance,[type(f) for f in decoded.findings] == [type(f) for f in instance.findings]. This guards a regression that dropsField(discriminator="kind")from the innerAnnotatedwrapper — a regression which would otherwise round-tripStale-style equality while silently deserializingreasonas a plaindict. - [x] Exhaustive
matchtest: a helper consumer function_describe(outcome) -> strmatches every variant andassert_nevers the otherwise branch; deliberately removing onecaseand runningmypy --warn-unreachableagainst the test file produces a build error (proves the discipline is enforceable). The deletion test is documented but not committed in the deleted state — it is the smoke-test ofmypyconfiguration once S1-11's per-module override is in place. - [x]
StraceUnavailableis a Pydantic model carried as thereasonfield onTraceScenarioFailedwhen the macOS path triggers; thereasonfield's type is itself a discriminated union (placeholder variants today:StraceUnavailable | DockerBuildFailed | ScenarioTimeout | ImageDigestUnresolved) so S5-02 cannot smuggle a string. Each placeholder variant ships withkind: Literal[…]+ round-trip; new variants are added by S5-02 implementation as needed (not invented speculatively here). - [x]
TraceScenarioSkippedcarries a typedreason(e.g.,NoDockerfile,ImageBuildUnavailable) — same discriminated-union discipline. - [x]
ScannerFailedcarriesexit_code: intandstderr_tail: str(capped at 4 KB at construction time —field_validatortruncates if longer; documented in module docstring as "the writer caps further at 64 MB; this is the per-outcome cap"). - [x]
ScannerSkippedcarriesreason: Literal["tool_missing", "tool_unhealthy", "upstream_unavailable"]— a Literal-string enum keeps the slack tight; adding a fourth requires an ADR amendment to 02-ADR-0001's "what shape do scanner outcomes take" footnote or a follow-up ADR. - [x]
mypy --strictclean on both modules. No per-module[[tool.mypy.overrides]]edit is required:[tool.mypy] warn_unreachable = trueis already repo-wide since Phase 0 S1-02 (verified atpyproject.tomlline 141; established by S1-11 validation as "honored-broader-than-arch"). A test (or static assertion in the CI fence) asserts the repo-wide flag is present and unmodified; both new modules are included in the defaultmypy --strictglob (i.e., noexclude = …entry added). - [x] No file under
src/codegenie/probes/imports a discriminated-union variant by name from outside_shared/orlayer_c/scenario_result.py(a smoke import test asserts this); the contract is "import the union, not the variants" except for construction. - [x]
scripts/check_forbidden_patterns.pyis extended to banmodel_constructundersrc/codegenie/probes/_shared/**andsrc/codegenie/probes/layer_c/scenario_result.py. Inspection at validation time confirms_is_under_phase2_banned_packagecurrently covers{indices, tccm, skills, conventions, adapters, depgraph, output}and does NOT coverprobes/_shared/orprobes/layer_c/; the hedge "if not already covered" from the original draft is dropped. The extension MUST live inside the script'sapplies_whenpredicate (path-scoped rule) per S1-11 AC-1's discipline — NOT in.pre-commit-config.yaml'sfiles:/exclude:regex — so the test surface and runtime surface are the same. A dedicated test parametrizes the foursource_form ∈ {class_call, instance_call, kwarg, renamed_class}× two new paths (src/codegenie/probes/_shared/synth.py,src/codegenie/probes/layer_c/scenario_result.py-style synth) — 8 combinations, each expected to exit non-zero with both02-ADR-0010 §Decisionandproduction ADR-0033 §3substrings emitted (mirrors S1-11 AC-2). Negative coverage:probes/layer_a/synth.pyandprobes/layer_b/synth.pyMUST exit zero (the predicate is surgical, not blanket — mirrors S1-11 AC-3). - [x] Discriminator strings are exactly pinned (cross-doc contract). For each variant of each union, a dedicated test asserts the exact string named in
phase-arch-design.md §"Component design"#5 / #6 — forScannerOutcome:ScannerRan().kind == "ran",ScannerSkipped(reason="tool_missing").kind == "skipped",ScannerFailed(exit_code=1, stderr_tail="").kind == "failed". ForScenarioResult: the matching strings ("completed","failed","skipped"). ForTraceFailureReason:"strace_unavailable","docker_build_failed","scenario_timeout","image_digest_unresolved". ForTraceSkipReason:"no_dockerfile","image_build_unavailable". A symmetric swap (e.g.,ScannerRan.kind = "failed"+ScannerFailed.kind = "ran") would round-trip cleanly but break every downstream consumer + every renderer / golden file — this test is the structural pin against that mutation. - [x] JSON-shape pin: a dedicated test asserts
model_dump(mode="json")produces a literal dict with key"kind"(not"tag","type", etc.) for one ScannerOutcome variant (ScannerSkipped(reason="tool_missing")→{"kind": "skipped", "reason": "tool_missing"}) and one ScenarioResult variant (TraceScenarioFailed(scenario_name="startup", reason=StraceUnavailable())→{"kind": "failed", "scenario_name": "startup", "reason": {"kind": "strace_unavailable"}}). Catches a symmetric rename of the discriminator field name (e.g.,kind → tagon every variant) which round-trip identity would otherwise tolerate. - [x] Unknown discriminator rejection at the top-level AND every nested level. For each of
ScannerOutcome,ScenarioResult,TraceFailureReason,TraceSkipReason, a parametrized test assertsTypeAdapter(<Union>).validate_python({"kind": "bogus_<name>"})raisespydantic.ValidationError. Pins theField(discriminator="kind")wrapper at every level of the sum-of-sums. - [x] Exhaustive
matchtest over the inner unions (AC-6a): helper consumer functions_describe_failure_reason(r: TraceFailureReason) -> strand_describe_skip_reason(r: TraceSkipReason) -> streachmatchevery variant andassert_neveron the otherwise branch — symmetric with the top-level discipline in AC-6. The producer/consumerassert_neverladder discipline must be rehearsed at EVERY level of the sum, not just the top (S5-05 freshness + S8-01 renderer willmatchonreasontoo). - [x] Hypothesis property test (new file
tests/property/__init__.pyif not present, plustests/property/test_sum_types_roundtrip.py): for any Hypothesis-generated value ofScannerOutcomeAND any Hypothesis-generated value ofScenarioResult, the JSON round-trip is byte-identical AND nested-type-preserving. Strategies registered for each variant; integer ranges bounded (exit_code ∈ [0, 255],stderr_taillength ∈ [0, 4096]); the property is the load-bearing argument that pre-shipping the sum types pays — exhaustive over input space, not just example-based. Mirrors S1-01'stests/property/test_index_freshness_roundtrip.py. Forms the basis of S7-05's portfolio integration. - [x]
Finding.metadataJSONValue round-trip: a dedicated test constructsScannerRan(findings=[Finding(id="rule-1", severity="medium", metadata={"a": [1, 2.0, "x", True, None, {"nested": [{"deep": [None]}]}]})])and asserts byte-identical JSON round-trip preserves themetadatatree, including nestedlist/dict/None. Pins consumption of Phase 1's existingJSONValue(atsrc/codegenie/parsers/__init__.py:34) and catches a regression tometadata: dict[str, Any](which would still round-trip primitives but lose the recursiveJSONValueconstraint mypy enforces). - [x] Frozen + extra="forbid" mutation-resistance: for every variant of both unions, a parametrized test asserts (a)
inst.kind = "other"raisesValidationError(frozen discipline); (b) constructing with an unexpected field — e.g.,ScannerRan(findings=[], extra_field=1)— raisesValidationError(extra=forbid discipline). One test per (variant, mutation) crosscut. - [x]
__all__is pinned literally: a test assertsset(module.__all__) == EXPECTED_NAMES_SCANNERfor_shared/scanner_outcome.pyandset(module.__all__) == EXPECTED_NAMES_SCENARIOforlayer_c/scenario_result.py, where the expected sets are the variant names + the union alias + (for scenario) the innerTraceFailureReason/TraceSkipReasonreason types. Catches silent export drift. - [x]
stderr_tailcap as named module constant:STDERR_TAIL_CAP_BYTES: Final[int] = 4096is exposed as a module-level constant in_shared/scanner_outcome.py. Both thefield_validatorand the boundary tests import it (no magic number duplication). Module docstring contrasts this per-outcome cap with the S3-03 writer's 64 MB cap; the test asserts the constant isFinal[int] = 4096. - [x]
stderr_tailcap boundary mutation-resistance: parametrized test over input lengths {0, 1, 4095, 4096, 4097, 8192}; expected output lengths {0, 1, 4095, 4096, 4096, 4096}. Pins off-by-one in[: cap]slicing. - [x]
ScannerSkipped.reasonLiteral closure: a parametrized test cycles every value in{"tool_missing", "tool_unhealthy", "upstream_unavailable"}(success) and asserts at least three out-of-set strings ("","ad_hoc","TOOL_MISSING"— note casing) each raiseValidationError. - [x]
Finding.severityLiteral closure: a parametrized test cycles every value in{"info", "low", "medium", "high", "critical"}(success) and at least three out-of-set strings ("unknown","INFO","") each raiseValidationError. - [x] Source-scan for
model_constructin both new modules:(Path(scanner_outcome.py.read_text())and the scenario module) do not contain the literal substringmodel_construct(other than potentially in a docstring naming the ban). Complementary to theforbidden-patternsscript extension; survives even if pre-commit is bypassed.
Implementation outline¶
- Create
src/codegenie/probes/_shared/__init__.pyandsrc/codegenie/probes/_shared/scanner_outcome.py. - Define
ScannerRan,ScannerSkipped,ScannerFailedas Pydantic models withfrozen=True, extra="forbid"andkinddiscriminators.ScannerRan.findings: list[Finding]is a forward reference to aFindingplaceholder Pydantic model (also defined in this module — minimal shape:kind: Literal["finding"],id: str,severity: Literal["info","low","medium","high","critical"],metadata: dict[str, JSONValue]). The fullFindingshape evolves with S5-04 / S6-06 / S6-07; today it is the smallest model that satisfies round-trip. - Export
ScannerOutcome = Annotated[Union[ScannerRan, ScannerSkipped, ScannerFailed], Field(discriminator="kind")]. - Create
src/codegenie/probes/layer_c/__init__.pyandsrc/codegenie/probes/layer_c/scenario_result.py. - Define
StraceUnavailable,DockerBuildFailed,ScenarioTimeout,ImageDigestUnresolvedas Pydantic models (eachkind: Literal["…"]), and aTraceFailureReason = Annotated[Union[...], Field(discriminator="kind")]union for the innerreasonfield. - Define
NoDockerfile,ImageBuildUnavailableas Pydantic models, and aTraceSkipReason = Annotated[Union[...], Field(discriminator="kind")]union forTraceScenarioSkipped.reason. - Define
TraceScenarioCompleted(kind, scenario_name: str, artifact_uri: Path, wall_clock_ms: int, syscalls_observed: int, shared_libs_count: int);TraceScenarioFailed(kind, scenario_name, reason: TraceFailureReason);TraceScenarioSkipped(kind, scenario_name, reason: TraceSkipReason). - Export
ScenarioResult = Annotated[Union[TraceScenarioCompleted, TraceScenarioFailed, TraceScenarioSkipped], Field(discriminator="kind")]. - Write the round-trip + exhaustive-match tests under
tests/unit/probes/_shared/test_scanner_outcome.pyandtests/unit/probes/layer_c/test_scenario_result.py. - Extend
pyproject.toml[tool.mypy]per-module overrides if S1-11 hasn't already pinnedcodegenie.probes._shared.*andcodegenie.probes.layer_c.scenario_result— surface the diff in "Notes for the implementer".
TDD plan — red / green / refactor¶
Red (write before code; both files start absent or empty):
test_scanner_outcome_roundtrip(tests/unit/probes/_shared/test_scanner_outcome.py): importScannerOutcomeand each variant; construct one of each (withScannerRan(findings=[Finding(...)])carrying at least one element); for each constructed valuev, asserttype(v).model_validate_json(v.model_dump_json()) == vAND[type(f) for f in decoded.findings] == [type(f) for f in v.findings]forScannerRan. Initial state:ModuleNotFoundError.test_scanner_outcome_match_exhaustive: a private_describe(outcome: ScannerOutcome) -> strdefined inside the test modulematches eachkindandassert_never(outcome)on the otherwise branch; assert each variant's string. Initial state: import fails.test_scenario_result_roundtrip(tests/unit/probes/layer_c/test_scenario_result.py): construct one of each top-level variant; forTraceScenarioFailed, parametrize over everyTraceFailureReasonvariant (4) and assert nested-type preservationtype(decoded.reason) is type(instance.reason); forTraceScenarioSkipped, parametrize over everyTraceSkipReasonvariant (2) with the same nested-type assertion. Initial state:ModuleNotFoundError.test_scenario_result_match_exhaustive: helper_describe(result: ScenarioResult) -> strwith exhaustive match +assert_neveron the otherwise branch. Initial state: import fails.test_trace_failure_reason_match_exhaustiveandtest_trace_skip_reason_match_exhaustive(AC-6a): helpers_describe_failure_reason(r: TraceFailureReason) -> strand_describe_skip_reason(r: TraceSkipReason) -> streachmatchevery variant withassert_never. Initial state: import fails.test_strace_unavailable_is_typed:TraceScenarioFailed(scenario_name="startup", reason=StraceUnavailable())round-trips withreason.kind == "strace_unavailable"; the parametrized matrix in Test 3 already covers the other inner variants.test_scanner_failed_stderr_tail_truncates: parametrized over input lengths{0, 1, 4095, 4096, 4097, 8192}(expected output lengths{0, 1, 4095, 4096, 4096, 4096}); the test importsSTDERR_TAIL_CAP_BYTESfrom the module — no magic number repetition.test_scanner_skipped_reason_literal_closure: parametrized successes over{"tool_missing", "tool_unhealthy", "upstream_unavailable"}; parametrized failures over{"", "ad_hoc", "TOOL_MISSING"}(each raisesValidationError).test_finding_severity_literal_closure: parametrized successes over{"info", "low", "medium", "high", "critical"}; parametrized failures over{"unknown", "INFO", ""}(each raisesValidationError).test_discriminator_strings_are_exactly_pinned: for every variant of every union, assertVariant(...).kind == "<exact string named in phase-arch-design.md §Component design #5/#6>". Strings as a literal table in the test file; would catch any symmetric swap.test_json_shape_pinned: assertScannerSkipped(reason="tool_missing").model_dump(mode="json") == {"kind": "skipped", "reason": "tool_missing"}andTraceScenarioFailed(scenario_name="startup", reason=StraceUnavailable()).model_dump(mode="json") == {"kind": "failed", "scenario_name": "startup", "reason": {"kind": "strace_unavailable"}}. Catches symmetrickind → tagrename.test_unknown_discriminator_is_rejected: parametrized over all four unions (ScannerOutcome,ScenarioResult,TraceFailureReason,TraceSkipReason); each assertsTypeAdapter(<Union>).validate_python({"kind": f"bogus_{name}"})raisespydantic.ValidationError.test_models_are_frozen_and_forbid_extra: parametrized over every variant of both unions × {mutate_kind,extra_field}; asserts each (variant, mutation) raisesValidationError. Mirrors S1-01.test_all_exports_are_pinned: assertset(scanner_outcome.__all__) == EXPECTED_SCANNER_NAMESandset(scenario_result.__all__) == EXPECTED_SCENARIO_NAMES, with the expected sets literal in the test.test_finding_metadata_jsonvalue_roundtrip: constructsScannerRan(findings=[Finding(id="rule-1", severity="medium", metadata={"a": [1, 2.0, "x", True, None, {"nested": [{"deep": [None]}]}]})]); asserts byte-identical JSON round-trip ANDdecoded.findings[0].metadata == original.metadata.test_modules_have_no_model_construct: source-scan over the bytes of both new module files; asserts the literal substringmodel_constructdoes not appear (a docstring naming the ban is the only allowed occurrence; the test pins zero occurrences and the implementer omits the substring entirely from the source — name itpydantic ctorin docstrings instead).test_forbidden_patterns_extension_covers_shared_and_scenario_result(tests/unit/pre_commit/test_forbidden_patterns_phase2_extension.py— extends existing S1-11 test): parametrized over 4source_form× 2 new path scopes (src/codegenie/probes/_shared/synth.py,src/codegenie/probes/layer_c/synth.py); each of the 8 combinations writes a synthetic.pyfile undertmp_path, runsscripts/check_forbidden_patterns.pyvia subprocess, asserts exit non-zero AND output contains both02-ADR-0010 §Decisionandproduction ADR-0033 §3. Negative coverage: samesource_formwriting underprobes/layer_a/synth.pyandprobes/layer_b/synth.pyMUST exit zero.test_mypy_warn_unreachable_is_repo_wide: parsespyproject.tomland asserts[tool.mypy] warn_unreachable == True; asserts no[[tool.mypy.overrides]]block hasexcludematching_shared/scanner_outcomeorlayer_c/scenario_result(both modules covered by default).tests/property/test_sum_types_roundtrip.py(AC-15): Hypothesis strategies registered for every variant of both unions; the propertygiven(scanner_outcomes()) assert round_trip(v) == v and type_preserves(v, round_trip(v))and the equivalent forscenario_results(). Bounds:exit_code ∈ [0, 255],stderr_taillength ∈ [0, 4096],scenario_nameprintable ASCII length ∈ [1, 64],findingslist length ∈ [0, 16];metadataisrecursive(json_value())with depth bound 4 (matches Phase 1'sJSONValuedepth cap).
Green:
- Create the two modules with the variant models, the
Annotated[Union, Field(discriminator)]unions, and the helper validators (stderr_tailtruncation;Literalreasons). - Make every test pass without touching any consumer probe.
- Extend
scripts/check_forbidden_patterns.py's_is_under_phase2_banned_package(or itsapplies_whenpredicate) so the existing model_construct rule fires forprobes/_shared/**andprobes/layer_c/scenario_result.py.
Refactor:
- Extract
JSONValuetype alias usage to match Phase 0's existingJSONValueimport fromcodegenie.parsers(do not re-define). - Add module docstrings naming: (a) the consumers (S5-02 / S5-04 / S6-06 / S6-07 / S6-08); (b) the producer/consumer
assert_neverladder discipline ("this module is the producer; consumersmatchexhaustively and a new variant requires coordinated edits on every consumer + an ADR amendment to 02-ADR-0006 / a follow-up ADR"); (c) the per-outcomeSTDERR_TAIL_CAP_BYTES = 4096cap vs. the S3-03 writer's 64 MB cap. - Confirm
__all__exports are the union + the variant names + the placeholder reason types — the union is the public surface; variants are public only for construction. - Confirm
STDERR_TAIL_CAP_BYTES: Final[int] = 4096is exposed at module level and consumed by thefield_validator(no magic number duplication).
Files to touch¶
- New:
src/codegenie/probes/_shared/__init__.py,src/codegenie/probes/_shared/scanner_outcome.py,src/codegenie/probes/layer_c/__init__.py,src/codegenie/probes/layer_c/scenario_result.py. - New tests:
tests/unit/probes/_shared/__init__.py,tests/unit/probes/_shared/test_scanner_outcome.py,tests/unit/probes/layer_c/__init__.py,tests/unit/probes/layer_c/test_scenario_result.py,tests/property/__init__.py(if absent),tests/property/test_sum_types_roundtrip.py. - Extend (required):
scripts/check_forbidden_patterns.py— extend themodel_constructrule'sapplies_whenpredicate (or_is_under_phase2_banned_packageset) to coverprobes/_shared/**andprobes/layer_c/scenario_result.py. Verified at validation time: NOT currently covered. - Extend (required):
tests/unit/pre_commit/test_forbidden_patterns_phase2_extension.py— add parametrization for the two new path scopes (8 new positive + 2+ negative cases), mirroring S1-11 AC-2 / AC-3. - NO EDIT:
pyproject.toml[tool.mypy]—warn_unreachable = trueis already repo-wide since Phase 0 S1-02 (pyproject.tomlline 141). Per-module overrides would be redundant. Thetest_mypy_warn_unreachable_is_repo_widetest asserts the repo-wide flag is unchanged.
Out of scope¶
- Any probe implementation that constructs these values (
RuntimeTraceProbe= S5-02;SyftProbe/GrypeProbe= S5-04; Layer G scanners = S6-06 / S6-07 / S6-08). - The
Findingshape's eventual full schema — placeholder model lands here; real shape evolves with consumers. - Writer composition through
RedactedSlice(ScannerOutcomeflows through S3-03's writer signature — the writer already acceptsRedactedSlice, and this story doesn't change that). - Any change to
ProbeABC orProbeContext(banned in Phase 2 except for S1-09'simage_digest_resolver).
Notes for the implementer¶
- This is the 2nd canonical sum-type story in Phase 2 (S1-01
IndexFreshnesswas the 1st). The validator-hardened S1-01 is the precedent template: discriminator-string pinning, JSON-shape pinning, nested-type roundtrip, exhaustivematchat every level, Hypothesis property test, source-scan formodel_construct,__all__pinning. Read_validation/S1-01-index-freshness-sum-type.mdbefore implementing — it explains why each test exists and which mutation it catches. - Variant-set extension is deliberately NON-Open/Closed (mirrors S1-01 / 02-ADR-0006). The proliferation of
@register_*decorators elsewhere in Phase 2 (@register_probe,@register_index_freshness_check,@register_dep_graph_strategy) is NOT license to make these unions pluggable. Adding a fifthScannerOutcomevariant or a fifthTraceFailureReasonvariant is an ADR amendment to 02-ADR-0006 (or a follow-up ADR), not a registry-by-addition. Theassert_neverarms on every consumer'smatchare the structural enforcement; silentUnionwidening is impossible without breaking every consumer at mypy time. - Producer/consumer
assert_neverladder discipline (mirrorsIndexFreshness↔confidence_section.py). This module is the producer; consumers are S5-02 (RuntimeTraceProbe), S5-04 (SyftProbe/GrypeProbe), S6-06 (SemgrepProbe), S6-07 (GitleaksProbe), S6-08 (coverage mapping + freshness registry). The module docstring MUST name them. Every consumer'smatchladder mustassert_neveron the otherwise branch;mypy --warn-unreachable(repo-wide since Phase 0 S1-02) enforces the discipline once consumers land. - Documentation-debt acknowledgement.
phase-arch-design.md §"Data model"line 731 still pins[internal] codegenie/probes/layer_g/scanner_outcome.py— the location was revised during High-level-impl.md authoring (§172) when it became clear that Layer C'sSyftProbe/GrypeProbe(S5-04) ALSO consumeScannerOutcome._shared/is the correct location; the arch document is stale on this point. Do NOT edit the arch (Rule 3 — surgical changes); the High-level-impl.md location is authoritative. If a reviewer asks, point to this Validation note and S1-04 / S6-06 / S6-07 / S6-08 consumers fromlayer_g/+ S5-04 fromlayer_c/. - Smart constructor at the cap boundary (DF-2):
ScannerFailed.stderr_tailis capped at construction by afield_validator— Pydantic's smart-constructor idiom. The cap value MUST live asSTDERR_TAIL_CAP_BYTES: Final[int] = 4096at module level; tests, the validator, AND any future consumer that needs to compare bounds all import it. Do NOT inline the literal4096anywhere else in the module. scenario_namenewtype deferral.scenario_name: strcrosses ≥ 2 module boundaries (this module → S5-02 → S5-05 → S8-01); the closed set of 5 default scenarios (startup,smoke_test,healthcheck,shutdown,error_path) per High-level-impl.md §165 suggests aLiteral[...]orNewType("ScenarioName", str). S1-05 is the canonical newtype story (identifiers-newtypes); pre-empting that scope here is creep. Use rawstrfor now; S5-02 may extract to S1-05's newtype kernel once cross-module usage is concrete.- The choice to put
scanner_outcome.pyunder_shared/(notlayer_c/and notlayer_g/) is load-bearing — Layer C'sSyftProbe/GrypeProbeand Layer G's curated scanners (SemgrepProbe,GitleaksProbe, etc.) must import the sameScannerOutcometype. Duplicating it underlayer_c/scanner_outcome.pyandlayer_g/scanner_outcome.pywould re-introduce the structural drift Phase 2 is rejecting. If you find yourself wanting two locations, surface it in "Notes" and stop — that's an ADR-amend trigger. Findingis intentionally minimal here. Resist the urge to model semgrep / gitleaks / grype finding shapes now — S5-04 / S6-06 / S6-07 each emit their ownmetadatapayload and the union's job in this story is only to round-trip. If you find yourself adding scanner-specific fields, you've slipped into S6-06 / S6-07 / S6-08.- The
reasonfield onTraceScenarioFailedis itself a discriminated union — not a string. This is deliberate per phase-arch-design.md §"Edge cases" rows 5/6 + final-design.md §"Components" #6: macOS's permanent path emitsStraceUnavailable()and the consumer (S5-05's freshness check + S8-01's renderer) mustmatchon it as a typed value. A stringly-typedreason: strwould silently lose themypy --warn-unreachableenforcement and was the exact "anti-pattern" called out in §"Anti-patterns avoided". ScannerSkipped.reasonis the one place aLiteralmakes more sense than a sum type — three closed alternatives, no payload differs. If a fourth reason needs structured payload (e.g., "upstream_unavailable" needs the upstream slice name), promote to a discriminated union in a follow-up ADR rather than adding ametadata: dictescape hatch.- macOS
dtrussis not used; we emitStraceUnavailable()for any non-Linux host. The macOS path is permanent — final-design.md §"Where security/best-practices traded off perf" makes this explicit. - Open-question echo: the S1-11 per-module override list should include both new modules. If it doesn't (because S1-11 landed before this story was scoped), this story's PR extends
pyproject.tomlminimally; document the diff in PR description so the S1-11 ADR's "Consequences" can be reviewed. - The deliberate-
case-deletion exhaustiveness smoke test is documented in "Acceptance criteria" but not committed in deleted state. Treat it as a developer-runnable check: remove onecase, runmypy --warn-unreachable, confirm the error, restore thecase. This is part of S8-01's renderer Implementation risk #4 verification — landing the discipline here de-risks Step 8. - If
pytest-subprocessor any other test dep is needed, it lands as[project.optional-dependencies] dev = […]and is verified by Phase 0fence(it's not an LLM dep).