Runtime-checkable SafetyVerdict violations now replace ChatResponse.surface (and TurnEvent.surface on the main path) with a deterministic typed refusal string. Ethics violations remain audit-only. Why safety-only: safety is the universal floor (ADR-0029, never-swappable, fail-closed). Ethics is swappable per-deployment; wiring ethics into refusal would let pack-swappers silently change refusal behavior via JSON edit. Wrong coupling. Why typed refusal (not hedge injection / not re-articulation): typed refusal is deterministic, audit-detectable by prefix, and preserves replayability. Hedge injection would blur surface-preferences-driven hedging vs predicate-driven refusal. Re-articulation retry yields the same surface (planner is deterministic; no refusal-bias hint surface exists). Deferred to a future ADR. Refusal contract: - ChatResponse.surface = typed refusal string - walk_surface + articulation_surface = unchanged (audit preserved) - runtime._last_refusal_was_typed = True (next-turn evidence for no_silent_correction) - Only runtime_checkable=True violations refuse - Stub path symmetric Files: - chat/refusal.py (new) — pure refusal builder + audit helpers - chat/runtime.py — invoke build_refusal_surface after safety_verdict - tests/test_safety_refusal.py (new) — 20 tests - docs/decisions/ADR-0036-safety-refusal-policy.md (new) Verification: - 20 new tests; combined pack-layer suite 116 green - CLI suites unchanged: smoke 67, runtime 19, cognition 121 - core eval cognition: intent 100%, versor_closure 100% (baseline)
177 lines
7.9 KiB
Markdown
177 lines
7.9 KiB
Markdown
# ADR-0036: Safety-Only Typed Refusal Policy
|
|
|
|
**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), [`ADR-0035-turn-loop-verdict-surfacing.md`](ADR-0035-turn-loop-verdict-surfacing.md)
|
|
|
|
## Context
|
|
|
|
ADR-0035 wired `SafetyCheck` and `EthicsCheck` into the turn loop as
|
|
**observation only** — verdicts attach to `ChatResponse` and `TurnEvent`
|
|
but do not change behavior. The closing notes flagged refusal /
|
|
re-articulation as the natural follow-up *once real verdict data
|
|
flowed*.
|
|
|
|
With one ADR's worth of verdict surfacing in the runtime, two scope
|
|
axes had to be decided before wiring refusal:
|
|
|
|
1. **Trigger scope.** Safety only? Safety + ethics? Per-predicate?
|
|
2. **Refusal shape.** Typed refusal surface? Hedge injection?
|
|
Re-articulation via planner retry?
|
|
|
|
The decision was made jointly with the user, surfaced via an explicit
|
|
scope question before any code landed.
|
|
|
|
## Decision
|
|
|
|
**Safety-only typed refusal.** A `SafetyVerdict` with at least one
|
|
`runtime_checkable=True, upheld=False` result replaces
|
|
`ChatResponse.surface` with a deterministic typed refusal string.
|
|
Ethics violations remain audit-only.
|
|
|
|
### Why safety only
|
|
|
|
Safety is the universal floor (ADR-0029): five fixed boundaries, never
|
|
swappable, fail-closed on load. Ethics is deployment configuration
|
|
above that floor (ADR-0033): swappable per-deployment, falls back to
|
|
default. Wiring ethics into refusal would let pack-swappers silently
|
|
change the runtime's refusal behavior by editing a JSON file — exactly
|
|
the coupling we want to avoid. Safety is the architectural place to
|
|
encode "the floor never moves."
|
|
|
|
A future ADR can revisit per-predicate ethics refusal once individual
|
|
ethics commitments have empirical violation rates from real corpora.
|
|
|
|
### Why typed refusal (not hedge injection, not re-articulation)
|
|
|
|
* **Typed refusal** is deterministic, audit-detectable by prefix, and
|
|
preserves replayability. The refusal carries the violated
|
|
boundary ids in lex order. Same violation → same bytes.
|
|
* **Hedge injection** would blur the boundary between
|
|
alignment-score-driven hedging (ADR-0028 surface preferences) and
|
|
predicate-driven refusal. The same surface change could mean two
|
|
different things. Audit becomes ambiguous.
|
|
* **Re-articulation** via planner retry is deterministic too, but the
|
|
planner has no refusal-bias hint surface today — retry with
|
|
unchanged inputs yields the same surface. Deferred to a future ADR
|
|
that first lands evidence-threading through the planner.
|
|
|
|
### Why only `runtime_checkable=True` violations refuse
|
|
|
|
A predicate that reports `runtime_checkable=False` is honestly stating
|
|
"I have no evidence to make a real claim." Refusing on no-evidence
|
|
predicates would refuse on architectural absence, not behavioral
|
|
violation. The ADR-0032/0034 honest-reporting discipline means
|
|
`runtime_checkable` is exactly the gate for "did we observe a real
|
|
violation."
|
|
|
|
### What the runtime contract looks like now
|
|
|
|
`chat/refusal.py`:
|
|
|
|
* `TYPED_REFUSAL_PREFIX = "I cannot proceed — safety boundary violated: "`
|
|
* `build_refusal_surface(verdict) -> str | None` — pure function, no I/O.
|
|
* `violated_runtime_checkable(verdict) -> tuple[str, ...]` — lex-sorted helper.
|
|
* `is_typed_refusal(surface) -> bool` — audit helper.
|
|
|
|
`chat/runtime.py` — both the main turn path and `_stub_response`
|
|
invoke `build_refusal_surface(safety_verdict)` after the verdict is
|
|
computed. On a non-None return:
|
|
|
|
* `ChatResponse.surface` = typed refusal
|
|
* `ChatResponse.walk_surface` = unchanged (audit evidence preserved)
|
|
* `ChatResponse.articulation_surface` = unchanged (realizer evidence preserved)
|
|
* `TurnEvent.surface` = typed refusal (main path only; stub path bypasses turn_log by design)
|
|
* `runtime._last_refusal_was_typed = True` — so the next turn's
|
|
`no_silent_correction` predicate has live evidence.
|
|
|
|
### Surface contract integrity
|
|
|
|
The runtime surface contract from CLAUDE.md says:
|
|
|
|
```
|
|
surface = articulation_surface (selected user-facing response)
|
|
walk_surface = retained telemetry/evidence
|
|
```
|
|
|
|
Refusal changes the *selection* (`surface` no longer equals
|
|
`articulation_surface`); it does not corrupt the evidence
|
|
(`walk_surface` and `articulation_surface` retain what the runtime
|
|
would have said). An auditor reading a refusal turn sees:
|
|
|
|
* what the runtime *would have* surfaced (walk_surface / articulation),
|
|
* what it *did* surface (typed refusal),
|
|
* and *why* (safety_verdict).
|
|
|
|
This is the same audit shape as a non-refusing turn — no new contract.
|
|
|
|
## Consequences
|
|
|
|
### Positive
|
|
|
|
* **First load-bearing pack-layer behavior.** The pack-layer surface
|
|
now has a way to actually stop the runtime from emitting bad output,
|
|
not just label it.
|
|
* **Deterministic.** Same forced violation → byte-identical refusal
|
|
string. Replay invariant preserved.
|
|
* **Audit-complete.** Every refusal carries the verdict and the
|
|
preserved walk/articulation evidence. No silent refusals.
|
|
* **Bookkeeping closes the loop on `no_silent_correction`.** When the
|
|
runtime refuses, it sets `_last_refusal_was_typed=True`, so the
|
|
next turn's predicate has live evidence of typed refusal.
|
|
* **Cheap.** One pure function call per turn. Test suites and
|
|
cognition eval unchanged.
|
|
|
|
### Negative / risks
|
|
|
|
* **No per-predicate refusal opt-out.** All `runtime_checkable=True`
|
|
safety violations refuse. If a future safety pack introduces a
|
|
predicate that should be observe-only, the surface needs a
|
|
per-predicate `audit_only` flag. Acceptable today: the v1 safety
|
|
pack has five boundaries and refusing on each is the right semantics.
|
|
* **Hedge injection is *not* the refusal path.** A high-confidence
|
|
emission with low alignment score still passes through unhedged
|
|
unless the manifold's `surface_preferences` choose to hedge. This
|
|
is correct: hedging is a surface preference (ADR-0028), refusal is
|
|
a safety boundary. Conflating them was rejected.
|
|
* **Stub path refusal happens but `TurnEvent` is not emitted.** Same
|
|
pre-existing limit as ADR-0035. Audit completeness for stub paths
|
|
is a separate ADR.
|
|
|
|
## Verification
|
|
|
|
* `tests/test_safety_refusal.py` — 20 tests covering: pure refusal
|
|
builder (none, all-upheld, non-checkable violation, single
|
|
violation, determinism, lex order); helpers
|
|
(`violated_runtime_checkable`, `is_typed_refusal`); ChatRuntime
|
|
integration (ordinary turn unchanged, forced violation emits
|
|
refusal, walk_surface preserved, articulation_surface preserved,
|
|
verdicts still attached, `_last_refusal_was_typed` bookkeeping,
|
|
`TurnEvent.surface` carries the refusal); ethics violations do
|
|
NOT trigger refusal; stub-path refusal.
|
|
* Combined pack-layer surface suite: **116 tests, all green**
|
|
(safety pack + safety check + ethics pack + ethics check +
|
|
turn-loop verdicts + refusal).
|
|
* CLI suites unaffected: smoke 67, runtime 19, cognition 121.
|
|
* `core eval cognition`: intent_accuracy 100%, versor_closure_rate 100%
|
|
(baseline preserved).
|
|
|
|
## Open questions deferred to a future ADR
|
|
|
|
1. **Per-predicate ethics refusal.** Pack-schema flag to opt specific
|
|
ethics commitments into refusal once empirical violation rates are
|
|
available.
|
|
2. **Hedge-injection as a separate surface affordance.** A
|
|
below-threshold alignment score could prepend the manifold's hedge
|
|
without triggering refusal. Today this is partially handled by
|
|
the assembler's `SurfaceContext`; lifting it to a runtime-level
|
|
decision is its own ADR.
|
|
3. **`TurnEvent` for stub paths.** Audit completeness across the
|
|
refusal-on-stub path.
|
|
4. **Refusal telemetry sink.** A structured log emitter consumes
|
|
refusals for operational dashboards.
|
|
5. **`core chat --show-verdicts` CLI flag.** Per-turn verdict and
|
|
refusal printout for manual audit.
|
|
6. **Refusal-bias planner retry.** Re-articulation as a deliberate
|
|
re-plan with refusal context threaded in. Deferred until
|
|
evidence-threading through the planner lands.
|