11 KiB
ADR-0035: Turn-Loop Verdict Surfacing for SafetyCheck and EthicsCheck
Status: Accepted (2026-05-17)
Author: Joshua Shay + planner pass
Companion docs: ADR-0032-safety-check-surface.md, ADR-0034-ethics-check-surface.md
Context
ADR-0032 and ADR-0034 landed the structural surfaces — SafetyCheck and EthicsCheck — as observational, registry-of-predicates classes parallel in shape to IdentityCheck. Both ADRs deliberately left auto-invocation in the turn loop as a follow-up.
The follow-up is overdue. An observation surface that no caller invokes is a label without a verdict. ChatRuntime constructs both checks at startup and exposes them on self.safety_check / self.ethics_check, but no path in the turn loop calls .check(...). The pack-layer audit is uniformly silent regardless of what the turn actually did.
Two scope options were on the table:
- Surfacing only. Auto-invoke both checks at end-of-turn, populate contexts from whatever evidence the runtime has, attach verdicts to
ChatResponseandTurnEventfor audit. No behavioral change. - Surfacing + refusal. Same as above plus typed refusal on runtime-checkable violations.
The conservative choice — option 1 — was selected. Reasoning, from the implementation discussion:
Most context fields are
Noneby default and the runtime today only has evidence for ~3 of the ~10 fields across both surfaces (versor_condition, identity_manifold hashes, alignment_score, plus the unknown-domain signal). With sparse evidence, most violations are unobservable at v1 — wiring refusal now would refuse on a tiny fraction of theoretical violations while letting the rest slip silently through. The right sequence is to land the invocation point, observe what evidence actually surfaces across real turns, then decide refusal policy with that data in hand.
CLAUDE.md's "small, load-bearing PRs with clear evidence" doctrine maps to this.
Decision
ChatRuntime invokes both checks at the end of every chat turn — both the main articulation path and the _stub_response path. Verdicts attach to ChatResponse and TurnEvent as new optional fields. No behavioral effect; no refusal; no re-articulation; no retry.
Invocation sites
| Path | When invoked | What populates the context |
|---|---|---|
| Main articulation path | Just before constructing TurnEvent |
result.final_state, identity_score, walk_surface |
_stub_response |
Just before constructing the ChatResponse |
field_state, fixed ungrounded signal |
Stub paths trigger exactly when grounding is insufficient — i.e., when the disclose_limitations commitment is most active. Surfacing verdicts there preserves the contract "every ChatResponse carries a SafetyVerdict and EthicsVerdict" and gives disclose_limitations a stable runtime-checkable signal.
Evidence the turn loop populates today
SafetyContext (runtime-checkable in production):
| Field | Source | Predicate it unlocks |
|---|---|---|
field_state.versor_condition |
versor_condition(final_state.F) via an _FieldStateWithVersor adapter |
preserve_versor_closure |
last_refusal_was_typed |
runtime._last_refusal_was_typed (default True; reserved for future typed-refusal bookkeeping) |
no_silent_correction |
identity_manifold_hash_before |
runtime._identity_manifold_hash — captured once at startup; manifold is never mutated |
no_identity_override |
identity_manifold_hash_after |
recomputed at end-of-turn — equal by construction | no_identity_override |
EthicsContext (runtime-checkable in production):
| Field | Source | Predicate it unlocks |
|---|---|---|
alignment_score |
IdentityScore.alignment (zero on stub path) |
acknowledge_uncertainty |
hedge_threshold_soft |
identity_manifold.surface_preferences.hedge_threshold_soft |
acknowledge_uncertainty |
hedge_emitted |
_surface_contains_hedge(walk_surface, identity_manifold) — substring check against the manifold's hedge phrases (strong/soft/qualifier + per-axis variants) |
acknowledge_uncertainty |
grounded_in_evidence |
walk_surface != _UNKNOWN_DOMAIN_SURFACE |
disclose_limitations |
disclosure_emitted |
walk_surface == _UNKNOWN_DOMAIN_SURFACE (the inverse) |
disclose_limitations |
Not yet populated (predicates default to runtime_checkable=False, upheld=True):
cited_source_shas/allowed_source_shas— citation pipeline isn't wired into chat turns.high_stakes_topic/recommended_human_review— no high-stakes classifier.prescribed_single_answer/presented_options_count— no prescriptiveness classifier.
These fields surface in verdicts as runtime_checkable=False per the ADR-0032/0034 honest-reporting discipline. As classifiers land (future ADRs), the corresponding predicates become checkable without changing the surface contract.
Hash-of-manifold helper
_hash_identity_manifold(manifold) produces a deterministic SHA-256 of the load-bearing manifold fields:
payload = {
"value_axes": [{"axis_id", "name", "direction", "weight"} for axis in manifold.value_axes],
"boundary_ids": sorted(manifold.boundary_ids),
"alignment_threshold": manifold.alignment_threshold,
}
sha256(canonical_json(payload))
Captured once at ChatRuntime.__init__ and recomputed each turn. Because the runtime never mutates the manifold post-construction, before/after hashes are equal by construction — and that is the correct semantics for no_identity_override. An unequal hash would indicate the specific failure mode the predicate exists to surface.
Surface contract changes
New optional fields, defaulting to None:
ChatResponse.safety_verdict: object—SafetyVerdicton every non-error path;Noneonly if a future path constructsChatResponsedirectly without invoking the surface.ChatResponse.ethics_verdict: object—EthicsVerdictsymmetric.TurnEvent.safety_verdict: object— present on every main-path turn;Noneon stub paths (which bypassturn_logby existing design).TurnEvent.ethics_verdict: object— symmetric.
The fields are typed as object to avoid forcing core/physics/identity.py and chat/runtime.py to import the pack layer at module-resolution time — the underlying types are packs.safety.check.SafetyVerdict and packs.ethics.check.EthicsVerdict. Callers downcast at the use site.
What this ADR explicitly does NOT do
- No refusal. A runtime-checkable violation produces an audit verdict; the response is unchanged.
- No re-articulation. No retry path, no hedge injection on
acknowledge_uncertaintyviolation, no escalation ondisclose_limitationsviolation. - No logging integration. Verdicts are attached to the data structures; emitting them to logs / telemetry is a downstream decision.
- No CLI surface. No
core chat --show-verdictsflag yet. - No GenerationResult coupling. Verdicts live on the chat-turn-shaped objects (
ChatResponse,TurnEvent), not onGenerationResult— they belong to the turn, not the generation pass. - No cross-surface verdict bundle. Three separate verdicts (identity / safety / ethics) per turn. A unified type is convenience sugar deferred to a future ADR.
Consequences
Positive
- Audit no longer silent. Every chat turn now produces three orthogonal verdicts (identity score + safety verdict + ethics verdict). An operator inspecting
runtime.turn_log[-1]sees what each surface concluded. - Honest evidence-availability gradient. Predicates report
runtime_checkable=Falsewhere the runtime has no evidence,runtime_checkable=Truewhere it does. Future ADRs that wire in more evidence (high-stakes classifier, citation pipeline, prescriptiveness detector) increase the checkable count without changing the surface contract. - Surface contract uniform across stub and main paths.
disclose_limitationsis the first commitment where stub paths matter — and they correctly fire asupheld=Truebecause stubs emit the unknown-domain marker. - Forward-compatible with refusal. When the refusal-wiring ADR lands, the call site already exists; only the policy hook changes.
- Cheap. Auto-invocation is two predicate-registry passes per turn. No measurable cost on the smoke/cognition/runtime suites.
Negative / risks
_FieldStateWithVersoradapter is a small piece of glue.FieldStatedoes not exposeversor_conditionas an attribute, so the SafetyCheck predicate cannot read it viagetattrdirectly. The adapter is a frozen dataclass with one field. Mitigated: the adapter is one local class; ifFieldStatelater carries the value natively, the adapter becomes a trivial removal.hedge_emittedis substring-based. A more rigorous detector (token-aware, hedge-phrase registry separate from the manifold) would catch edge cases. Acceptable at v1 because false negatives surface asacknowledge_uncertaintyviolations in audit — exactly where audit is supposed to direct attention — rather than passing silently.- Stub path does not append to
turn_log. Pre-existing behavior. The verdict is on theChatResponsebut theTurnEventdoes not exist for stub turns. Documented as a known limit; a future ADR could appendTurnEventrecords for stub paths too if audit completeness becomes a priority.
Verification
tests/test_turn_loop_verdicts.py— 14 tests covering: verdicts attached toChatResponse/TurnEvent; runtime-checkability ofpreserve_versor_closure,no_identity_override,no_silent_correction,acknowledge_uncertainty,disclose_limitations;no_manipulationhonestly remaining non-runtime-checkable; hash determinism + before/after equality; hedge-detection happy and edge cases.- Combined pack-layer test surface (loaders + checks + turn-loop) is 122 tests, all green.
- CLI suites unaffected: smoke 67, cognition 121, teaching 17, runtime 19.
Open questions deferred to a future ADR
- Refusal / re-articulation policy. What does the runtime do with a violation? Refuse? Hedge? Log only? Per-pack policy? Per-predicate policy? Now that real verdict data flows, this can be decided empirically.
- TurnEvent for stub paths. Should stub turns append a
TurnEventso audit completeness covers the entire turn stream? - Verdict telemetry / logging. A structured log sink that consumes verdicts is the next operational concern.
- CLI audit flag.
core chat --show-verdictswould print per-turn verdict summaries. - Unified verdict bundle type. A
TurnVerdictsrecord grouping identity + safety + ethics for callers that want a single object.