Skip to content

Story S3-03 — Writer signature tightening + envelope-level redactor composition + secrets_redacted_count log field

Step: Step 3 — Plant SecretRedactor + RedactedSlice smart constructor at the writer chokepoint Status: Done — executed 2026-05-16 (see _attempts/S3-03.md) Effort: S Depends on: S3-02 (RedactedSlice model; this story imports it at the writer + seam), S3-01 (redact_secrets body that produces the RedactedSlice; composition order pins this story's mock-spy test) ADRs honored: 02-ADR-0010 (RedactedSlice smart constructor at the writer boundary — type-level "redactor was called"), 02-ADR-0005 (no plaintext persistence — the chokepoint discipline this story finishes), 02-ADR-0008 (no event stream in Phase 2 — secrets_redacted_count is one new structured-log field, not an event-stream subscription)

Validation notes (phase-story-validator, 2026-05-16)

Verdict: HARDENED. The story's intent — tighten the writer signature, pin the composition order, emit a single secrets_redacted_count log field — traces cleanly to 02-ADR-0010, 02-ADR-0005, 02-ADR-0008, and phase-arch-design.md §"Gap 4" / §"Logging strategy". But the draft's prescriptions referenced phantom Phase-0 surfaces — six BLOCK-severity inconsistencies with master would have stalled the executor on the first tool call. The structural-fix shape from S3-01/S3-02 validations applies: keep the goal, correct the call sites. Edits applied:

  1. B1 (BLOCK) — write_envelope does not exist on master. Draft prescribed write_envelope(slice_: dict[str, JSONValue], ...) -> Path (a module-level function returning Path). Phase 0 ships class Writer with Writer.write(envelope: dict[str, Any], raw_artifacts: list[tuple[str, bytes]], output_dir: Path) -> None (verified at src/codegenie/output/writer.py:142). The CLI seam _seam_write_envelope(envelope, raw_artifacts, output_dir) -> bytes (verified at src/codegenie/cli.py:344) is the only call site and returns YAML bytes (for the audit anchor SHA), not Path. Fix: AC-1, AC-2, AC-3, AC-7, the Goal, References, Implementation outline, and Out-of-scope rewritten to name Writer.write (the method whose envelope parameter tightens from dict[str, Any] to RedactedSlice) AND _seam_write_envelope (the seam whose parameter must tighten in lock-step). The "return Path" claim removed throughout (the method returns None; the seam returns bytes).
  2. B2 (BLOCK) — Composition site is wrong (OutputSanitizer.scrub is per-probe, not envelope-level). Draft asserted the composition [field_name_regex_pass, json_value_tree_walk_pass, redact_secrets] is documented in OutputSanitizer.scrub. Master scrub(output: ProbeOutput, repo_root: Path) -> SanitizedProbeOutput (verified at src/codegenie/output/sanitizer.py:158) has two passes: (1) _walk_pass1_keys — secret-field-name rejection (raises SecretLikelyFieldNameError; NOT a regex replacement); (2) _scrub_container — absolute-path scrubbing (NOT a "JSONValue tree walk" depth-cap pass). The story's named passes do not exist. Worse, redact_secrets (S3-01 AC-1) takes dict[str, JSONValue] — not a ProbeOutput — so it cannot literally compose inside scrub. The arch doc resolves this at line 768: "Merged envelope flows through OutputSanitizer.scrubSecretRedactor.redact_secrets → writer." The redactor's natural composition site is the envelope-merge seam in cli.py, between Step 8 (_seam_shallow_merge) and Step 9 (_seam_validate_envelope), not inside per-probe scrub. Fix: AC-4, AC-5, AC-6, AC-7 and Implementation outline rewritten — composition is a new module-level _PASSES: list[Callable] in a new src/codegenie/output/envelope_redactor.py module (or equivalent — see implementer note) that the new _seam_redact_envelope(envelope) -> tuple[RedactedSlice, int] step in cli.py consumes; the docstring documenting the composition lives at the seam-level module, not sanitizer.py. The pre-existing Phase-0 per-probe OutputSanitizer.scrub is unchanged (preserves Phase 0 contract-freeze). The composition order pinned: _redact_known_patterns_pass (S3-01's named-pattern regex sweep) → _redact_entropy_pass (S3-01's entropy fallback) → _build_redacted_slice_pass (the smart-constructor closure that returns RedactedSlice). Note: the original draft's claim that "Phase 0's field-name regex + JSONValue tree walk run before redact_secrets" is preserved as architectural context (per-probe scrub runs first; envelope-merge happens; then envelope-redactor runs), but it is no longer a _PASSES membership claim — those Phase-0 passes are upstream, not co-located.
  3. B3 (BLOCK) — event="envelope.written" does not exist on master. Draft asserted the new field rides on an existing writer-completion event. grep -rn '_log\.info\|logger\.info' src/codegenie/output/writer.py returns zero hits (verified — the Phase-0 Writer.write is silent; only _log.warning("writer.csafe.unavailable"|"writer.symlink.refused") exist). No envelope.written event is emitted by Phase 0. Fix: AC-11 reworded — this story introduces the writer-completion event event="envelope.written" as the carrier for secrets_redacted_count. AC-11 asserts (a) the event is emitted exactly once per Writer.write call; (b) the event name is the constant EVENT_ENVELOPE_WRITTEN: Final[str] = "envelope.written" from src/codegenie/logging.py (no string literal at the call site, same regression-resistance discipline as SECRETS_REDACTED_COUNT_FIELD); (c) the event is emitted on the success path after _atomic_write_bytes returns (so a failed write does not emit a misleading "written" signal). The event is single-event, single-field — 02-ADR-0008's "no event stream" is honored (one new structured-log field on a documented success event, not an event-bus subscription).
  4. B4 (BLOCK) — AC-1's reveal_type mechanism is invalid. Draft prescribed reveal_type(write_envelope.__annotations__["slice_"]) corresponds to RedactedSlice. reveal_type is a mypy directive emitted by the type-checker, not a runtime callable that "corresponds to" a value. Implementer note 148 acknowledges "Pick the runtime form for AC-1" but the AC text never updates. Fix: AC-1 rewritten — runtime introspection via typing.get_type_hints(Writer.write)["envelope"] is RedactedSlice AND typing.get_type_hints(_seam_write_envelope)["envelope"] is RedactedSlice. Both must hold (the seam and the method are the two consumer surfaces; both narrow in lock-step). A regression that tightens only one of the two is caught.
  5. B5 (BLOCK) — AC-13 contract-freeze claim unverifiable at validation time. Draft said "verify against the actual snapshot file at implementation time." A validator cannot tell whether tests/unit/test_probe_contract.py snapshots Writer.write or _seam_write_envelope without grep. Fix: AC-13 rewritten with the explicit verification recipe: result = subprocess.run([sys.executable, "-c", "import tests.unit.test_probe_contract as m; print(repr(getattr(m, '_WRITER_WRITE_SNAPSHOT', None) or getattr(m, '_PROBE_ABC_SNAPSHOT', None)))"], …). The test names the two candidate snapshot constants by programmatic enumeration: a regression that adds Writer.write to the frozen surface (so the signature tightening fails the snapshot) is caught here. The PR description must document the snapshot diff if any.
  6. B6 (BLOCK) — redact_secrets's probe_name parameter has no envelope-level meaning. Draft assumed redact_secrets (S3-01 AC-1: redact_secrets(slice_: dict[str, JSONValue], probe_name: ProbeId)) composes inside OutputSanitizer.scrub where probe_name is in scope per-probe. At the envelope-merge layer the merged envelope has no single probe_name — findings come from many probes. Fix: AC-12 + Implementation outline + Notes-for-implementer pin the envelope-level convention: the seam calls redact_secrets(envelope, ProbeId("__envelope__")) (a sentinel ProbeId value reserved for the envelope-merge pass). The SecretFinding.probe_name field carries "__envelope__" for any finding the per-probe scrub missed — visible to the CLI summary as "secrets matched at envelope merge". AC-12b added: assert that the per-probe pass also runs (when wired by Phase 2's S6-06/S6-07 scanners) so the dominant attribution path (per-probe) is preserved; envelope-level redaction is the safety net, not the first line of defense.

  7. F1 (harden) — AC-2 mypy invocation under-specified. Draft said "subprocess mypy --strict" without pinning python -m mypy (canonical invocation in CI), the fixture path, or the exact error-substring contract. Mirror S1-11 AC-2 / S3-02 F1 — both substrings must appear. Fix: AC-2 hardened: subprocess.run([sys.executable, "-m", "mypy", "--strict", str(fixture_path)], capture_output=True, text=True, cwd=<repo_root>) against tests/unit/output/_fixtures/raw_dict_to_writer.py (per implementer note 147). Assert result.returncode != 0. Assert result.stdout contains BOTH "incompatible type" AND 'expected "RedactedSlice"' (the and contract). AC-2b added: tests/unit/output/_fixtures/redacted_slice_to_writer.py (a clean snippet calling Writer.write(my_redacted_slice, [], output_dir)) must produce result.returncode == 0 — the positive control proving the fixture-mypy harness is wired correctly.

  8. F2 (harden) — AC-3 runtime-rejection mechanism ambiguous. Draft offered three candidate mechanisms (TypeError, AttributeError, isinstance); implementer note 149 picks isinstance but AC-3 didn't pin it. A regression that lets a raw dict reach Writer.write and coincidentally fails later (e.g., on slice_.findings_count) would satisfy a permissive AC-3 even though the writer never guarded. Fix: AC-3 hardened — Writer.write body, FIRST executable statement, is if not isinstance(envelope, RedactedSlice): raise TypeError(...); the TypeError message contains the substring RedactedSlice (case-sensitive) AND the substring 02-ADR-0010 (so the failure points the reader at the source-of-truth). Test asserts: pytest.raises(TypeError, match=r"RedactedSlice.*02-ADR-0010"). AC-3b added: programmatic check via inspect.getsource(Writer.write) — the isinstance(envelope, RedactedSlice) guard appears in the source (a regression that drops the check is caught by source-level inspection, NOT by Python type-hints at runtime which are stripped). The same guard is duplicated in _seam_write_envelope (defense-in-depth — the seam is the public consumer; the method is the internal consumer; both reject).
  9. F3 (harden) — AC-5 mock-spy mechanism requires explicit _PASSES indirection. Draft said wrap each pass with Mock(wraps=original). But if the seam-level composition inlines three function calls (no _PASSES indirection), Mock(wraps=original) cannot intercept — module-attribute monkeypatching against function references does not redirect direct calls inside the same module. Fix: Implementation outline + AC-5 pin the structural requirement: _PASSES: tuple[SanitizerPass, ...] = (_redact_known_patterns_pass, _redact_entropy_pass, _build_redacted_slice_pass) is a module-level tuple in src/codegenie/output/envelope_redactor.py; the seam calls _redact_envelope(envelope) which iterates for pass_ in _PASSES. Mock-spy test monkeypatches envelope_redactor._PASSES = (Mock(wraps=_redact_known_patterns_pass), Mock(wraps=_redact_entropy_pass), Mock(wraps=_build_redacted_slice_pass)) and asserts the recorded call sequence in a shared record: list[str] (each spy appends its name before delegating). The record mechanism (recommended in original implementer note 146) is pinned in the AC.
  10. F4 (harden) — AC-6 mutation test asserts the wrong thing. Draft text: "the test asserts the non-mutated order is still verified by the spy chain (i.e., the mutation test is the negative form of AC-5 — under the mutation, the assertion fails)." This is confusing — it conflates the positive and negative cases. Fix: AC-6 rewritten with two-part structure: (a) test_reorder_mutation_changes_recorded_order — monkeypatch _PASSES = (_redact_entropy_pass, _redact_known_patterns_pass, _build_redacted_slice_pass) (entropy before known-patterns); call _redact_envelope; assert the recorded order is ["entropy", "known_patterns", "build"], NOT the canonical order. This proves the mechanism is sensitive. (b) test_canonical_order_under_no_mutation — assert with the unmodified _PASSES the recorded order is ["known_patterns", "entropy", "build"]. Both must pass.
  11. F5 (harden) — AC-9/AC-10 fixture shape conflates unit vs integration. Draft said "gather a fixture repo with no secrets" / "gather a fixture repo with three seeded secrets" — but the story is unit-level (writer + seam + log emission), not end-to-end gather. A full gather pipeline as a unit test is slow and brittle. Fix: AC-9, AC-10 rewritten to construct a RedactedSlice directly via redact_secrets({}, ProbeId("__envelope__")) (zero-secret case) or via a seeded fixture dict (three-secret case), pass to Writer.write via a tmp_path output_dir, capture logs with structlog.testing.capture_logs(), and assert the event + field. The end-to-end-gather form is owned by S6-07 / S7-04. Implementer note 153 added pinning the unit-vs-integration split.
  12. F6 (harden) — AC-12 dataflow assertion missing source-level mechanism. Draft said "no SecretFinding data appears in any persisted artifact" without pinning how the test verifies. Fix: AC-12 hardened — read output_dir/repo-context.yaml after Writer.write; assert via substring search that NONE of the SecretFinding field names ("pattern_class", "cleartext_len") appear anywhere in the YAML bytes. Assert via substring search that NONE of the canonical seeded plaintexts (e.g., "AKIAIOSFODNN7EXAMPLE") appear. Assert "<REDACTED:fingerprint=" DOES appear (positive control — the redactor ran). A regression that threads the findings list into the envelope is caught by the first negative; a regression that disables the redactor is caught by the third positive.
  13. F7 (harden) — EVENT_ENVELOPE_WRITTEN / SECRETS_REDACTED_COUNT_FIELD import-by-name unenforced. AC-8 said "no string literal at the call site" but pinned no mechanism. Same shape as S3-02 AC-14 (regex-based source check). Fix: AC-8 hardened with a programmatic source check — inspect.getsource(Writer.write) does NOT contain the literal "secrets_redacted_count" (string-literal regex: re.compile(r'["\']secrets_redacted_count["\']')). Same for "envelope.written" at the call site. The constants are imported and referenced by name. AC-8b added: SECRETS_REDACTED_COUNT_FIELD in codegenie.logging.__all__ AND EVENT_ENVELOPE_WRITTEN in codegenie.logging.__all__. A regression that forgets to export is caught.
  14. F8 (harden) — Cross-story integration with S3-01 + S3-02 unasserted. S3-02's analogous F8 closure mandates a parametrized integration test feeding redact_secrets slices of {0, 1, 3-distinct, same-fingerprint-twice} secrets. The S3-03 layer's natural extension: each of those cases, fed end-to-end through _seam_redact_envelope + _seam_write_envelope, produces the expected secrets_redacted_count=N log emission AND a YAML file whose contents satisfy the F6 substring contract. Fix: AC-15 added — parametrized over the four shapes; for each, assert (a) the log event carries the expected count; (b) the YAML satisfies the F6 substring contract; (c) the RedactedSlice round-trips through model_validate(model_dump()) post-write (the writer must not mutate the slice). Pins the structural-defense ladder's end-to-end witness.
  15. F9 (harden) — Module-docstring assertion technique unspecified. AC-4 said the docstring documents composition order but pinned no test mechanism. Same F11 closed in S3-01 / S3-02. Fix: AC-4 strengthened — programmatic check via inspect.getdoc(codegenie.output.envelope_redactor) substring-matches all four substrings: "02-ADR-0005", "02-ADR-0010", "02-ADR-0008", AND "Three-pass composition" (or equivalent ladder framing). A regression that drops any of the four is caught.
  16. F10 (harden) — Per-probe scrub-still-runs invariant unasserted. B6's fix names the per-probe + envelope two-layer defense, but no AC enforces that S3-01's per-probe attribution path is preserved. Fix: AC-16 added — given a fixture probe output whose schema_slice already contains a <REDACTED:fingerprint=…> placeholder (per-probe scrub ran upstream), the envelope-level _redact_envelope is idempotent: re-scanning a placeholder string does NOT produce a new finding (the placeholder is not itself a high-entropy match — verify against the entropy threshold). A regression that double-counts placeholders is caught.

  17. DP1 (Note) — _PASSES registry crosses the rule-of-three threshold but stays a tuple. This story is the third known-pass composition in the codebase (Phase 0 per-probe scrub has two passes; S3-03 lands three envelope-level passes). Production ADR-0033 §3 + the design-patterns toolkit's Registry/Plugin pattern names rule-of-three for promotion. Not promoted to AC — the third pass is the closure (_build_redacted_slice_pass finalizes the RedactedSlice), not a content-redaction pass. The fourth content-redaction pass (Phase-4 RAG-scrubber or a future per-task-class redactor) would cross the threshold for promotion to a @register_sanitizer_pass decorator. Fix: Notes-for-implementer §"Design patterns" added — _PASSES stays a literal tuple in Phase 2; Phase 4+ promotes to a decorator registry when the fourth content pass arrives. Cite the open/closed prescription in the note prose (a registry is closed for modification, open for extension; today's literal tuple is closed for both — fine while N=3).

  18. DP2 (Note) — SanitizerPass Protocol fits today. The three passes share the shape Callable[[dict[str, JSONValue]], dict[str, JSONValue] | RedactedSlice]. A Protocol-typed alias makes the seam testable and the contract self-documenting. Fix: Notes-for-implementer adds class SanitizerPass(Protocol): def __call__(self, slice_: dict[str, JSONValue]) -> dict[str, JSONValue]: ... as the recommended type for _PASSES members (with the closure pass typed as a sibling Protocol since its return type differs). The Protocol surface keeps the registry promotion (DP1) trivially compatible later.
  19. DP3 (Note) — Fingerprint newtype rule-of-three threshold REACHED at S3-03 (third consumer). S3-01 (Validation note #11) deferred; S3-02 (Validation note #12) deferred; S3-03 is the third consumer (Writer.write reads envelope.fingerprints to embed in the persisted shape per 02-ADR-0010 Tradeoffs row 2). Production ADR-0033 §3 names primitive obsession on cross-module identifiers as a review-blocker. Decision: elevate to a Phase-3-entry cross-cutting story note rather than this story's AC — three of the four eventual consumers are inside Phase 2 (sanitizer.py, redacted_slice.py, Writer.write); the fourth (CLI summary at S8-02) is the natural concurrent landing site. Fix: Notes-for-implementer §"Design patterns" pins this — S3-03 does not introduce Fingerprint; S8-02 (or a sibling cross-cutting story) lands it concurrently with the CLI consumer. The opportunity is logged in _validation/S3-03.md for follow-up tracking; the deferral is principled (rule-of-three threshold is reached but the fourth consumer is one story away).
  20. DP4 (Note) — Pure-impure split holds. The composition module (envelope_redactor.py) is pure — no I/O, no logging, no filesystem reads. The seam (_seam_redact_envelope in cli.py) is impure (it's a seam — that's its job). The Writer (Writer.write) is impure (it persists). The log emission (secrets_redacted_count) is impure-shell. The functional core / imperative shell discipline is preserved. Fix: Notes-for-implementer §"Pure module" enforces that envelope_redactor.py may not import logging, structlog, os.environ, subprocess, or time — same constraint S3-02 placed on redacted_slice.py. A regression that adds I/O to the redactor is a review-blocker.
  21. DP5 (Note) — Make-illegal-states-unrepresentable + smart-constructor ladder, closed at this story. S3-01 makes the runtime defense (replace cleartext). S3-02 makes the type-level defense (RedactedSlice smart constructor). S3-03 makes the chokepoint defense (writer + seam both narrow to RedactedSlice; an off-path consumer that constructs raw envelope dict[str, Any] cannot reach disk via the chokepoint). The third structural-defense rung lands here; S7-04 (Gap-5 inspect-based boundary test) closes the source-level rung after Phase 2's probes are all in. Document the four-rung ladder in the module docstring + PR description.

Stage 3 research skipped — no NEEDS RESEARCH findings. Every gap was answerable from arch (phase-arch-design.md §"Gap 4" + §"Logging strategy") + ADRs (02-ADR-0005, 02-ADR-0008, 02-ADR-0010) + verified live source (src/codegenie/output/writer.py:142forWriter.write;src/codegenie/output/sanitizer.py:158forscrub;src/codegenie/cli.py:344for_seam_write_envelope;src/codegenie/cli.py:308for_seam_shallow_merge`) + S3-01 / S3-02 sibling validation precedent.

Coverage critic: HARDEN (eight findings — F1–F8 closed; the unit-vs-integration split, log-event introduction, cross-story integration, and idempotence over placeholders were all gaps). Test-quality critic: HARDEN (mutation table shows four plausibly-wrong implementations would have slipped past the original TDD plan — a Writer.write that accepts both dict and RedactedSlice (no isinstance guard, no mypy fixture), a _PASSES-less inline composition (mock-spy unable to fire), an envelope that threads SecretFinding data into the YAML (no F6 substring negative), and a log event without a single source-of-truth constant (string-literal drift) — all closed below). Consistency critic: SIX BLOCK findings (B1–B6), zero unresolvable ADR conflicts (the arch doc line 768 — "Merged envelope flows through OutputSanitizer.scrubSecretRedactor.redact_secrets → writer" — was the source-of-truth that resolved B2's "where does composition live" question). Design-pattern critic: five nits surfaced as Notes-for-implementer (_PASSES registry promotion deferred until N=4; SanitizerPass Protocol recommended; Fingerprint newtype rule-of-three reached but deferred to S8-02 concurrent landing; pure-module discipline pinned; four-rung structural-defense ladder documented). Seventeen ACs original → twenty-three ACs after hardening (AC-2b, AC-3b, AC-8b, AC-12b, AC-15, AC-16 added; AC-1, AC-2, AC-3, AC-4, AC-5, AC-6, AC-7, AC-8, AC-9, AC-10, AC-11, AC-12, AC-13 reworded).

Ready for phase-story-executor.

Context

S3-01 lands redact_secrets(slice_: dict[str, JSONValue], probe_name: ProbeId) -> tuple[RedactedSlice, list[SecretFinding]] in src/codegenie/output/sanitizer.py. S3-02 lands the RedactedSlice Pydantic model with frozen=True, extra="forbid", fingerprint format validators, and the model_construct ban. Both prior stories are inert until the writer accepts RedactedSlice — without the signature tightening at the chokepoint, the runtime defense (02-ADR-0005) holds but the type-level defense (02-ADR-0010) does not. This story is the closing edge:

  1. The Writer.write method's envelope parameter narrows from dict[str, Any] to RedactedSlice (the public consumer surface change).
  2. The CLI seam _seam_write_envelope narrows in lock-step (the call site that consumes Writer.write).
  3. A new envelope-level composition step lands (_seam_redact_envelope in cli.py, or equivalent module — see implementer note) between Step 8 (_seam_shallow_merge) and Step 9 (_seam_validate_envelope). The composition is the canonical three-pass sequence: known-pattern regex sweep → entropy fallback → RedactedSlice closure. The pass list lives in a module-level _PASSES tuple so the mock-spy test can monkeypatch and the executor's Validator pass can verify the order via call records.
  4. src/codegenie/logging.py gains two module-level constants — SECRETS_REDACTED_COUNT_FIELD: Final[str] = "secrets_redacted_count" and EVENT_ENVELOPE_WRITTEN: Final[str] = "envelope.written" — and Writer.write (on success only) emits exactly one structured-log event whose name is the second constant and whose secrets_redacted_count field carries envelope.findings_count. A zero-count run emits secrets_redacted_count=0 explicitly — grep-able for the auditor who needs "did this run find any secrets?".

The composition order matters for a non-obvious reason. The Phase 0 per-probe OutputSanitizer.scrub runs first (secret-field-name rejection + absolute-path scrubbing); the coordinator then merges per-probe schema_slice dicts into a single envelope; the envelope-level _redact_envelope runs as the safety net for any cleartext that survived per-probe scrubbing (e.g., a high-entropy string buried inside a list-of-dict that the per-probe pass missed because no field-name matched the secret-name regex). The per-probe pass attributes findings to a specific probe_name; the envelope-level pass uses the sentinel ProbeId("__envelope__") because the merged envelope has no single probe. The CLI summary distinguishes the two surfaces — per-probe findings show the probe name; envelope-level findings show "envelope" (interpreted: "the redactor caught it at the merge layer; consider tightening the per-probe pattern set"). The two layers compose; the envelope-level pass is the load-bearing chokepoint that 02-ADR-0010's type-level guarantee binds.

The mock-spy test (test_envelope_redactor_composition.py) constructs an _redact_envelope(envelope) invocation where each of the three composed passes (held in _PASSES) is wrapped with a Mock(wraps=original) spy that appends its name to a shared record: list[str] before delegating. The test asserts the recorded sequence is ["known_patterns", "entropy", "build"]. A reorder regression (e.g., a contributor moving _build_redacted_slice_pass to the front for "consistency") flips the recorded order and fails the build.

The writer signature tightening is a contract-surface narrowing. The previous Phase-0 signature accepted dict[str, Any]. The new signature accepts only RedactedSlice. This is a one-way narrowing: _seam_write_envelope is the only call site, and it narrows in lock-step; mypy --strict catches any other call site that passes a raw dict. The mypy --strict test (test_writer_signature.py) is a fixture-file pair: _fixtures/raw_dict_to_writer.py (a snippet that calls Writer().write({}, [], output_dir)) must fail mypy with error: ... incompatible type ... expected "RedactedSlice"; _fixtures/redacted_slice_to_writer.py (a snippet that calls Writer().write(my_redacted_slice, [], output_dir)) must pass mypy clean. Both fixtures run in CI as python -m mypy --strict invocations and assert exit codes + error substrings.

The secrets_redacted_count log field is the audit grep-ability invariant. Phase 2 final-design Open Q 3 is closed by 02-ADR-0008 ("no event stream") — Phase 2 adds one structured-log event with one new field at one call site, not an event-bus subscription. The field's value is envelope.findings_count from the RedactedSlice produced by _seam_redact_envelope, captured at the writer chokepoint. A 0-count run emits secrets_redacted_count=0 — grep-friendly for the auditor. The CLI summary line (count + file:line list) is touched in S8-02; this story emits the structured-log field at the writer; the CLI summary path consumes it.

References — where to look

  • Architecture:
  • ../phase-arch-design.md §"Sequence — secret-redaction flow" (line ~420) — the composition order at the envelope-merge layer.
  • ../phase-arch-design.md §"Component design" #4 SecretRedactor — the writer-chokepoint discipline, the in-memory findings list policy.
  • ../phase-arch-design.md §"Harness engineering" → "Logging strategy" (line ~783) — Phase 2 adds one log field at the writer: secrets_redacted_count (int), so a 0-count run is grep-able. Phase 0 codegenie/logging.py is otherwise unchanged.
  • ../phase-arch-design.md §"Gap analysis & improvements" Gap 4 — the writer signature tightening from dict to RedactedSlice; type-level "redactor was called".
  • ../phase-arch-design.md line 768 — "Merged envelope flows through OutputSanitizer.scrubSecretRedactor.redact_secrets → writer." This pins the envelope-level composition site (after per-probe scrub + coordinator merge, before validate + write).
  • Phase 2 ADRs:
  • ../ADRs/0010-redacted-slice-smart-constructor-at-writer-boundary.md — Consequences section names "the writer signature change is a contract surface shift requiring a coordinated edit across all callers (one — the sanitizer pipeline)" — on master, the actual single caller is _seam_write_envelope.
  • ../ADRs/0005-secret-findings-no-plaintext-persistence.md — Consequences section names the composition: per-probe OutputSanitizer.scrub (Phase 0) → envelope-merge (Phase 0) → redact_secrets (Phase 2 envelope-level chokepoint) → writer.
  • ../ADRs/0008-no-event-stream-in-phase-2.md — the structured-log-field-only rationale; secrets_redacted_count is the one field on the one new event this story adds.
  • Source design:
  • ../final-design.md §"Anti-patterns avoided" #5model_construct bypass (verified by S3-02; this story does not regress it).
  • Existing code on master (verified via grep at validation time):
  • src/codegenie/output/writer.py:142 — Phase 0 class Writer with Writer.write(envelope: dict[str, Any], raw_artifacts: list[tuple[str, bytes]], output_dir: Path) -> None. NOT a module-level write_envelope function returning Path. This story tightens the envelope parameter type to RedactedSlice.
  • src/codegenie/output/sanitizer.py:158 — Phase 0 OutputSanitizer.scrub(output: ProbeOutput, repo_root: Path) -> SanitizedProbeOutput. Per-probe two-pass: secret-field-name rejection (raises SecretLikelyFieldNameError) + absolute-path scrubbing. This story does NOT edit scrub — the envelope-level redaction is a new module/seam, not a composition into per-probe scrub.
  • src/codegenie/cli.py:344 — Phase 0 _seam_write_envelope(envelope: dict[str, Any], raw_artifacts, output_dir) -> bytes. The seam that calls Writer().write(envelope, raw_artifacts, output_dir) and returns the YAML bytes (for the audit anchor). This story tightens its envelope parameter to RedactedSlice in lock-step.
  • src/codegenie/cli.py:308 — Phase 0 _seam_shallow_merge(envelope, outputs) -> dict[str, Any]. Step 8 of the 11-step pipeline. The new _seam_redact_envelope runs between this and _seam_validate_envelope (Step 9).
  • src/codegenie/cli.py:572 — the call site that consumes _seam_write_envelope (yaml_bytes = _seam_write_envelope(envelope, raw_artifacts, output_dir)). This story inserts a _seam_redact_envelope call here that produces the RedactedSlice consumed by both the validate seam (which validates the inner .slice) and the write seam.
  • src/codegenie/logging.py — Phase 0 structlog factory; this story adds two field-name constants (SECRETS_REDACTED_COUNT_FIELD, EVENT_ENVELOPE_WRITTEN) and exports both via __all__.
  • Phase 0 contract-freeze: tests/unit/test_probe_contract.py snapshots Probe ABC, OutputSanitizer.scrub signature, run_allowlisted signature. The scrub signature is unchanged in this story. The Writer.write signature is not part of Phase 0's frozen surface per the existing snapshot file (verify at implementation time and document the diff in the PR if untrue).
  • Phase 1 shape calibration:
  • docs/phases/01-context-gather-layer-a-node/stories/S1-02-safe-json-parser.md §"AC-13/14" — structured-event emission via structlog.testing.capture_logs(); the same pattern applies to AC-9–AC-11 below.

Goal

Tighten the writer's public signature, finalize and document the envelope-level redactor composition order, and emit the secrets_redacted_count log field at the writer chokepoint:

  1. src/codegenie/output/writer.py::Writer.writeenvelope parameter narrows from dict[str, Any] to RedactedSlice (the type system rejects raw dict at the writer's public surface; mypy --strict catches the violation; a runtime isinstance guard rejects with TypeError).
  2. src/codegenie/cli.py::_seam_write_envelopeenvelope parameter narrows from dict[str, Any] to RedactedSlice in lock-step (the seam is the only call site of Writer.write).
  3. A new src/codegenie/output/envelope_redactor.py module hosts _PASSES: tuple[SanitizerPass, ...] (three module-level passes) and _redact_envelope(envelope: dict[str, JSONValue]) -> RedactedSlice. The module docstring documents the composition order: known-pattern regex sweep → entropy fallback → RedactedSlice closure. A new _seam_redact_envelope(envelope) -> tuple[RedactedSlice, int] in cli.py consumes it and runs between Step 8 (_seam_shallow_merge) and Step 9 (_seam_validate_envelope).
  4. src/codegenie/logging.py declares two module-level constants — SECRETS_REDACTED_COUNT_FIELD: Final[str] = "secrets_redacted_count" AND EVENT_ENVELOPE_WRITTEN: Final[str] = "envelope.written" — exported via __all__. Writer.write (on success only, after _atomic_write_bytes returns) emits exactly one structured-log event whose name is EVENT_ENVELOPE_WRITTEN and whose SECRETS_REDACTED_COUNT_FIELD value is envelope.findings_count. A zero-count run emits the field explicitly (not omitted).
  5. A mypy --strict fixture pair test (tests/unit/output/test_writer_signature.py) asserts: (a) _fixtures/raw_dict_to_writer.py (a snippet calling Writer().write({}, [], output_dir)) fails mypy with incompatible type AND expected "RedactedSlice"; (b) _fixtures/redacted_slice_to_writer.py (a clean snippet) passes mypy clean. CI runs both as python -m mypy --strict.

Acceptance criteria

Writer signature tightening:

  • [ ] AC-1 — src/codegenie/output/writer.py::Writer.write signature is def write(self, envelope: RedactedSlice, raw_artifacts: list[tuple[str, bytes]], output_dir: Path) -> None (other parameters and the None return type preserved from Phase 0). A test asserts typing.get_type_hints(Writer.write)["envelope"] is RedactedSlice AND typing.get_type_hints(_seam_write_envelope)["envelope"] is RedactedSlice (the seam and the method narrow in lock-step). A regression that tightens only one of the two is caught.
  • [ ] AC-2 — tests/unit/output/test_writer_signature.py::test_writer_refuses_raw_dict_at_typecheck — runs subprocess.run([sys.executable, "-m", "mypy", "--strict", str(fixture)], capture_output=True, text=True, cwd=<repo_root>) against tests/unit/output/_fixtures/raw_dict_to_writer.py (which contains from codegenie.output.writer import Writer; Writer().write({}, [], Path("/tmp"))). Asserts (a) result.returncode != 0; (b) result.stdout contains BOTH literal substrings "incompatible type" AND 'expected "RedactedSlice"' (the and contract — both must appear, mirroring S1-11 AC-2's hardening shape).
  • [ ] AC-2b — Positive controltests/unit/output/test_writer_signature.py::test_writer_accepts_redacted_slice_at_typecheck — same invocation against tests/unit/output/_fixtures/redacted_slice_to_writer.py (a snippet calling Writer().write(my_redacted_slice, [], Path("/tmp")) where my_redacted_slice comes from redact_secrets). Asserts result.returncode == 0 AND result.stdout is empty (or contains only mypy's "Success" banner). This proves the fixture-mypy harness is wired correctly; without it, AC-2 could pass spuriously on a broken mypy invocation.
  • [ ] AC-3 — Runtime: Writer.write's first executable statement is if not isinstance(envelope, RedactedSlice): raise TypeError(...). The TypeError message contains the substring "RedactedSlice" (case-sensitive) AND the substring "02-ADR-0010". Test: with pytest.raises(TypeError, match=r"RedactedSlice.*02-ADR-0010"): Writer().write({}, [], tmp_path). The runtime-rejection layer complements the type-check-time rejection.
  • [ ] AC-3b — Source-level verificationinspect.getsource(Writer.write) contains the regex r"isinstance\s*\(\s*envelope\s*,\s*RedactedSlice\s*\)". A regression that drops the guard (relying only on Python's stripped-at-runtime type hints) is caught at source-level inspection. Same source-check is run against _seam_write_envelope (defense-in-depth: both surfaces guard).

Envelope-level redactor composition order:

  • [ ] AC-4 — src/codegenie/output/envelope_redactor.py module docstring documents the composition order: "Three-pass composition (envelope-level chokepoint, 02-ADR-0010): (1) _redact_known_patterns_pass — regex sweep across the merged envelope (S3-01 named patterns); (2) _redact_entropy_pass — Shannon-entropy fallback for novel credential shapes (S3-01 len ≥ 32, ≥ 4.5 bits/char); (3) _build_redacted_slice_pass — the smart-constructor closure that returns RedactedSlice (02-ADR-0010). The order is load-bearing: known patterns first (cheap regex hits exit early), entropy second (expensive Shannon-entropy walk only on survivors), closure last (immutable model construction). Reordering would not change semantic output but would lose the cheap-first invariant. Per-probe OutputSanitizer.scrub (Phase 0, 02-ADR-0005) is upstream of this module — not a co-located pass. See 02-ADR-0008 for the no-event-stream framing of secrets_redacted_count. Verified by test_envelope_redactor_composition.py." A test asserts inspect.getdoc(codegenie.output.envelope_redactor) substring-matches all four references: "02-ADR-0005", "02-ADR-0010", "02-ADR-0008", and "Three-pass composition". A regression that drops any of the four substrings fails.
  • [ ] AC-5 — tests/unit/output/test_envelope_redactor_composition.py::test_redact_envelope_invokes_passes_in_order — monkeypatches envelope_redactor._PASSES with a tuple of Mock(wraps=pass_) spies; each spy first appends its name to a shared record: list[str] (e.g., record.append("known_patterns")) and then delegates to the wrapped original. Calls _redact_envelope(fixture_envelope). Asserts record == ["known_patterns", "entropy", "build"]. Each spy's call_count == 1.
  • [ ] AC-6 — Mutation sensitivitytests/unit/output/test_envelope_redactor_composition.py adds two paired tests:
  • test_reorder_mutation_changes_recorded_order — monkeypatch _PASSES = (_redact_entropy_pass, _redact_known_patterns_pass, _build_redacted_slice_pass) (entropy before known-patterns). Call _redact_envelope. Assert record == ["entropy", "known_patterns", "build"] (NOT the canonical order). This proves the recording mechanism is order-sensitive.
  • test_canonical_order_under_no_mutation — with the live _PASSES, assert record == ["known_patterns", "entropy", "build"].
  • Both pass together; a regression that disables the recording mechanism (or hard-codes the assertion) is caught by the first.
  • [ ] AC-7 — _redact_envelope return type annotation is RedactedSlice; the seam's call site (_seam_redact_envelope) propagates the RedactedSlice to both _seam_validate_envelope (which validates RedactedSlice.slice — the inner dict) and _seam_write_envelope (which accepts RedactedSlice per AC-1). Test asserts typing.get_type_hints(envelope_redactor._redact_envelope)["return"] is RedactedSlice. No tuple at this layer (the list[SecretFinding] is owned by S3-01's redact_secrets; the envelope-level closure uses envelope.findings_count for downstream consumers).

SECRETS_REDACTED_COUNT_FIELD + EVENT_ENVELOPE_WRITTEN log surface:

  • [ ] AC-8 — src/codegenie/logging.py exports SECRETS_REDACTED_COUNT_FIELD: Final[str] = "secrets_redacted_count" AND EVENT_ENVELOPE_WRITTEN: Final[str] = "envelope.written" as module-level constants. Writer.write imports both by name; no string literals at the call site — a programmatic check via inspect.getsource(Writer.write) asserts neither of the regexes re.compile(r'["\']secrets_redacted_count["\']') nor re.compile(r'["\']envelope\.written["\']') has a hit (the constants are used by name only). A typo in either name is caught at import time.
  • [ ] AC-8b — Both constants appear in codegenie.logging.__all__. Test: "SECRETS_REDACTED_COUNT_FIELD" in codegenie.logging.__all__ AND "EVENT_ENVELOPE_WRITTEN" in codegenie.logging.__all__. A regression that forgets to export is caught at the test boundary, not at first downstream consumer.
  • [ ] AC-9 — tests/unit/output/test_writer_logs_secrets_redacted_count.py::test_count_field_emitted_on_zero_count — constructs a RedactedSlice with findings_count=0, fingerprints=[] via redact_secrets({}, ProbeId("__envelope__")). Calls Writer().write(empty_redacted, [], tmp_path / "ctx") with structlog.testing.capture_logs() active. Asserts exactly one captured event has event == "envelope.written" AND its fields contain secrets_redacted_count == 0. A 0-count run is not silent.
  • [ ] AC-10 — tests/unit/output/test_writer_logs_secrets_redacted_count.py::test_count_field_emitted_on_nonzero_count — constructs an envelope fixture containing three seeded secrets (e.g., two distinct AWS keys + one entropy hit at distinct leaves of the merged envelope), runs it through _redact_envelope, passes the resulting RedactedSlice (with findings_count == 3) to Writer.write under capture_logs(). Asserts the same event records secrets_redacted_count == 3.
  • [ ] AC-11 — Event uniqueness + success-path-only emission — the captured events list filtered by event == "envelope.written" has exactly one entry per Writer.write call. A second test (test_no_event_on_write_failure) injects a write failure (e.g., output_dir = tmp_path / "no_such_dir" / "nested" with parents=False — or a monkeypatched _atomic_write_bytes that raises OSError); asserts zero envelope.written events are captured (the event is emitted only after _atomic_write_bytes returns; a failure path is silent on envelope.written).

Sanitizer → writer → log dataflow:

  • [ ] AC-12 — End-to-end (within-unit) dataflow assertion. Construct a fixture envelope dict containing three seeded plaintext secrets (e.g., {"probes": {"p1": {"value": "AKIAIOSFODNN7EXAMPLE"}}, ...} with two distinct AWS keys + one high-entropy 40-char base64 string). Run through _seam_redact_envelope then _seam_write_envelope. Then read tmp_path / "ctx" / "repo-context.yaml" as bytes. Assert via substring search:
  • Negative (no plaintext): NONE of "AKIAIOSFODNN7EXAMPLE", the second AWS key literal, the entropy plaintext literal appear in the YAML bytes.
  • Negative (no SecretFinding fields): NONE of "pattern_class", "cleartext_len" (S3-01 SecretFinding field names) appear in the YAML bytes.
  • Positive (redactor ran): b"<REDACTED:fingerprint=" DOES appear in the YAML bytes. A regression that disables the redactor is caught by the third assertion; a regression that threads SecretFinding data into the envelope is caught by the second.
  • [ ] AC-12b — Per-probe attribution preserved — a fixture where the per-probe OutputSanitizer.scrub upstream already redacted one secret (substituted with <REDACTED:fingerprint=…> via the S6-06/S6-07 scanners that will land later — for this story, a hand-built fixture matching the post-scrub shape) AND the envelope-level pass catches one additional novel-shape secret. Assert the resulting RedactedSlice.findings_count == 1 (only the envelope-level finding; the per-probe placeholder is idempotent under the envelope-level pass — see AC-16). The story's chokepoint is the envelope-level pass; per-probe attribution is the upstream pass.

Idempotence and Phase-0/1 invariants preserved:

  • [ ] AC-13 — Phase-0 tests/unit/test_probe_contract.py contract-freeze snapshot continues to pass. Verification recipe: at story-execution time, run python -c "import tests.unit.test_probe_contract as m; print([n for n in dir(m) if 'SNAPSHOT' in n.upper()])" to enumerate the snapshot constants. If a _WRITER_WRITE_SNAPSHOT (or equivalent named after the writer) exists, document the diff in the PR description and reference 02-ADR-0010 Consequences. If no writer-frozen snapshot exists, AC-13 is a no-op assertion; document this finding in the PR. The OutputSanitizer.scrub snapshot is unchanged (this story does not touch scrub).
  • [ ] AC-14 — No model_construct calls anywhere in src/codegenie/output/** (positive assertion from S3-02 AC-14 continues to pass after this story's edits). The new envelope_redactor.py module constructs RedactedSlice only via the public Pydantic constructor inside _build_redacted_slice_pass (this is one new call site; the S3-02 lint rule does not ban the public constructor, only model_construct). S7-04's inspect-based boundary test will later assert redact_secrets (S3-01) and _build_redacted_slice_pass (S3-03) are the only two construction sites in src/; for this story the constraint is the negative model_construct assertion.
  • [ ] AC-15 — Cross-story integration with S3-01 + S3-02 (mirrors S3-02 AC-15b) — parametrized over the four canonical shapes:
  • Zero secrets — envelope contains no plaintext → findings_count == 0, len(fingerprints) == 0, log event records secrets_redacted_count=0, YAML satisfies F6 contract (no pattern_class, no plaintext, no <REDACTED:fingerprint= either since zero matches).
  • One secret — envelope contains one AWS key → findings_count == 1, len(fingerprints) == 1, log event records secrets_redacted_count=1, YAML satisfies F6 contract (positive includes <REDACTED:fingerprint=).
  • Three distinct secrets — envelope contains two distinct AWS keys + one GitHub token in three different leaves → findings_count == 3, len(fingerprints) == 3, log event records secrets_redacted_count=3.
  • Same-fingerprint-twice — same AWS key in two distinct leaves → findings_count == 2, len(fingerprints) == 1, log event records secrets_redacted_count=2 (the 02-ADR-0010 contract: count is total findings, fingerprints are deduplicated). For each case, additionally assert the post-write RedactedSlice round-trips through RedactedSlice.model_validate(model.model_dump()) (the writer must not mutate the slice). A regression where the writer accidentally mutates the slice (e.g., re-orders dict keys destructively) is caught.
  • [ ] AC-16 — Placeholder-idempotence — a fixture envelope whose slice already contains a <REDACTED:fingerprint=abcdef12> placeholder string (modeling the post-per-probe-scrub shape). Run _redact_envelope. Assert (a) the placeholder is unchanged in the output (entropy ≈ log2(16) = 4.0 per hex char ≈ below the 4.5 threshold for an 8-char fingerprint; a 30-char <REDACTED:fingerprint=abcdef12> string has further-reduced effective entropy from the literal prefix); (b) findings_count does NOT increment for the placeholder. A regression that double-counts placeholders (entropy mis-tuned, or known-pattern regex accidentally matching <REDACTED:) is caught. If the implementer's chosen pattern set or entropy threshold would naively match the placeholder, document the carve-out (e.g., the known-pattern table includes r"<REDACTED:fingerprint=[0-9a-f]{8}>" as an explicit non-match exclusion) in the module docstring.
  • [ ] AC-17 — Phase 0 safe_yaml.load / safe_json.load chokepoints are unaffected (this story does not touch the parsers layer).
  • [ ] AC-18 — The Phase-2 forbidden-patterns glob (S1-11) covering src/codegenie/output/** continues to cover writer.py, sanitizer.py, redacted_slice.py (S3-02), and the new envelope_redactor.py. A regression that scopes the glob narrower is caught by S3-02 AC-13's runtime predicate assertion (_is_under_phase2_banned_package(Path("src/codegenie/output/envelope_redactor.py")) is True).

Toolchain:

  • [ ] AC-19 — ruff check, ruff format --check, mypy --strict, pytest pass on touched files. mypy --strict flags the raw-dict-to-writer snippet (AC-2) and clean-passes the RedactedSlice-to-writer snippet (AC-2b).

Implementation outline

  1. Create src/codegenie/output/envelope_redactor.py:
    """Envelope-level secret-redaction chokepoint (02-ADR-0005, 02-ADR-0010, 02-ADR-0008).
    
    Three-pass composition (envelope-level, post-merge):
        1. _redact_known_patterns_pass — regex sweep (S3-01 named patterns).
        2. _redact_entropy_pass — Shannon-entropy fallback (S3-01).
        3. _build_redacted_slice_pass — smart-constructor closure → RedactedSlice.
    
    Per-probe OutputSanitizer.scrub (Phase 0) is upstream of this module —
    not a co-located pass. See 02-ADR-0008 for the no-event-stream framing
    of `secrets_redacted_count`.
    
    Verified by tests/unit/output/test_envelope_redactor_composition.py.
    """
    from __future__ import annotations
    from typing import Protocol
    from codegenie.output.sanitizer import redact_secrets  # S3-01
    from codegenie.output.redacted_slice import RedactedSlice  # S3-02
    from codegenie.parsers import JSONValue
    from codegenie.types import ProbeId
    
    _ENVELOPE_PROBE_ID = ProbeId("__envelope__")
    
    class SanitizerPass(Protocol):
        def __call__(self, slice_: dict[str, JSONValue]) -> dict[str, JSONValue]: ...
    
    def _redact_known_patterns_pass(slice_: dict[str, JSONValue]) -> dict[str, JSONValue]: ...
    def _redact_entropy_pass(slice_: dict[str, JSONValue]) -> dict[str, JSONValue]: ...
    def _build_redacted_slice_pass(slice_: dict[str, JSONValue]) -> RedactedSlice: ...
    
    _PASSES: tuple[object, ...] = (
        _redact_known_patterns_pass,
        _redact_entropy_pass,
        _build_redacted_slice_pass,
    )
    
    def _redact_envelope(envelope: dict[str, JSONValue]) -> RedactedSlice:
        redacted, _findings = redact_secrets(envelope, _ENVELOPE_PROBE_ID)
        return redacted
    
    Note: the three pass functions may delegate to a single redact_secrets call internally for Phase 2 simplicity — the named, mockable _PASSES tuple is what AC-5/AC-6 require. The pass-level decomposition can be a thin shim around redact_secrets (e.g., _redact_known_patterns_pass calls into the known-pattern path inside sanitizer.py; _redact_entropy_pass calls the entropy path; _build_redacted_slice_pass is the closure). The shim's contract is the order; the implementation chooses how literally the three passes decompose redact_secrets's body.
  2. Edit src/codegenie/cli.py:
  3. Add a new seam _seam_redact_envelope(envelope: dict[str, Any]) -> RedactedSlice between _seam_shallow_merge (Step 8) and _seam_validate_envelope (Step 9). The seam calls envelope_redactor._redact_envelope(envelope) and returns the RedactedSlice.
  4. Tighten _seam_write_envelope's envelope parameter from dict[str, Any] to RedactedSlice. Body: writer.write(envelope, raw_artifacts, output_dir) (the writer accepts RedactedSlice per AC-1; the YAML serialization reads envelope.slice inside Writer.write's body).
  5. The call-site (around line 572): redacted_envelope = _seam_redact_envelope(envelope); _seam_validate_envelope(redacted_envelope); yaml_bytes = _seam_write_envelope(redacted_envelope, raw_artifacts, output_dir). The _seam_validate_envelope signature also tightens (it now validates redacted_envelope.slice under the hood — implementer choice on whether the seam takes RedactedSlice or dict[str, Any]; the principled answer is RedactedSlice for type-uniformity at the seam layer).
  6. Edit src/codegenie/output/writer.py:
  7. Change Writer.write signature: def write(self, envelope: RedactedSlice, raw_artifacts: list[tuple[str, bytes]], output_dir: Path) -> None.
  8. First executable statement: if not isinstance(envelope, RedactedSlice): raise TypeError(f"Writer.write requires RedactedSlice (02-ADR-0010); got {type(envelope).__name__}").
  9. Inside the body, read envelope.slice (the redacted dict payload) for YAML serialization. The existing yaml.dump(envelope, ...) call becomes yaml.dump(envelope.slice, ...).
  10. On the success path (after _atomic_write_bytes(output_dir / "repo-context.yaml", body) returns successfully), emit _log.info(EVENT_ENVELOPE_WRITTEN, **{SECRETS_REDACTED_COUNT_FIELD: envelope.findings_count}) (with whatever existing context fields the writer already includes — path=str(...), etc.). Imports: from codegenie.logging import EVENT_ENVELOPE_WRITTEN, SECRETS_REDACTED_COUNT_FIELD.
  11. Update the writer's docstring to name 02-ADR-0010 (the signature tightening) and 02-ADR-0005 (the persistence-zero-plaintext discipline this signature enforces).
  12. Edit src/codegenie/logging.py:
  13. Add SECRETS_REDACTED_COUNT_FIELD: Final[str] = "secrets_redacted_count".
  14. Add EVENT_ENVELOPE_WRITTEN: Final[str] = "envelope.written".
  15. Append both names to __all__.
  16. Update the module docstring to reference 02-ADR-0008 (the single-log-field discipline) and 02-ADR-0010 (the writer-completion event).
  17. Write tests/unit/output/test_writer_signature.py (AC-1, AC-2, AC-2b, AC-3, AC-3b):
  18. Fixtures: tests/unit/output/_fixtures/raw_dict_to_writer.py and _fixtures/redacted_slice_to_writer.py.
  19. Subprocess invocations against both fixtures with python -m mypy --strict; assert exit codes + error substring contracts.
  20. Runtime test: pass a raw dict to Writer.write; assert TypeError with the message regex.
  21. Source-level test: inspect.getsource(Writer.write) regex contains the isinstance guard.
  22. Write tests/unit/output/test_envelope_redactor_composition.py (AC-4, AC-5, AC-6, AC-7):
  23. test_composition_order_documented_in_docstring: inspect.getdoc substring matches.
  24. test_redact_envelope_invokes_passes_in_order: monkeypatch _PASSES with Mock(wraps=...) spies that append to a record: list[str]; assert canonical order.
  25. test_reorder_mutation_changes_recorded_order + test_canonical_order_under_no_mutation: paired mutation-sensitivity test.
  26. test_return_type_is_redacted_slice: typing.get_type_hints(_redact_envelope)["return"] is RedactedSlice.
  27. Write tests/unit/output/test_writer_logs_secrets_redacted_count.py (AC-8, AC-8b, AC-9, AC-10, AC-11, AC-12, AC-12b):
  28. test_constants_module_level_and_exported: SECRETS_REDACTED_COUNT_FIELD + EVENT_ENVELOPE_WRITTEN are Final[str] constants in codegenie.logging.__all__.
  29. test_no_string_literals_at_call_site: source-level regex check that Writer.write does not contain the string literals.
  30. test_count_field_emitted_on_zero_count + test_count_field_emitted_on_nonzero_count: capture_logs() assertions over Writer.write with constructed RedactedSlice fixtures.
  31. test_event_unique_per_write_call: filter captured events by event == "envelope.written"; assert exactly one per call.
  32. test_no_event_on_write_failure: monkeypatch _atomic_write_bytes to raise OSError; assert zero envelope.written events captured.
  33. test_dataflow_no_plaintext_no_secret_finding_fields (AC-12): end-to-end through _seam_redact_envelope + _seam_write_envelope; substring assertions over the persisted YAML bytes.
  34. test_per_probe_attribution_preserved (AC-12b): pre-scrubbed fixture with a placeholder + one novel-shape secret; assert envelope-level findings count of 1.
  35. Write tests/unit/output/test_envelope_redactor_integration.py (AC-15, AC-16):
  36. Parametrized over the four canonical shapes (zero / one / three-distinct / same-fingerprint-twice).
  37. For each: end-to-end dataflow + log assertion + YAML substring assertion + post-write RedactedSlice round-trip.
  38. Placeholder-idempotence: assert envelope-level pass leaves an existing <REDACTED:fingerprint=…> placeholder unchanged and does not increment the count.
  39. Do NOT edit redact_secrets itself (S3-01 owns it). Do NOT edit RedactedSlice (S3-02 owns it). Do NOT edit OutputSanitizer.scrub (Phase 0; this story does not touch per-probe scrub).

Out of scope

  • The SecretRedactor / redact_secrets body (S3-01).
  • The RedactedSlice Pydantic model (S3-02).
  • The CLI summary line (secrets_redacted_count: <N> + file:line list) at gather end — this story emits the log event; the CLI summary path consumes it; the summary line itself is touched in S8-02.
  • tests/adv/phase02/test_secret_in_source.py (S6-07 — load-bearing adversarial; depends on this story landing the chokepoint).
  • tests/adv/phase02/test_no_inmemory_secret_leak.py (S7-04 — inspect-based boundary test; asserts no dict reaches the writer call site in src/ AND _build_redacted_slice_pass + redact_secrets are the only RedactedSlice construction sites).
  • The Fingerprint newtype (production ADR-0033 §3 — rule-of-three reached at this story but extraction deferred to S8-02 concurrent landing; see Notes-for-implementer §"Design patterns" DP3).
  • Phase 4 RAG ingestion path inheriting the RedactedSlice type guarantee (02-ADR-0010 Consequences) — Phase 4 design concern.
  • Any OutputSanitizer.scrub edits or per-probe-pass additions — out of scope. The per-probe pass is upstream of this module.

Notes for the implementer

  • Where does the _seam_redact_envelope step live? The natural choice is a new function in cli.py adjacent to _seam_shallow_merge and _seam_validate_envelope. It calls envelope_redactor._redact_envelope(envelope). The seam-test pattern from S1-02 (structlog.testing.capture_logs() over a built-up envelope) is the test shape.
  • Mock-spy ordering test technique. unittest.mock.Mock(wraps=original) preserves behavior while recording calls. The recommended mechanism: a shared record: list[str] that each spy appends its name to before delegating — simpler and equally robust as time.monotonic_ns(). Pinned in AC-5.
  • The mypy --strict subprocess test. Subprocess invocation of python -m mypy --strict <path> is the canonical mechanism (matches CI). Fixture files (one bad, one good) under tests/unit/output/_fixtures/. AC-2 + AC-2b cover both directions; without the positive control (AC-2b), the negative could pass spuriously on a broken harness.
  • Runtime rejection of raw dict. Python's type hints are stripped at runtime. The simplest defense is the isinstance(envelope, RedactedSlice) guard as the first executable statement of Writer.write. The TypeError message contains both "RedactedSlice" and "02-ADR-0010" so a future reader of the traceback can find the source-of-truth ADR without re-deriving it. AC-3b's source-level regex check (re.compile(r"isinstance\s*\(\s*envelope\s*,\s*RedactedSlice\s*\)")) is the regression net.
  • SECRETS_REDACTED_COUNT_FIELD + EVENT_ENVELOPE_WRITTEN placement. Both belong in codegenie/logging.py (the canonical home for cross-module log-field/event constants). Single source of truth; one typo-resistant constant per surface. Per-probe and CLI consumers later import the same constants.
  • The "0-count is grep-able" property. Auditors run grep secrets_redacted_count: <log_path> to confirm a clean run. If secrets_redacted_count=0 is silently omitted (the field only appears when nonzero), the auditor cannot distinguish "clean run" from "log corruption" or "missing emission". Always emit the field; AC-9 pins this.
  • The writer-completion event name is introduced by this story. Phase 0's Writer.write is silent (verified at validation time — only _log.warning(...) for csafe + symlink-refused). This story adds _log.info(EVENT_ENVELOPE_WRITTEN, ...) on the success path after _atomic_write_bytes returns. The event is single-event, single-field — 02-ADR-0008's "no event stream" is honored (one new structured-log event, not an event-bus subscription). AC-11's failure-path test pins that an OSError during write does NOT emit the event.
  • Coordinated edit across callers. 02-ADR-0010 Consequences names "the writer signature change is a contract surface shift requiring a coordinated edit across all callers (one — the sanitizer pipeline)". On master, the actual single caller is _seam_write_envelope; verify by grep -rn "Writer().*write\|Writer.write\|writer\.write" src/ before and after the edit. If a second call site appears (e.g., a test fixture or a debug-write path), it is either (a) a test that must be updated to construct via redact_secrets or (b) a bypass that defeats the chokepoint — flag in the PR description.
  • Forbidden-patterns reach. S1-11 covers src/codegenie/output/**. The new envelope_redactor.py is inside that glob; this story does not introduce new banned patterns. S3-02 AC-14 (regex r"\.model_construct\s*\(|\bmodel_construct\s*=" over src/codegenie/output/) continues to pass.
  • LOC budget. envelope_redactor.py ≈ 60 LOC (three thin passes + module-level tuple + protocol + redactor entry). cli.py edits ≈ 15 LOC (new seam function + call-site rewire + signature tightening of _seam_write_envelope). writer.py edits ≈ 15 LOC (signature tightening + isinstance guard + log emission). logging.py edits ≈ 5 LOC (two constants + __all__). Tests ≈ 400 LOC across four new files. Total ~495 LOC.
  • Unit-vs-integration split. The story is unit-level. End-to-end gather (test_secret_in_source.py) lands in S6-07; the inspect-based source-level boundary test (test_no_inmemory_secret_leak.py) lands in S7-04. This story's tests construct RedactedSlice directly (via redact_secrets({}, ProbeId("__envelope__"))) and call Writer.write / the seam directly with tmp_path output dirs.
  • The structural ladder, completed at four rungs. This story closes the third rung of 02-ADR-0005's structural defense; the fourth rung is the S7-04 source-level boundary test. (1) Runtime — redact_secrets replaces cleartext (S3-01); (2) Type-system — RedactedSlice is a smart-constructor (S3-02); (3) Chokepoint — Writer.write + _seam_write_envelope accept only RedactedSlice (this story); (4) Source-level — inspect-based boundary test that no other path reaches the writer (S7-04). When this PR lands, "redactor was called" is type-checkable AND chokepoint-enforced; only the source-level rung remains.

Design patterns

  • DP1 — _PASSES registry: rule-of-three reached but stays a literal tuple. This story is the third known-pass composition (Phase 0 per-probe scrub has two passes; S3-03's envelope-level has three). Rule-of-three names this the moment to consider promoting to a @register_sanitizer_pass decorator registry. But: Phase 2's three envelope-level passes are fixed by 02-ADR-0010 (_build_redacted_slice_pass is the closure, not a content pass — adding a fourth would be a content pass like a future Phase-4 RAG-scrubber or a per-task-class redactor). A literal tuple is closed for modification and closed for extension — fine while N=3. When the fourth content pass arrives in Phase 4+, promote _PASSES to a decorator registry. Until then, the literal tuple is correct per Rule 2 ("three similar lines is better than a premature abstraction"). The structure is already extension-friendly (the SanitizerPass Protocol makes promotion mechanical).
  • DP2 — SanitizerPass Protocol. Type each pass via a Protocol-typed alias: class SanitizerPass(Protocol): def __call__(self, slice_: dict[str, JSONValue]) -> dict[str, JSONValue]: .... The closure pass has a different return type (RedactedSlice); type it as a sibling Protocol (class SliceClosurePass(Protocol): def __call__(self, slice_: dict[str, JSONValue]) -> RedactedSlice: ...). The Protocol surface makes the registry promotion (DP1) trivially compatible: a @register_sanitizer_pass decorator that takes a SanitizerPass-typed callable lands without rewriting any current pass.
  • DP3 — Fingerprint newtype: rule-of-three reached, deferred to S8-02 concurrent landing. This story is the third consumer of the 8-hex fingerprint string (S3-01 produces; S3-02 validates via RedactedSlice.fingerprints; S3-03 reads envelope.fingerprints in Writer.write to embed in the persisted shape). Production ADR-0033 §3 names primitive obsession on cross-module identifiers as a review-blocker. Decision: S3-03 does NOT introduce the newtype — three of the four eventual consumers are inside Phase 2; the fourth (CLI summary at S8-02) is the natural concurrent landing site. Surface the opportunity in the S8-02 story prose so the cross-cutting refactor lands with all four consumers in one PR. The deferral is principled (rule-of-three threshold reached, but the four-consumer cliff is one story away — landing the newtype now plus one rewrite in S8-02 vs. landing it concurrently with S8-02 plus zero in-flight rewrites — Rule 3 "surgical changes" favors the latter).
  • DP4 — Pure module discipline for envelope_redactor.py. No I/O, no logging, no filesystem reads, no os.environ, no subprocess, no time. The three passes are pure functions over their arguments; _redact_envelope is a pure delegator. The seam (_seam_redact_envelope in cli.py) is impure (it's a seam — that's its job, per the functional-core / imperative-shell pattern). The Writer (Writer.write) is impure (it persists + logs). The log emission is impure-shell. Future contributors must not add I/O to envelope_redactor.py — if a need arises ("log every fingerprint generation"), the logging belongs at the seam or in S3-01's redact_secrets, not in this module. A regression that imports logging, structlog, Path, os, or subprocess at the top of envelope_redactor.py is a review-blocker per this Note. Mirrors S3-02's DP3 for redacted_slice.py.
  • DP5 — Smart-constructor + chokepoint ladder closed at three rungs. S3-01 → runtime ("replace cleartext"); S3-02 → type-system ("RedactedSlice is a smart-constructor; model_construct banned"); S3-03 → chokepoint ("Writer.write and _seam_write_envelope accept only RedactedSlice; isinstance guard rejects raw dict at runtime"). The fourth rung (source-level inspect-based boundary test that no other path reaches the writer) lands in S7-04 after Phase 2's probes are all in. Document the four-rung ladder in the envelope_redactor.py module docstring AND the PR description. This is the canonical implementation of the toolkit's "Smart constructor + Make illegal states unrepresentable" pattern applied at the I/O boundary.