Skip to content

Attempt log — S3-03 (writer-signature tightening + envelope-redactor composition + secrets_redacted_count)

Append-only journal. Each attempt entry: ReAct trace summary → verification → diagnosis → lesson.

Attempt 1 — 2026-05-16 — GREEN on first cycle

Context loaded

  • Story: 23 ACs after HARDENED validation (6 BLOCK, 10 HARDEN, 5 design-note nudges).
  • Architecture: phase-arch-design.md §"Gap 4" + §"Logging strategy"; arch line 768 pinned the envelope-merge composition site (after OutputSanitizer.scrub + coordinator merge, before validate + write).
  • ADRs: 02-ADR-0005 (no plaintext persistence), 02-ADR-0008 (no event stream), 02-ADR-0010 (smart-constructor at writer boundary).
  • Phase 0 / 1 surfaces verified:
  • Writer.write(envelope: dict[str, Any], raw_artifacts, output_dir) -> None at src/codegenie/output/writer.py:142.
  • _seam_write_envelope(envelope: dict[str, Any], raw_artifacts, output_dir) -> bytes at src/codegenie/cli.py:344.
  • _seam_validate_envelope(envelope: dict[str, Any]) -> None at src/codegenie/cli.py:338.
  • _seam_shallow_merge at src/codegenie/cli.py:308 (Step 8 of the pipeline).
  • redact_secrets + RedactedSlice already shipped via S3-01 / S3-02.

What landed

Source edits (4 files):

  1. src/codegenie/output/envelope_redactor.py — new module. Defines:
  2. _ENVELOPE_PROBE_ID: ProbeId = ProbeId("__envelope__") sentinel.
  3. _PLACEHOLDER_RE = re.compile(r"<REDACTED:fingerprint=[0-9a-f]{8}>") carve-out for placeholder idempotence (AC-16).
  4. SanitizerPass + SliceClosurePass Protocols — DP2 from the validation note.
  5. _PassState dataclass + module-level ContextVar[_PassState | None] — pure per-call state threading. DP4 (functional core): no logging, no Path, no subprocess, no os.environ.
  6. Three named passes: _redact_known_patterns_pass (S3-01 named regex sweep), _redact_entropy_pass (Shannon-entropy fallback with placeholder skip), _build_redacted_slice_pass (smart-constructor closure).
  7. _PASSES: tuple[Any, ...] literal tuple (DP1 — Open/Closed at N=3; promotion to decorator registry deferred to a fourth content pass in Phase 4+).
  8. _redact_envelope(envelope) -> RedactedSlice entry point that binds _PassState via ContextVar.set / reset so passes share findings without globals.
  9. Module docstring documents 02-ADR-0005, 02-ADR-0008, 02-ADR-0010, and the "Three-pass composition" framing (AC-4 substring contract).

  10. src/codegenie/logging.py — added EVENT_ENVELOPE_WRITTEN: Final[str] = "envelope.written" + SECRETS_REDACTED_COUNT_FIELD: Final[str] = "secrets_redacted_count"; both exported via __all__.

  11. src/codegenie/output/writer.pyWriter.write:

  12. Signature narrowed from envelope: dict[str, Any] to envelope: RedactedSlice.
  13. First executable statement is the runtime isinstance(envelope, RedactedSlice) guard raising TypeError with both "RedactedSlice" and "02-ADR-0010" substrings (AC-3).
  14. YAML body now serializes envelope.slice (the redacted payload), not the raw envelope.
  15. Success-path log emission added after _atomic_write_bytes returns: _log.info(EVENT_ENVELOPE_WRITTEN, **{SECRETS_REDACTED_COUNT_FIELD: envelope.findings_count}) — no string literals at the call site (AC-8).

  16. src/codegenie/cli.py:

  17. New seam _seam_redact_envelope(envelope: dict[str, Any]) -> RedactedSlice between Step 8 and Step 9.
  18. _seam_validate_envelope tightened: now takes RedactedSlice and calls validator.validate(envelope.slice).
  19. _seam_write_envelope tightened: parameter is RedactedSlice; defense-in-depth isinstance guard with lazy importlib.import_module for the runtime class (cold-start cost discipline preserved).
  20. _run_gather_pipeline body wires redacted_envelope = _seam_redact_envelope(envelope) and threads it through validate + write.
  21. RedactedSlice imported under if TYPE_CHECKING: so the runtime cold-start contract stays kept.

