core/docs/decisions/ADR-0035-turn-loop-verdict-surfacing.md
Shay 514ace0cbf feat(adr-0035): turn-loop auto-invocation — surfacing only
Wires SafetyCheck and EthicsCheck into ChatRuntime at end-of-turn on
both the main articulation path and _stub_response.  Verdicts attach
to ChatResponse.safety_verdict / .ethics_verdict and TurnEvent.
Observational at v1: no refusal, no re-articulation, no behavioral
change.  Refusal policy is the next ADR with real verdict data in hand.

Runtime-checkable predicates today:
  - preserve_versor_closure         (via _FieldStateWithVersor adapter)
  - no_identity_override            (manifold hash before vs after; equal by construction)
  - no_silent_correction            (runtime._last_refusal_was_typed bookkeeping)
  - acknowledge_uncertainty         (IdentityScore.alignment + hedge detection)
  - disclose_limitations            (walk_surface == _UNKNOWN_DOMAIN_SURFACE)

Predicates with no runtime evidence (no_manipulation, no_fabricated_source,
defer_high_stakes_to_human_review, respect_user_autonomy, no_hot_path_repair)
honestly report runtime_checkable=False per the ADR-0032/0034 discipline.
They become checkable as classifiers and pipelines land — surface contract
doesn't change.

Test coverage: 14 new tests; combined pack-layer surface suite (loaders +
checks + turn-loop) now 122 green.  CLI suites unaffected: smoke 67,
cognition 121, teaching 17, runtime 19.  Cognition eval baseline preserved.
2026-05-17 20:57:33 -07:00

129 lines
11 KiB
Markdown

# 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-0032-safety-check-surface.md), [`ADR-0034-ethics-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:
1. **Surfacing only.** Auto-invoke both checks at end-of-turn, populate contexts from whatever evidence the runtime has, attach verdicts to `ChatResponse` and `TurnEvent` for audit. No behavioral change.
2. **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 `None` by 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:
```python
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``SafetyVerdict` on every non-error path; `None` only if a future path constructs `ChatResponse` directly without invoking the surface.
- `ChatResponse.ethics_verdict: object``EthicsVerdict` symmetric.
- `TurnEvent.safety_verdict: object` — present on every main-path turn; `None` on stub paths (which bypass `turn_log` by 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_uncertainty` violation, no escalation on `disclose_limitations` violation.
- **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-verdicts` flag yet.
- **No GenerationResult coupling.** Verdicts live on the chat-turn-shaped objects (`ChatResponse`, `TurnEvent`), not on `GenerationResult` — 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=False` where the runtime has no evidence, `runtime_checkable=True` where 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_limitations` is the first commitment where stub paths matter — and they correctly fire as `upheld=True` because 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
- **`_FieldStateWithVersor` adapter is a small piece of glue.** `FieldState` does not expose `versor_condition` as an attribute, so the SafetyCheck predicate cannot read it via `getattr` directly. The adapter is a frozen dataclass with one field. Mitigated: the adapter is one local class; if `FieldState` later carries the value natively, the adapter becomes a trivial removal.
- **`hedge_emitted` is 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 as `acknowledge_uncertainty` violations 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 the `ChatResponse` but the `TurnEvent` does not exist for stub turns. Documented as a known limit; a future ADR could append `TurnEvent` records for stub paths too if audit completeness becomes a priority.
## Verification
- `tests/test_turn_loop_verdicts.py` — 14 tests covering: verdicts attached to `ChatResponse` / `TurnEvent`; runtime-checkability of `preserve_versor_closure`, `no_identity_override`, `no_silent_correction`, `acknowledge_uncertainty`, `disclose_limitations`; `no_manipulation` honestly 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
1. **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.
2. **TurnEvent for stub paths.** Should stub turns append a `TurnEvent` so audit completeness covers the entire turn stream?
3. **Verdict telemetry / logging.** A structured log sink that consumes verdicts is the next operational concern.
4. **CLI audit flag.** `core chat --show-verdicts` would print per-turn verdict summaries.
5. **Unified verdict bundle type.** A `TurnVerdicts` record grouping identity + safety + ethics for callers that want a single object.