ADR-0009: Cache-hit pass-through as a first-class coordinator output (ProbeExecution = Ran \| CacheHit \| Skipped)¶
Status: Accepted Date: 2026-05-11 Tags: coordinator · cache · interface · phase-evolution Related: ADR-0005, production ADR-0006
Context¶
../critique.md §6.5 flags a shared blind spot across all three lens designs: each one describes the coordinator's cache interaction as cache.get → run probe → cache.put. None of the three Phase 0 designs implements the skip-and-pass-through path where a cache hit returns a cached ProbeOutput without running the probe and the coordinator records the cache hit as a distinct event from a fresh run.
Phase 14's continuous-gather model (production ADR-0006) is built on incremental gathers: only probes with changed declared_inputs re-run; the rest pass through. The coordinator must report which probes ran fresh, which were cache hits, and which were skipped for some other reason. Phase 13's cost ledger (ADR-0004) attributes spend differently depending on this distinction — a cache hit costs effectively zero; a Ran execution carries the probe's compute cost.
If the coordinator returns just dict[str, ProbeOutput] (lens-design default), Phase 14 cannot tell the difference between "this probe ran fresh in this gather" and "we returned the cached result of a prior gather." That distinction is load-bearing for both cost attribution and incremental gather.
Options considered¶
dict[str, ProbeOutput]only (lens-design default). Coordinator returns just the outputs. Phase 14 has to infer cache-vs-fresh from cache state at gather time — a separate query, racy, lossy.- Side channel via structured logging. Emit
probe.cache_hitandprobe.successevents; consumers correlate them with outputs by name. Works for monitoring; brittle for cost attribution (events can be lost). ProbeExecution = Ran \| CacheHit \| Skippedalongside outputs (synth gap-fix). Coordinator returnsGatherResult(outputs, executions).Ran(output)carries the freshly-run output;CacheHit(output, key)carries the cached output and the key it came from;Skipped(reason)covers cases like "applies() returned False" or "preconditions not met." Phase 14 readsexecutionsfor incremental decisions; Phase 13 reads it for cost attribution; the audit writer reads it for per-probe records.
Decision¶
The Coordinator returns GatherResult(outputs: dict[str, ProbeOutput], executions: dict[str, ProbeExecution]). ProbeExecution is a tagged union: Ran(output: ProbeOutput) | CacheHit(output: ProbeOutput, key: str) | Skipped(reason: str). All three variants are frozen dataclasses. The executions dict is populated for every probe the coordinator was asked to dispatch — including those that produced no output (Skipped). Phase 0 ships one probe; the cache-hit smoke test (test_cli_end_to_end.py::test_cache_hit_on_second_run) asserts executions["language_detection"] is a CacheHit on the second invocation.
Tradeoffs¶
| Gain | Cost |
|---|---|
| Phase 14's incremental-gather model has the coordinator interface it needs — no contract extension when continuous gather lands | Two-channel output (outputs + executions) instead of one — callers must consume both; mitigated by GatherResult carrying them together as a frozen dataclass |
| Phase 13's cost attribution distinguishes free cache hits from probe runs — accurate spend per probe execution | Tagged-union pattern is more Python-3.10+-ish (match statement-friendly) than the average codebase; readers must learn the variants |
The audit writer (ADR-0004) populates ProbeExecutionRecord cleanly from each variant — Ran/CacheHit carry the key and blob hash; Skipped marks exit_status="skipped" |
Skipped is over-engineered for Phase 0 (no probe is ever skipped — LanguageDetectionProbe.applies() returns True for everything). The variant exists for Phase 1+ |
Phase 14's "extension by addition" (production/design.md §2.5) holds — the contract freezes here; later phases consume more variants, not different shapes |
Three variants instead of two — Skipped might have been deferred; the synthesis chose to encode it now because Phase 1's applies_to_languages filter is the first consumer |
The structured-logging events probe.cache_hit / probe.success / probe.skip line up with the variants — same names, same semantics, dual surface for both consumers and audit writers |
Two surfaces (events + dataclass) emit equivalent information; mitigated by them being constructed at the same code point, never independently |
Consequences¶
src/codegenie/coordinator/coordinator.pyexportsGatherResult,Ran,CacheHit,Skipped,ProbeExecution. Frozen dataclasses throughout.- The CLI exit policy in
cli.pyreadsexecutionsto compute exit codes: 0 if ≥1 probe inoutputs; 2 if all probes have errors or areSkipped. - The audit writer reads
executions[probe_name]to populateProbeExecutionRecord—Ran→exit_status="ok",CacheHit→exit_status="ok", cache_hit=True,Skipped→exit_status="skipped"(ADR-0004). - The lifecycle event names align:
probe.cache_hitis emitted onCacheHit,probe.successonRansuccess,probe.skiponSkipped. Phase 6's state ledger subscribes to the events; Phase 13's cost ledger consumes theexecutionsdict. - Phase 1's six probes will produce a mix of
RanandCacheHitresults; Phase 2 introduces the firstapplies()filter that returnsSkipped(reason="language not detected"). None of these phases editsProbeExecution. test_cli_end_to_end.py::test_cache_hit_on_second_runis the Phase 0 exit criterion #4 verifier (../final-design.md §11): second run reportsCacheHit, andos.scandiris never re-entered (verified viamonkeypatch).
Reversibility¶
Medium. Collapsing back to dict[str, ProbeOutput] only is mechanically cheap (delete the executions dict; consumers fall back to log events) but loses the audit-record fidelity (ADR-0004's cache_key and blob_sha256 populate from this output) and breaks Phase 14's incremental-gather plan. After Phase 1, every probe set tested has this contract; removal is multi-phase coordinated work.
Evidence / sources¶
../final-design.md §2.6(Cache-hit pass-through is first-class)../final-design.md §L4 row 5(Shared blind spot resolution: cache-hit pass-through in coordinator output)../critique.md §6.5(Shared blind spot — no skip-and-pass-through path)../phase-arch-design.md §Component design / Coordinator(GatherResultshape)../phase-arch-design.md §Data model(Ran | CacheHit | Skippeddataclasses)- production ADR-0006 — incremental gather depends on this distinction
- ADR-0004 — audit anchors populate from
ProbeExecutionvariants - ADR-0005 — coordinator contract this completes