`ChatRuntime.correct()` propagates a backward perturbation through the session graph (per session/correction.py): each past turn whose output versor has non-trivial CGA-alignment with the correction versor is blended toward it (decayed by graph distance). The forward regen turn that followed already emitted a TurnEvent — but the backward perturbation itself was invisible to the telemetry sink. ADR-0059 closes that gap with a discriminated event line. - chat/telemetry.py — adds `serialize_correction_event` + `format_correction_event_jsonl` emitting one JSONL line discriminated by `"type": "correction"`. Payload: target_turn, records_count, turns_skipped, turn_idxs_affected, max_delta_norm, mean_delta_norm, SHA-256 correction_versor_digest, pack ids. No raw versor coordinates. - chat/runtime.py — `_emit_correction_event` (mirrors `_emit_turn_event`); called from `correct()` after the graph state is updated but before the forward regen turn. No-op without sink. - tests/test_correction_telemetry.py — 7 tests pin: no-op without sink, emission with sink, payload shape (required keys + types + ranges), SHA-256 digest shape, trust boundary (no versor coordinates leaked), determinism (byte-identical lines across runs), correction event and turn event coexist in the sink. Trust boundary (per CLAUDE.md): - Metadata-only: only L2 deltas + SHA-256 digest. - No implicit wall-clock. - Deterministic: same CorrectionResult → byte-identical line. - Sink contract unchanged: `emit(line: str)`. - `versor_condition < 1e-6` invariant: untouched (telemetry-only). Verification: smoke 67 / runtime 19 / correction telemetry 7 — green.
6.5 KiB
ADR-0059 — Correction-Pass Telemetry Emission
Status: Accepted Date: 2026-05-18 Author: Shay
Context
ChatRuntime.correct(text, target_turn=-1) propagates a backward
perturbation through the session graph: each past turn whose output
versor has non-trivial CGA-alignment with the correction versor is
blended toward it (decayed by graph distance). The mechanism is
documented in session/correction.py; CorrectionPass.apply()
returns a CorrectionResult carrying:
records: tuple[CorrectionRecord, ...]— per-turn old/new versors, alignment, decay, blend weight, distance from the correction-origin.turns_affected: intturns_skipped: int
The forward regen turn that follows correct() emits its own
TurnEvent via the existing telemetry sink. But the backward
perturbation itself — which past turns moved, by how much, and
toward what — was invisible to the telemetry sink.
That gap matters for the audit story:
- The session graph's state changed between turn events. A future replay of the session would see two turn events whose deltas don't add up unless the audit consumer knows a correction happened.
- The ADR-0055..0057 reviewed-corpus story rests on every state change being inspectable. A backward correction that no sink records is a structurally invisible mutation.
Decision
Emit one JSONL line per correct() invocation, discriminated by
"type": "correction", to the existing telemetry sink. Same sink
contract as TurnEvent; new event type alongside.
Payload
{
"type": "correction",
"target_turn": -1,
"identity_pack_id": "default_general_v1",
"safety_pack_id": "core_safety_axes_v1",
"ethics_pack_id": "default_general_ethics_v1",
"records_count": 1,
"turns_skipped": 0,
"turn_idxs_affected": [1],
"max_delta_norm": 1.3337411880493164,
"mean_delta_norm": 1.3337411880493164,
"correction_versor_digest": "e7e98af75ff6b4475807ed66f0672ec0cfab6f205896f8e626fc1490026ed597"
}
records_count/turns_skipped— how many turns were perturbed vs filtered by the minimum-alignment threshold.turn_idxs_affected— sorted tuple of affected turn indices. Lets a downstream consumer correlate the correction with the specific past turn events it perturbed.max_delta_norm/mean_delta_norm— L2 distance between old and new versors per record, max and mean across records. Magnitude of the backward perturbation without leaking coordinates.correction_versor_digest— SHA-256 hex of the correction versor's float32 bytes. Deterministic identifier for the perturbation source; pairs identical corrections across runs without exposing coordinates.
Trust boundary (per CLAUDE.md)
- Metadata-only. No raw versor coordinates ever appear in the
payload —
old_versor,new_versor,correction_versorare not serialised. Only the L2 deltas and the SHA-256 digest. - No implicit wall-clock. Timestamp is caller-provided (kwarg);
the runtime does not reach for
datetime.now()on this path. - Deterministic. Same
CorrectionResult→ byte-identical line.sort_keys=Truein the serialiser; record order matchesCorrectionPass.applyinsertion order (deterministic). - No-op without a sink. When no telemetry sink is attached,
_emit_correction_eventreturns immediately —correct()behaviour is byte-identical to pre-ADR-0059. - Sink errors propagate. Mirrors
_emit_turn_event's contract: a misconfigured durable sink surfaces loudly rather than silently dropping audit evidence.
Why a discriminated event, not an extended turn event
Two options were considered:
-
Add a
correction_eventblock toTurnEvent. Same line, optional field. Lighter touch but pollutes the turn-event schema with a payload that's only present oncorrect()calls, and conflates two semantically distinct events (the backward perturbation and the forward regen turn). -
Emit a separate event with a
"type": "correction"discriminator. New line type alongside turn events. Sink contract unchanged (stillemit(line: str)); consumers discriminate by reading the"type"field (absent on turn events today, present on correction events).
Path (2) chosen because:
- It keeps the turn-event schema clean.
- It models the underlying event structure honestly: a
correct()call produces two events (the perturbation, then the regen turn), not one composite turn event. - It composes with the existing
FanOutSinkandJsonlFileSinkunchanged. - A future
_emit_supersession_event,_emit_proposal_event, etc. can follow the same discriminator pattern.
Consequences
What changes
chat/telemetry.pygainsserialize_correction_eventandformat_correction_event_jsonl. No changes to existing serialisers.chat/runtime.pyadds_emit_correction_event(mirrors_emit_turn_event) and calls it fromcorrect()after the graph state is updated but before the forward regen turn.
What does not change
- Sink contract: still
emit(line: str). - Existing telemetry consumers that key off the absence of a
"type"field (treating every line as a turn event) keep working — they'll see correction lines and either ignore them or fail loudly, depending on their parser. Recommended consumer pattern: dispatch onpayload.get("type", "turn"). CorrectionPassitself is untouched;CorrectionResultalready carried everything the event needs.versor_condition < 1e-6invariant: this ADR adds telemetry emission only; no field state is touched.
Verification
tests/test_correction_telemetry.py 7 passed
- no_emission_without_sink
- emission_when_sink_attached
- correction_payload_shape (required keys, types, ranges)
- correction_versor_digest_is_sha256_prefix_or_empty
- no_versor_coordinates_in_payload (trust boundary)
- correction_event_is_deterministic_across_runs (byte-identical lines)
- turn_event_and_correction_event_coexist
Lanes (regression check):
core test --suite smoke 67 passed
core test --suite runtime 19 passed