* feat(W-024): reboot_event audit trail entry (L10b.3, ADR-0158) L10 scope §Sub-question 3: a reboot_event analog of TurnEvent, written to the telemetry JSONL, lets future audit reconstruct when this engine instance lost and regained its lifetime. - serialize_reboot_event / format_reboot_event_jsonl in chat/telemetry.py emit type="reboot" with restored_turn_count, stored/current revisions, revision_matched, recognizers_count, candidates_count - ChatRuntime._load_engine_state() buffers the JSONL line in _pending_reboot_payload (str|None); ChatRuntime.attach_telemetry_sink() flushes it exactly once when a sink is first attached - Reboot event precedes all turn events in the session audit stream - Pinned by 11 tests: serializer structure, determinism, revision_matched logic, runtime integration (emit-once, no-checkpoint, no-load-state, revision match, ordering) Closes L10b: W-022 (atomic writes) + W-023 (revision warning) + W-024 together satisfy ADR-0146's atomic/observable/auditable checkpoint triad. * fix(W-024): expose cached public git revision helper
482 lines
17 KiB
Python
482 lines
17 KiB
Python
"""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=(",", ":"))
|
|
|
|
|
|
# ---------- ADR-0158 — reboot-event serializer ----------
|
|
|
|
|
|
def serialize_reboot_event(
|
|
*,
|
|
restored_turn_count: int,
|
|
stored_revision: str,
|
|
current_revision: str,
|
|
recognizers_count: int,
|
|
candidates_count: int,
|
|
timestamp: str | None = None,
|
|
) -> dict[str, object]:
|
|
"""Produce a JSON-safe audit dict for an engine-state reboot recovery.
|
|
|
|
Written to the telemetry JSONL when ``ChatRuntime`` loads an existing
|
|
checkpoint at startup (L10 scope §Sub-question 3). Lets future audit
|
|
reconstruct when this engine instance lost and regained its lifetime.
|
|
|
|
``revision_matched`` is True iff both revisions are known and equal,
|
|
indicating the checkpoint was written by the same code that is running.
|
|
False means the operator should verify the loaded state is still valid
|
|
(the W-023 ``RuntimeWarning`` will already have fired).
|
|
|
|
Trust boundary: no surface text or tokens; metadata only.
|
|
"""
|
|
revision_matched = bool(
|
|
stored_revision not in ("unknown", "")
|
|
and current_revision not in ("unknown", "")
|
|
and stored_revision == current_revision
|
|
)
|
|
out: dict[str, object] = {
|
|
"type": "reboot",
|
|
"restored_turn_count": int(restored_turn_count),
|
|
"stored_revision": str(stored_revision),
|
|
"current_revision": str(current_revision),
|
|
"revision_matched": revision_matched,
|
|
"recognizers_count": int(recognizers_count),
|
|
"candidates_count": int(candidates_count),
|
|
}
|
|
if timestamp is not None:
|
|
out["timestamp"] = str(timestamp)
|
|
return out
|
|
|
|
|
|
def format_reboot_event_jsonl(
|
|
*,
|
|
restored_turn_count: int,
|
|
stored_revision: str,
|
|
current_revision: str,
|
|
recognizers_count: int,
|
|
candidates_count: int,
|
|
timestamp: str | None = None,
|
|
) -> str:
|
|
"""Serialize a reboot event as one deterministic JSONL line.
|
|
|
|
``"type": "reboot"`` discriminates this line from turn and correction
|
|
events at consume time without changing the sink contract.
|
|
"""
|
|
payload = serialize_reboot_event(
|
|
restored_turn_count=restored_turn_count,
|
|
stored_revision=stored_revision,
|
|
current_revision=current_revision,
|
|
recognizers_count=recognizers_count,
|
|
candidates_count=candidates_count,
|
|
timestamp=timestamp,
|
|
)
|
|
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_reboot_event_jsonl",
|
|
"format_turn_event_jsonl",
|
|
"format_verdict_summary",
|
|
"serialize_correction_event",
|
|
"serialize_reboot_event",
|
|
"serialize_turn_event",
|
|
]
|