TurnEvent had no trace_hash field, so teaching/discovery._trace_hash always returned "" via getattr default. Every persisted DiscoveryCandidate had source_turn_trace="" — provenance gap observed in a real 103-turn session. - Add trace_hash: str = "" to TurnEvent - runtime.finalize_turn_trace_hash back-stamps last TurnEvent and unstamped tail of _pending_candidates, then re-persists - CognitiveTurnPipeline.process calls finalize_turn_trace_hash after compute_trace_hash, before constructing CognitiveTurnResult Invariants: empty hash is a no-op; back-walk halts at first already- stamped candidate (no overwrite of prior turns); trace_hash bytes are unchanged for any given turn. Validated: tests/test_adr_0153_trace_hash_backstamp.py (6 tests), core test --suite cognition/smoke/runtime/teaching all green. Out of scope: OOV candidate trace_hash (same root cause, line-streamed sink requires different fix); telemetry-sink trace_hash exposure.
3.3 KiB
ADR-0153 — TurnEvent trace_hash back-stamp (W-020a)
Status: accepted Date: 2026-05-25
Context
teaching/discovery.py documents that DiscoveryCandidate.source_turn_trace
"is the upstream TurnEvent.trace_hash" (module docstring, line 41).
The extractor reads it via:
def _trace_hash(turn_event: Any) -> str:
value = getattr(turn_event, "trace_hash", "") or ""
return str(value)
But TurnEvent (core/physics/identity.py) has no trace_hash field.
Every DiscoveryCandidate written to disk had source_turn_trace="".
Same defect at chat/runtime.py:874 for OOVCandidate.
The real trace_hash is computed by core/cognition/trace.py:compute_trace_hash
inside CognitiveTurnPipeline.process after runtime.chat() returns —
which is after _emit_discovery_candidates has already buffered and
_checkpointed_response has already persisted the candidates.
Symptom observed: a 103-turn session wrote a discovery_candidates.jsonl
whose one candidate had "source_turn_trace": "" — provenance lost.
Decision
- Add
trace_hash: str = ""toTurnEvent. - Add
ChatRuntime.finalize_turn_trace_hash(trace_hash). Back-stamps:turn_log[-1](viadataclasses.replace)- the unstamped tail of
_pending_candidates - re-persists
discovery_candidates.jsonl
CognitiveTurnPipeline.processcallsfinalize_turn_trace_hashaftercompute_trace_hash, before constructingCognitiveTurnResult.
The back-walk halts at the first already-stamped candidate, so prior turns' candidates are never overwritten.
Invariants
- Empty
trace_hashis a no-op (refusal/stub call sites do not lose byte-identity). - Re-stamping is idempotent for the most recent turn and forbidden for earlier turns.
- No change to
compute_trace_hashinputs — trace_hash bytes for any given turn remain identical to pre-ADR-0153. - No change to
TurnEventtelemetry sink emission order (sinks receive the pre-stamp event; the persisted record gets the stamped one). A follow-up may add a "turn finalized" sink event with the trace_hash.
Out of scope
- OOV candidates (
OOVCandidate.source_turn_trace) — same root cause, but the sink is line-streamed at emit time, not buffered. Fix requires either buffering OOV or computing trace_hash earlier. Tracked separately; no production OOV sink was configured during the observed regression. - Telemetry sink trace_hash exposure — see invariants above.
Validation
tests/test_adr_0153_trace_hash_backstamp.py:
TurnEvent.trace_hashfield exists with default""- main-path
pipe.runstampsturn_log[-1].trace_hash == result.trace_hash - main-path
pipe.runstamps_pending_candidates[*].source_turn_trace - on-disk
discovery_candidates.jsonlreflects the stamped values - empty trace_hash is a no-op
- idempotent: prior stamps are not overwritten
CLI lanes: core test --suite cognition (120 + 1 skipped),
core test --suite smoke (67), core test --suite runtime (17),
core test --suite teaching (20) all green.
Provenance gap closure
After this ADR, every DiscoveryCandidate persisted via the
load-time auto-proposal pipeline (ADR-0151) carries the canonical
trace_hash of the turn that produced it, restoring the audit-trail
invariant the discovery module's docstring already promised.