Tests (4 new files, 36 new tests):

  • tests/unit/output/test_writer_signature.py (10 tests) — AC-1, AC-2 + AC-2b mypy subprocess pair, AC-3 runtime TypeError, AC-3b source-level isinstance regex on both surfaces.
  • tests/unit/output/test_envelope_redactor_composition.py (6 tests) — AC-4 docstring substrings, AC-5 mock-spy canonical order, AC-6 paired mutation sensitivity, AC-7 return-type annotation, plus a defense-in-depth check that a pass invoked outside _redact_envelope raises.
  • tests/unit/output/test_writer_logs_secrets_redacted_count.py (11 tests) — AC-8 source-level no-literals, AC-8b __all__ export, AC-9 zero-count emission, AC-10 non-zero count, AC-11 uniqueness + failure-path silence, AC-12 YAML substring contract, AC-12b per-probe + envelope two-layer attribution.
  • tests/unit/output/test_envelope_redactor_integration.py (6 tests) — AC-15 parametrized over the four canonical shapes (zero / one / three-distinct / same-fingerprint-twice) with round-trip identity check, AC-16 placeholder idempotence (placeholder-only, placeholder + novel).
  • tests/unit/output/_fixtures/raw_dict_to_writer.py + redacted_slice_to_writer.py — the negative + positive control fixtures the mypy subprocess pair invokes.

Coordinated test edits:

  • tests/unit/test_output_writer.pyENV rebuilt as a RedactedSlice via redact_secrets({"schema_version": "0.1.0", "probes": {}}, ProbeId("__envelope__")); the YAML round-trip assertion now compares against ENV.slice. Phase 0's 18 existing writer tests continue to pin atomic-write + symlink + chmod invariants unchanged.
  • tests/unit/test_cli_orchestration.py — orchestration test expects redact_envelope between shallow_merge and validate; stubs _seam_redact_envelope with a real (empty-input) RedactedSlice so downstream stubs still see a typed wrapper.
  • tests/unit/test_logging.py — added EXPECTED_PHASE2_WRITER_NAMES dict + extended the __all__-closure assertion (the L12 cascade pattern — every Phase-2 logging constant addition trips both the closure + the per-name value check). Added a sibling test_phase2_writer_constants_are_plain_strs_with_documented_values test mirroring the probe / gitignore discipline.

Tooling edits:

  • pyproject.toml — added ignore_imports = ["codegenie.cli -> codegenie.output.redacted_slice"] to the codegenie.cli must not top-level import heavy modules import-linter contract. Rationale: the TYPE_CHECKING-only import preserves the runtime cold-start contract (Python evaluates TYPE_CHECKING as False), but grimp's static graph still sees the edge. The whitelist captures the spirit-vs-letter mismatch in the config rather than relying on an undocumented analyzer behavior.

Verification

Stage 3 Ralph-Wiggum sweep — every AC has a runtime witness:

AC Evidence
AC-1 test_ac1_writer_write_envelope_is_redacted_slice, test_ac1_seam_write_envelope_is_redacted_slice, test_ac1_seam_redact_envelope_returns_redacted_slice
AC-2 test_ac2_writer_refuses_raw_dict_at_typecheck (subprocess python -m mypy --strict on the negative fixture; both substrings asserted)
AC-2b test_ac2b_writer_accepts_redacted_slice_at_typecheck (positive control proves the harness is wired)
AC-3 test_ac3_writer_raises_typeerror_for_raw_dict, test_ac3_seam_raises_typeerror_for_raw_dict (both surfaces, regex matches RedactedSlice.*02-ADR-0010)
AC-3b test_ac3b_writer_source_has_isinstance_guard, test_ac3b_seam_source_has_isinstance_guard
AC-4 test_ac4_docstring_documents_composition_and_adrs (all 4 substrings)
AC-5 test_ac5_redact_envelope_invokes_passes_in_canonical_order (mock-spy shared record list)
AC-6 test_ac6_reorder_mutation_changes_recorded_order + test_ac6_canonical_order_under_no_mutation
AC-7 test_ac7_redact_envelope_return_type_is_redacted_slice
AC-8 test_ac8_no_string_literals_at_writer_call_site (regex search over inspect.getsource)
AC-8b test_ac8b_constants_exported_via_all
AC-9 test_ac9_count_field_emitted_on_zero_count
AC-10 test_ac10_count_field_emitted_on_nonzero_count
AC-11 test_ac11_event_unique_per_write_call, test_ac11_no_event_on_write_failure
AC-12 test_ac12_persisted_yaml_has_no_plaintext_no_finding_fields (3 negatives + 1 positive substring)
AC-12b test_ac12b_per_probe_placeholder_is_idempotent_under_envelope_pass
AC-13 tests/unit/test_probe_contract.py snapshot does NOT include Writer.write (verified via grep -n "Writer" tests/unit/test_probe_contract.py → zero hits). AC-13 is a no-op assertion for this story; documented per the validation note.
AC-14 S3-02's test_no_model_construct_under_phase2_output_glob continues to pass (no model_construct calls anywhere in src/codegenie/output/**); the new envelope_redactor.py constructs RedactedSlice only via the public Pydantic constructor inside _build_redacted_slice_pass.
AC-15 test_ac15_envelope_redactor_writer_dataflow parametrized over _CASES (zero / one / three-distinct / same-fingerprint-twice); post-write round-trip via model_validate(model_dump()) proves the writer doesn't mutate the slice.
AC-16 test_ac16_placeholder_is_idempotent_no_double_count + test_ac16_placeholder_plus_novel_counts_only_novel
AC-17 No edits under src/codegenie/parsers/.
AC-18 S3-02 AC-13's _is_under_phase2_banned_package(Path("src/codegenie/output/envelope_redactor.py")) predicate test continues to assert True — the new module lands inside the existing src/codegenie/output/** glob (verified at test time by the ** substring match).
AC-19 ruff check, ruff format --check, mypy --strict src/, full pytest tests/ all green.

