Two thin layers closing the audit story end-to-end: - core chat --show-verdicts prints format_verdict_summary(verdicts) to stderr after each turn. Stdout stays clean for piped consumers. Format is dense and terse; designed to skim, not machine-parseable (the JSONL sink owns that contract). - FanOutSink forwards every emitted line to N sinks in declaration order. Fail-fast on first error — consistent with ADR-0040's single-sink contract (audit failures surface). Composes with any combination of JsonlFileSink / JsonlBufferSink / future sinks. Two formatters, one bundle: format_turn_event_jsonl (machine, ADR-0040) and format_verdict_summary (operator, ADR-0041) both consume the same TurnVerdicts. No risk of drift. Summary format: [identity=0.83 safety=ok ethics=VIOLATED:foo refusal=- hedge=YES] Audit story now reads end-to-end: - TurnVerdicts bundle (ADR-0039) - Machine JSONL sink (ADR-0040) - Fan-out + operator CLI (ADR-0041) Files: - chat/telemetry.py — FanOutSink dataclass, format_verdict_summary, _format_verdict_short helper - core/cli.py — --show-verdicts on chat subparser; cmd_chat prints summary to stderr when set - tests/test_telemetry_fanout_and_summary.py (new) — 13 tests - docs/decisions/ADR-0041-cli-verdicts-and-fanout.md (new) Verification: - Combined pack-layer + telemetry suite: 212 green (was 199; +13) - CLI suites unchanged: smoke 67, runtime 19, cognition 121 - core eval cognition: intent 100%, versor_closure 100% (baseline) - Manual smoke: echo "light is" | core chat --show-verdicts prints expected bracketed audit line to stderr alongside response.
7.1 KiB
ADR-0041: core chat --show-verdicts + Sink Fan-Out
Status: Accepted (2026-05-17)
Author: Joshua Shay + planner pass
Companion docs: ADR-0039-audit-completeness.md, ADR-0040-telemetry-sink.md
Context
ADR-0040 landed the machine-facing audit surface: deterministic JSONL lines on an attached sink. Two follow-ups were left explicit:
- Operator-facing readout. A human-readable per-turn summary printed alongside the chat response so an operator can debug refusals/hedges/violations interactively without parsing JSONL.
- Sink fan-out. Today one sink at a time. Operators often want both a durable local file and a remote aggregator (or two sinks pointing at different log stores) simultaneously.
Both are thin layers on top of the existing surface. Bundling them into one ADR keeps the audit story coherent.
Decision
core chat --show-verdicts
New CLI flag on the chat subparser. When set, after each turn the
REPL prints the verdict bundle summary to stderr:
> light is
I don't know — insufficient grounding for that yet.
[identity=- safety=ok ethics=VIOLATED:acknowledge_uncertainty refusal=- hedge=-]
Design choices:
- Summary goes to stderr. The chat response goes to stdout
(unchanged). Tooling that pipes
core chatthrough a filter doesn't see verdict noise interleaved with the response; humans watching the terminal see both. - Format is dense and terse. One bracketed line per turn,
fixed field order:
identity,safety,ethics,refusal,hedge. Designed to skim, not to be machine-parsed (the JSONL sink owns that contract). - Distinct from the machine-facing JSONL. ADR-0040's
format_turn_event_jsonlis for log aggregators; the newformat_verdict_summaryis for operators. Two formatters, one underlying bundle — no risk of drift.
FanOutSink
New sink in chat/telemetry.py:
@dataclass
class FanOutSink:
sinks: tuple = () # tuple[TurnEventSink, ...]
def emit(self, line: str) -> None:
for sink in self.sinks:
sink.emit(line)
- Fail-fast. First sink that raises propagates the exception; subsequent sinks are NOT called. Consistent with ADR-0040's single-sink contract: telemetry failures surface visibly, never silently drop audit signal.
- Composable. Any combination of sinks (file + buffer, multiple files, file + a future remote sink) works. Order is preserved.
- Stateless. No internal buffering; emission is synchronous and immediate to each child sink.
A ResilientSink wrapper (swallows errors per-sink) is intentionally
not included. Callers who want partial-success semantics wrap
individual sinks in their own error-tolerant shim. The default
contract should reflect the doctrine "audit failures are visible";
the resilient wrapper is its own future ADR if needed at scale.
format_verdict_summary formatter
New pure function in chat/telemetry.py:
def format_verdict_summary(verdicts) -> str: ...
- Returns
""forNoneinput. - Pulls fields off the bundle using
getattrwith safe defaults — same boundary discipline asserialize_turn_event. - Renders identity alignment to two decimals (
identity=0.83) oridentity=-when no score is available (stub turns). - Safety / ethics:
okwhen no violations, elseVIOLATED:<id1>,<id2>in lex order. - Remediation flags:
refusal=YES/refusal=-andhedge=YES/hedge=-.
Consequences
Positive
- Operator path complete. The audit story now reads end-to-end
for both humans and machines:
- Per-turn TurnVerdicts bundle (ADR-0039)
- Machine-facing JSONL sink (ADR-0040)
- Fan-out across multiple sinks (ADR-0041)
- Operator-facing CLI summary (ADR-0041)
- No new core runtime surface. Both additions are pure layers
on top of
ChatResponse.verdictsand the existing sink protocol. - Single source of truth. Operator and machine formatters share the underlying bundle; no risk of one going stale while the other evolves.
- Composable fan-out. Tested end-to-end: runtime attaches a
FanOutSinkand gets atomic distribution to file + buffer (or any N sinks) without changes to the runtime.
Negative / risks
format_verdict_summaryformat is human-stable, not machine-stable. Callers should not parse it. The JSONL sink remains the only machine-stable wire format. Documented in code comments but not enforced at the type level.- Fan-out is synchronous. Slow sinks slow the turn loop. Acceptable today (the only sinks are in-memory and local file); async / queued sinks are deferred to a future ADR alongside backpressure.
- CLI summary is on stderr. Tooling reading stderr separately works fine; tooling that merges streams sees the summary interleaved. This is the standard Unix split between content and metadata — the tradeoff was accepted to keep stdout clean for piped consumers.
Verification
tests/test_telemetry_fanout_and_summary.py— 13 tests covering: fan-out forwarding to all sinks, emission-order preservation, empty-sinks no-op, fail-fast on first error (downstream sinks NOT called), composition with file sink; runtime-with-fan-out end-to-end; verdict summary (None → empty, clean turn, safety violation, ethics violation, multiple violations lex-sorted, no identity score, real ChatResponse formats without error).- CLI smoke (manual):
Correct: cold-start stub turn has no identity score; ethics flags$ echo "light is" | core chat --show-verdicts > [identity=- safety=ok ethics=VIOLATED:acknowledge_uncertainty refusal=- hedge=-] I don't know — insufficient grounding for that yet.acknowledge_uncertaintybecause alignment_score=0.0 falls belowhedge_threshold_soft; default pack opt-in lists are empty so no refusal/hedge fires. - Combined pack-layer + telemetry suite: 212 tests, all green (was 199 after ADR-0040; +13).
- CLI suites unchanged: smoke 67, runtime 19, cognition 121.
core eval cognition: intent 100%, versor_closure 100% — baseline preserved.
Open questions deferred to a future ADR
- Resilient (error-tolerant) sink wrapper. Wraps any sink and
swallows emit-time errors with optional callback. Sibling to
FanOutSink; lands if real deployments need it. - Async / queued sinks. Synchronous fan-out becomes a bottleneck when one sink is a slow remote aggregator. A queued wrapper with backpressure semantics is the natural next step.
--show-verdictsgranularity flags. Today the summary is a single one-liner. Operators may want--verdicts-verbosefor per-predicate detail or--verdicts-only-on-violationfor high-volume sessions.- Schema versioning on the JSONL wire format. Was deferred from ADR-0040. Now that operators have a stable readout, the machine wire format can evolve more aggressively if needed.
- Sink registry. Today sinks are constructed by the caller.
A registry that resolves sinks from config strings
(
"file:/var/log/core/audit.jsonl","buffer") would simplify declarative deployment configuration.