core/docs/decisions/ADR-0059-correction-pass-telemetry.md
Shay 29449f3775 feat(adr-0059): correction-pass telemetry emission — backward perturbation auditable
`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.
2026-05-18 13:47:48 -07:00

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: int
  • turns_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_versor are 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=True in the serialiser; record order matches CorrectionPass.apply insertion order (deterministic).
  • No-op without a sink. When no telemetry sink is attached, _emit_correction_event returns 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:

  1. Add a correction_event block to TurnEvent. Same line, optional field. Lighter touch but pollutes the turn-event schema with a payload that's only present on correct() calls, and conflates two semantically distinct events (the backward perturbation and the forward regen turn).

  2. Emit a separate event with a "type": "correction" discriminator. New line type alongside turn events. Sink contract unchanged (still emit(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 FanOutSink and JsonlFileSink unchanged.
  • A future _emit_supersession_event, _emit_proposal_event, etc. can follow the same discriminator pattern.

Consequences

What changes

  • chat/telemetry.py gains serialize_correction_event and format_correction_event_jsonl. No changes to existing serialisers.
  • chat/runtime.py adds _emit_correction_event (mirrors _emit_turn_event) and calls it from correct() 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 on payload.get("type", "turn").
  • CorrectionPass itself is untouched; CorrectionResult already carried everything the event needs.
  • versor_condition < 1e-6 invariant: 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

Cross-References

  • ADR-0040 — the structured-logging sink this ADR adds an event type to.
  • ADR-0039 — the TurnVerdicts bundle that made every turn event uniform.
  • ADR-0055 — the broader inter-session-memory architecture where every state change must be inspectable, of which correction events are now part.