Toolchain runs (clean): - ruff check src/ tests/All checks passed! - ruff format --check src/ tests/255 files already formatted - mypy --strict src/Success: no issues found in 80 source files - pytest tests/2094 passed, 5 skipped, 3 deselected, 2 xfailed - pre-commit run --all-files — all hooks Pass - lint-imports --config pyproject.toml --no-cache — both contracts kept

Diagnosis — three small ruts the next attempt should skip

  1. get_type_hints on a function whose annotations use a TYPE_CHECKING-only import raises NameError. Tests must pass localns={"RedactedSlice": RedactedSlice} so the forward reference resolves at test time. The cli.py seams use TYPE_CHECKING to preserve the import-linter cold-start contract; tests import the class directly and feed it via localns. Pattern carries forward to any future Phase-2/3 story that annotates with a TYPE_CHECKING-only class.

  2. import-linter does not honor if TYPE_CHECKING: blocks — grimp sees the static edge regardless of runtime evaluation. The fix is ignore_imports = ["A -> B"] on the contract, with a comment naming the TYPE_CHECKING-only justification. Without the whitelist the lint-imports canary fails.

  3. ruff F821 fires on bare annotation identifiers even with from __future__ import annotations. The PEP 563 deferred-evaluation does NOT silence ruff's undefined-name check on annotations. So when an annotation references a TYPE_CHECKING-imported class, the class must actually be visible to ruff (via the TYPE_CHECKING import). Wrapping the annotation in string literals ("RedactedSlice") is the other escape hatch but isn't necessary once the import-linter whitelist is in place.

Refactor decisions (design-pattern lens)

Walked the design-patterns.md refactor checklist:

  • _PASSES registry (DP1 from the validation note) — stays a literal tuple. Promotion to a @register_sanitizer_pass decorator is deferred until a fourth content pass arrives (Phase 4 RAG-scrubber or per-task-class redactor). Documented in the module docstring + the validation log.
  • SanitizerPass Protocol (DP2) — landed. Plus a sibling SliceClosurePass Protocol for _build_redacted_slice_pass (different return type). Both keep promotion to a decorator registry mechanical.
  • Fingerprint newtype (DP3) — deferred to S8-02 concurrent landing as the validation note prescribed. Rule-of-three threshold is reached at this story (S3-01 produces; S3-02 validates; S3-03 reads envelope.fingerprints in Writer.write) but the fourth consumer (CLI summary at S8-02) is one story away; bundling all four edits avoids in-flight rewrites.
  • Pure-impure split (DP4)envelope_redactor.py imports no logging / structlog / Path / os.environ / subprocess / time. State is threaded via ContextVar (a stdlib pure-typing mechanism, not I/O). The seam (_seam_redact_envelope) is the imperative shell; the redactor is the functional core.
  • Smart-constructor + chokepoint ladder (DP5) — three rungs closed (runtime via S3-01, type-system via S3-02, chokepoint via this story). The fourth (inspect-based source-level boundary test) is owned by S7-04.

Files touched

  • src/codegenie/output/envelope_redactor.py — new (218 LOC).
  • src/codegenie/output/writer.py — signature tightening + isinstance guard + log emission + docstring (~30 LOC delta).
  • src/codegenie/cli.py — new seam + tightening of validate + write seams + pipeline rewire + TYPE_CHECKING import (~50 LOC delta).
  • src/codegenie/logging.py — two constants + __all__ extension (~12 LOC delta).
  • tests/unit/output/test_writer_signature.py — new (132 LOC).
  • tests/unit/output/test_envelope_redactor_composition.py — new (135 LOC).
  • tests/unit/output/test_writer_logs_secrets_redacted_count.py — new (160 LOC).
  • tests/unit/output/test_envelope_redactor_integration.py — new (112 LOC).
  • tests/unit/output/_fixtures/raw_dict_to_writer.py — new (19 LOC).
  • tests/unit/output/_fixtures/redacted_slice_to_writer.py — new (19 LOC).
  • tests/unit/test_output_writer.pyENV rebuilt as RedactedSlice; one assertion narrowed to .slice.
  • tests/unit/test_cli_orchestration.py — orchestration sequence + new _seam_redact_envelope stub.
  • tests/unit/test_logging.pyEXPECTED_PHASE2_WRITER_NAMES dict + extended closure + sibling per-name test.
  • pyproject.tomlignore_imports whitelist on the cli/cold-start contract.
  • docs/phases/02-context-gather-layers-b-g/stories/S3-03-writer-signature-sanitizer-composition.mdStatus: Done.

Lessons (carry forward)

See _lessons.md for the cross-story takeaways landed this attempt (L33–L35).