"""ADR-0040 — structured-logging sink for turn-event audit. Consumes ``TurnEvent`` records that ADR-0039 makes uniform across main and stub paths. Emits one JSON-line per turn with deterministic field ordering, suitable for log aggregation, replay, and offline audit pipelines. Trust boundary (per CLAUDE.md): * **Metadata-only by default.** Surface text and input tokens are redacted unless the caller explicitly opts in via ``include_content=True``. Audit needs counts, ids, and flags — not raw content — and the redact-by-default stance prevents accidental PII leakage when sinks point at shared log stores. * **No implicit wall-clock.** Timestamps are caller-provided so emission is reproducible under replay. The runtime never reaches for ``datetime.now()`` here. * **Append-only file paths.** ``JsonlFileSink`` opens the target in append mode and never truncates. Path is fixed at construction; the sink does not interpret user-controlled paths at emit time. * **Idempotent flush.** Each ``emit()`` flushes immediately so a crashed turn loop still has its prior turns durable on disk. See ``docs/decisions/ADR-0040-telemetry-sink.md``. """ from __future__ import annotations import json from dataclasses import dataclass, field from pathlib import Path from typing import IO, Protocol from core.epistemic_state import ( coerce_epistemic_state, coerce_normative_clearance, ) _UNKNOWN_DOMAIN_SURFACE = "I don't know — insufficient grounding for that yet." # ---------- pure serializer ---------- def serialize_turn_event( event, *, safety_pack_id: str = "", ethics_pack_id: str = "", identity_pack_id: str = "", include_content: bool = False, timestamp: str | None = None, ) -> dict[str, object]: """Produce a JSON-safe audit dict from a ``TurnEvent``. Pack ids are passed as kwargs because ``TurnEvent`` does not carry them — the runtime knows them. Fields are typed deliberately at the boundary so an upstream change to ``TurnEvent`` doesn't silently break the wire format; missing or differently-typed values fall back to safe defaults. """ verdicts = getattr(event, "verdicts", None) out: dict[str, object] = { "turn": int(getattr(event, "turn", 0)), "safety_pack_id": str(safety_pack_id), "ethics_pack_id": str(ethics_pack_id), "identity_pack_id": str(identity_pack_id), "epistemic_state": coerce_epistemic_state( getattr(event, "epistemic_state", None) ).value, "normative_clearance": coerce_normative_clearance( getattr(event, "normative_clearance", None) ).value, "normative_detail": str( getattr(event, "normative_detail", "") or "" ), "refusal_emitted": bool(getattr(verdicts, "refusal_emitted", False)), "hedge_injected": bool(getattr(verdicts, "hedge_injected", False)), "versor_condition": float(getattr(event, "versor_condition", 0.0)), "vault_hits": int(getattr(event, "vault_hits", 0)), "cycle_cost_total": float(getattr(event, "cycle_cost_total", 0.0)), "flagged": bool(getattr(event, "flagged", False)), "stub_path": getattr(event, "walk_surface", "") == _UNKNOWN_DOMAIN_SURFACE, "dialogue_role": str(getattr(event, "dialogue_role", "")), # ADR-0072 (R5) — operator-visible register identity per turn. # Empty strings on pre-R5 events / UNREGISTERED runtimes / empty # marker buckets, so the wire format degrades cleanly. "register_id": str(getattr(event, "register_id", "") or ""), "register_variant_id": str(getattr(event, "register_variant_id", "") or ""), # ADR-0073d (L1.4) — operator-visible anchor-lens identity per # turn. Empty strings on pre-L1.4 events / UNANCHORED runtimes / # turns where the lens did not engage. "anchor_lens_id": str(getattr(event, "anchor_lens_id", "") or ""), "anchor_lens_mode_label": str(getattr(event, "anchor_lens_mode_label", "") or ""), # ADR-0075 (C1) — realizer slot-type guard verdict per turn. # Empty strings on pre-C1 events; closed enums otherwise. "realizer_guard_status": str(getattr(event, "realizer_guard_status", "") or ""), "realizer_guard_rule": str(getattr(event, "realizer_guard_rule", "") or ""), "composer_graph_atom_status": str( getattr(event, "composer_graph_atom_status", "") or "" ), "composer_atom_set_hash": str( getattr(event, "composer_atom_set_hash", "") or "" ), "graph_atom_set_hash": str( getattr(event, "graph_atom_set_hash", "") or "" ), "composer_graph_atom_overlap_count": int( getattr(event, "composer_graph_atom_overlap_count", 0) or 0 ), } safety = getattr(event, "safety_verdict", None) if safety is not None: out["safety_violated"] = sorted( getattr(safety, "violated_boundaries", ()) or () ) out["safety_runtime_checkable_count"] = int( getattr(safety, "runtime_checkable_count", 0) ) out["safety_upheld"] = bool(getattr(safety, "upheld", True)) ethics = getattr(event, "ethics_verdict", None) if ethics is not None: out["ethics_violated"] = sorted( getattr(ethics, "violated_commitments", ()) or () ) out["ethics_runtime_checkable_count"] = int( getattr(ethics, "runtime_checkable_count", 0) ) out["ethics_upheld"] = bool(getattr(ethics, "upheld", True)) identity_score = getattr(event, "identity_score", None) if identity_score is not None: out["identity_alignment"] = float( getattr(identity_score, "alignment", 0.0) ) out["identity_flagged"] = bool(getattr(identity_score, "flagged", False)) out["identity_deviation_axes"] = sorted( getattr(identity_score, "deviation_axes", ()) or () ) if include_content: out["input_tokens"] = list(getattr(event, "input_tokens", ())) out["surface"] = str(getattr(event, "surface", "")) out["walk_surface"] = str(getattr(event, "walk_surface", "")) out["articulation_surface"] = str( getattr(event, "articulation_surface", "") ) # ADR-0077 (R6) — register canonical surface (pre-substantive, # pre-decoration). Content-gated like the other surfaces. out["register_canonical_surface"] = str( getattr(event, "register_canonical_surface", "") or "" ) if timestamp is not None: out["timestamp"] = str(timestamp) return out def format_turn_event_jsonl(event, **kwargs) -> str: """Serialize a turn event as one deterministic JSONL line. Field order is alphabetical (``sort_keys=True``) so two emissions of the same logical event produce byte-identical lines. No trailing newline — the sink owns line termination. """ payload = serialize_turn_event(event, **kwargs) return json.dumps(payload, sort_keys=True, separators=(",", ":")) # ---------- ADR-0059 — correction-event serializer ---------- def serialize_correction_event( correction_result, *, target_turn: int, identity_pack_id: str = "", safety_pack_id: str = "", ethics_pack_id: str = "", timestamp: str | None = None, ) -> dict[str, object]: """Produce a JSON-safe audit dict from a ``CorrectionResult``. Distinct from a turn event: this records the *backward* update to a session graph triggered by ``ChatRuntime.correct()``. The forward regen turn that follows still emits its own turn event; this event documents the perturbation itself so audit consumers can answer "which past turns moved, by how much, and toward what". Trust boundary (per CLAUDE.md): * **Metadata-only.** Versor coordinates are NOT emitted — only the L2-delta-norm-per-record and a SHA-256 digest of the correction versor's float32 bytes (deterministic identifier). * **No implicit wall-clock.** ``timestamp`` is caller-provided. * **Deterministic.** Same ``CorrectionResult`` → byte-identical serialized line. ``records`` are traversed in their tuple order (deterministic, matches insertion order from ``CorrectionPass.apply``). """ import hashlib import math records = getattr(correction_result, "records", ()) or () correction_versor = getattr(correction_result, "correction_versor", None) # Per-record L2 deltas + the max across records. deltas: list[float] = [] turn_idxs: list[int] = [] for r in records: old_v = getattr(r, "old_versor", None) new_v = getattr(r, "new_versor", None) if old_v is None or new_v is None: continue # numpy.ndarray subtraction + norm; pure stdlib fallback would # be slower but the runtime already imports numpy on this path. import numpy as np delta = float(np.linalg.norm(np.asarray(new_v) - np.asarray(old_v))) if math.isfinite(delta): deltas.append(delta) turn_idxs.append(int(getattr(r, "turn_idx", 0))) # SHA-256 digest of the correction versor's float32 bytes — gives # a stable identifier for the perturbation without leaking # coordinates. Falls back to empty string when missing. digest = "" if correction_versor is not None: import numpy as np digest = hashlib.sha256( np.asarray(correction_versor, dtype=np.float32).tobytes() ).hexdigest() out: dict[str, object] = { "type": "correction", "target_turn": int(target_turn), "identity_pack_id": str(identity_pack_id), "safety_pack_id": str(safety_pack_id), "ethics_pack_id": str(ethics_pack_id), "records_count": int(getattr(correction_result, "turns_affected", len(records))), "turns_skipped": int(getattr(correction_result, "turns_skipped", 0)), "turn_idxs_affected": sorted(turn_idxs), "max_delta_norm": max(deltas) if deltas else 0.0, "mean_delta_norm": (sum(deltas) / len(deltas)) if deltas else 0.0, "correction_versor_digest": digest, } if timestamp is not None: out["timestamp"] = str(timestamp) return out def format_correction_event_jsonl(correction_result, **kwargs) -> str: """Serialize a correction event as one deterministic JSONL line. The ``"type": "correction"`` field discriminates this line from turn events at consume time without changing the sink contract. """ payload = serialize_correction_event(correction_result, **kwargs) return json.dumps(payload, sort_keys=True, separators=(",", ":")) # ---------- sink protocol ---------- class TurnEventSink(Protocol): """Minimal sink contract. Sinks receive one already-serialized JSONL line per turn. The runtime calls ``emit()`` after each ``turn_log.append()`` — see ``ChatRuntime._emit_turn_event``. """ def emit(self, line: str) -> None: ... # ---------- concrete sinks ---------- @dataclass class JsonlBufferSink: """In-memory sink that captures every emitted line. Useful for tests, replay diffing, and small-volume audit where persistence is the caller's responsibility. """ lines: list[str] = field(default_factory=list) def emit(self, line: str) -> None: self.lines.append(line) class JsonlFileSink: """Append-only JSONL file sink with eager flush. The path is fixed at construction. Each ``emit()`` flushes immediately so a crashed runtime still has its prior turns durable on disk. Supports context-manager usage. """ def __init__(self, path: str | Path) -> None: self._path = Path(path) self._fh: IO[str] | None = None def emit(self, line: str) -> None: if self._fh is None: self._path.parent.mkdir(parents=True, exist_ok=True) self._fh = self._path.open("a", encoding="utf-8") self._fh.write(line) self._fh.write("\n") self._fh.flush() def close(self) -> None: if self._fh is not None: self._fh.close() self._fh = None def __enter__(self) -> "JsonlFileSink": return self def __exit__(self, *exc_info) -> None: self.close() # ---------- fan-out ---------- @dataclass class FanOutSink: """Forward every emitted line to N sinks in declaration order. ADR-0041. Composes with any combination of sinks — typically ``JsonlFileSink`` (durable) + ``JsonlBufferSink`` (in-memory audit), or two file sinks (local + shadow). **Error semantics:** fail-fast. If sink *i* raises, sinks *i+1..* are NOT called and the exception propagates to the caller. This is consistent with the single-sink contract: telemetry failures surface, never silently drop audit signal. Callers wanting partial-success semantics wrap individual sinks in their own error-tolerant shim. """ sinks: tuple = () # tuple[TurnEventSink, ...] def emit(self, line: str) -> None: for sink in self.sinks: sink.emit(line) # ---------- operator-facing summary formatter ---------- def format_verdict_summary(verdicts) -> str: """ADR-0041 — one-line human-readable summary of a TurnVerdicts bundle. Used by ``core chat --show-verdicts`` to print a per-turn audit line to the operator. Distinct from ``format_turn_event_jsonl`` (machine-facing): this is dense, terse, and skims the high-signal fields. Empty string when ``verdicts`` is None. Format:: [identity=0.83 safety=ok ethics=ok refusal=- hedge=-] [identity=0.42 safety=VIOLATED:preserve_versor_closure ethics=ok refusal=YES hedge=-] """ if verdicts is None: return "" parts: list[str] = [] identity = getattr(verdicts, "identity_score", None) if identity is not None: alignment = float(getattr(identity, "alignment", 0.0)) parts.append(f"identity={alignment:.2f}") else: parts.append("identity=-") safety = getattr(verdicts, "safety_verdict", None) parts.append(_format_verdict_short( safety, "safety", id_attr="violated_boundaries", )) ethics = getattr(verdicts, "ethics_verdict", None) parts.append(_format_verdict_short( ethics, "ethics", id_attr="violated_commitments", )) parts.append( "refusal=YES" if getattr(verdicts, "refusal_emitted", False) else "refusal=-" ) parts.append( "hedge=YES" if getattr(verdicts, "hedge_injected", False) else "hedge=-" ) return "[" + " ".join(parts) + "]" def _format_verdict_short(verdict, label: str, *, id_attr: str) -> str: if verdict is None: return f"{label}=-" violated = sorted(getattr(verdict, id_attr, ()) or ()) if not violated: return f"{label}=ok" return f"{label}=VIOLATED:{','.join(violated)}" __all__ = [ "FanOutSink", "JsonlBufferSink", "JsonlFileSink", "TurnEventSink", "format_correction_event_jsonl", "format_turn_event_jsonl", "format_verdict_summary", "serialize_correction_event", "serialize_turn_event", ]