From a0372c951f86546f3c06759ee1cefd169fad8378 Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 17 May 2026 21:10:52 -0700 Subject: [PATCH] feat(adr-0036): safety-only typed refusal policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- chat/refusal.py | 71 +++++ chat/runtime.py | 29 +- .../ADR-0036-safety-refusal-policy.md | 177 ++++++++++++ tests/test_safety_refusal.py | 257 ++++++++++++++++++ 4 files changed, 531 insertions(+), 3 deletions(-) create mode 100644 chat/refusal.py create mode 100644 docs/decisions/ADR-0036-safety-refusal-policy.md create mode 100644 tests/test_safety_refusal.py diff --git a/chat/refusal.py b/chat/refusal.py new file mode 100644 index 00000000..e4b0e590 --- /dev/null +++ b/chat/refusal.py @@ -0,0 +1,71 @@ +"""ADR-0036 — typed refusal surface for runtime-checkable safety violations. + +The refusal surface is the runtime's response to a SafetyVerdict that +reports at least one ``runtime_checkable=True, upheld=False`` result. +It is deliberately: + +* **Deterministic.** Same set of violated boundaries → same surface + bytes. Replayability is preserved. +* **Typed.** A constant prefix (``TYPED_REFUSAL_PREFIX``) plus + lex-ordered boundary ids. Audit consumers detect refusals by prefix, + not by NLP. +* **Safety-only.** Ethics violations are observational at v1 + (ADR-0035) and do not trigger refusal. Refusing on swappable + deployment commitments would let pack-swappers silently change + refusal behavior — the wrong coupling. + +See `docs/decisions/ADR-0036-safety-refusal-policy.md`. +""" + +from __future__ import annotations + +from typing import Iterable + +TYPED_REFUSAL_PREFIX = "I cannot proceed — safety boundary violated: " + + +def violated_runtime_checkable(verdict) -> tuple[str, ...]: + """Return the lex-sorted tuple of runtime-checkable violated boundary ids. + + A boundary is reported as violated only when the predicate had + enough evidence to make a real claim (``runtime_checkable=True``) + AND determined the turn breached the boundary (``upheld=False``). + Predicates that report ``runtime_checkable=False`` are honest + about lack of evidence and never trigger refusal. + """ + if verdict is None: + return () + boundary_ids: list[str] = [] + for result in getattr(verdict, "results", ()) or (): + if getattr(result, "runtime_checkable", False) and not getattr( + result, "upheld", True + ): + boundary_ids.append(str(result.boundary_id)) + return tuple(sorted(boundary_ids)) + + +def build_refusal_surface(verdict) -> str | None: + """Build a deterministic typed refusal surface, or ``None`` if no refusal. + + The contract: + + * Returns ``None`` when no runtime-checkable safety violation is + present. The caller keeps the originally-articulated surface. + * Returns ``TYPED_REFUSAL_PREFIX + ", ".join(lex_sorted_ids)`` when + one or more runtime-checkable boundaries were violated. + + The same verdict always produces the same string. + """ + violated = violated_runtime_checkable(verdict) + if not violated: + return None + return _format_refusal(violated) + + +def _format_refusal(boundary_ids: Iterable[str]) -> str: + return TYPED_REFUSAL_PREFIX + ", ".join(boundary_ids) + + +def is_typed_refusal(surface: str) -> bool: + """Audit helper: does this surface look like a typed refusal?""" + return bool(surface) and surface.startswith(TYPED_REFUSAL_PREFIX) diff --git a/chat/runtime.py b/chat/runtime.py index 290b3217..7a21e2ad 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -10,6 +10,7 @@ from typing import List import numpy as np from algebra.versor import versor_condition +from chat.refusal import build_refusal_surface from core.config import DEFAULT_CONFIG, DEFAULT_IDENTITY_PACK, RuntimeConfig from core.physics.drive import DriveGradientMap, GradientField from core.physics.energy import EnergyProfile @@ -497,8 +498,18 @@ class ChatRuntime: disclosure_emitted=True, ) ethics_verdict = self.ethics_check.check(ethics_ctx, self.ethics_pack) + # ADR-0036 — typed refusal also applies on the stub path. When + # a runtime-checkable safety boundary is violated even on the + # ungrounded surface (e.g. versor-closure failure), replace the + # user-facing ``surface`` with the deterministic typed refusal. + refusal_surface = build_refusal_surface(safety_verdict) + if refusal_surface is not None: + response_surface = refusal_surface + self._last_refusal_was_typed = True + else: + response_surface = _UNKNOWN_DOMAIN_SURFACE return ChatResponse( - surface=_UNKNOWN_DOMAIN_SURFACE, + surface=response_surface, proposition=prop, articulation=art, articulation_surface=_UNKNOWN_DOMAIN_SURFACE, @@ -670,10 +681,22 @@ class ChatRuntime: disclosure_emitted=not is_grounded, ) ethics_verdict = self.ethics_check.check(ethics_ctx, self.ethics_pack) + # ADR-0036 — safety-only typed refusal. A runtime-checkable + # SafetyVerdict violation replaces the user-facing ``surface`` + # with a deterministic typed refusal string. ``walk_surface`` + # and ``articulation_surface`` retain the original token-walk / + # realizer evidence for audit (per the runtime surface + # contract in CLAUDE.md). Ethics violations remain audit-only. + refusal_surface = build_refusal_surface(safety_verdict) + if refusal_surface is not None: + response_surface = refusal_surface + self._last_refusal_was_typed = True + else: + response_surface = walk_surface turn_event = TurnEvent( turn=self._context.turn - 1, input_tokens=tuple(filtered), - surface=surface, + surface=response_surface, walk_surface=walk_surface, articulation_surface=articulation.surface, dialogue_role=str(dialogue_role), @@ -688,7 +711,7 @@ class ChatRuntime: ) self.turn_log.append(turn_event) return ChatResponse( - surface=walk_surface, + surface=response_surface, proposition=proposition, articulation=articulation, articulation_surface=articulation.surface, diff --git a/docs/decisions/ADR-0036-safety-refusal-policy.md b/docs/decisions/ADR-0036-safety-refusal-policy.md new file mode 100644 index 00000000..299c4819 --- /dev/null +++ b/docs/decisions/ADR-0036-safety-refusal-policy.md @@ -0,0 +1,177 @@ +# 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. diff --git a/tests/test_safety_refusal.py b/tests/test_safety_refusal.py new file mode 100644 index 00000000..30818686 --- /dev/null +++ b/tests/test_safety_refusal.py @@ -0,0 +1,257 @@ +"""ADR-0036 — typed refusal on runtime-checkable safety violation. + +The refusal surface: + +* replaces ``ChatResponse.surface`` with a deterministic typed string, +* leaves ``walk_surface`` and ``articulation_surface`` intact (audit), +* fires only on safety violations (ethics is observational at v1), +* fires only on ``runtime_checkable=True`` results + (no-evidence predicates never refuse), +* sets ``runtime._last_refusal_was_typed = True`` so the next turn's + ``no_silent_correction`` predicate has live evidence. +""" + +from __future__ import annotations + +from chat.refusal import ( + TYPED_REFUSAL_PREFIX, + build_refusal_surface, + is_typed_refusal, + violated_runtime_checkable, +) +from chat.runtime import ChatRuntime +from core.config import RuntimeConfig +from packs.safety.check import SafetyCheckResult, SafetyVerdict + + +# ---------- pure-function refusal builder ---------- + + +class TestBuildRefusalSurface: + def test_none_verdict_returns_none(self) -> None: + assert build_refusal_surface(None) is None + + def test_all_upheld_returns_none(self) -> None: + verdict = _verdict( + _result("preserve_versor_closure", upheld=True, runtime_checkable=True), + _result("no_silent_correction", upheld=True, runtime_checkable=True), + ) + assert build_refusal_surface(verdict) is None + + def test_non_runtime_checkable_violation_does_not_refuse(self) -> None: + """A predicate that lacks evidence (runtime_checkable=False) and + reports upheld=False must NOT trigger refusal — by design, + no-evidence predicates default to ``upheld=True`` in the + check loop, but even if a deployment forces ``upheld=False`` + without evidence, refusal should not fire.""" + verdict = _verdict( + _result("no_fabricated_source", upheld=False, runtime_checkable=False), + ) + assert build_refusal_surface(verdict) is None + + def test_runtime_checkable_violation_returns_typed_surface(self) -> None: + verdict = _verdict( + _result("preserve_versor_closure", upheld=False, runtime_checkable=True), + ) + out = build_refusal_surface(verdict) + assert out is not None + assert out.startswith(TYPED_REFUSAL_PREFIX) + assert "preserve_versor_closure" in out + + def test_refusal_is_deterministic(self) -> None: + verdict = _verdict( + _result("preserve_versor_closure", upheld=False, runtime_checkable=True), + _result("no_silent_correction", upheld=False, runtime_checkable=True), + ) + first = build_refusal_surface(verdict) + second = build_refusal_surface(verdict) + assert first == second + + def test_refusal_lists_boundaries_in_lex_order(self) -> None: + verdict = _verdict( + _result("zzz_late", upheld=False, runtime_checkable=True), + _result("aaa_early", upheld=False, runtime_checkable=True), + ) + out = build_refusal_surface(verdict) + assert out is not None + assert out.index("aaa_early") < out.index("zzz_late") + + +class TestViolatedRuntimeCheckable: + def test_filters_non_checkable(self) -> None: + verdict = _verdict( + _result("a", upheld=False, runtime_checkable=False), + _result("b", upheld=False, runtime_checkable=True), + ) + assert violated_runtime_checkable(verdict) == ("b",) + + def test_filters_upheld(self) -> None: + verdict = _verdict( + _result("a", upheld=True, runtime_checkable=True), + _result("b", upheld=False, runtime_checkable=True), + ) + assert violated_runtime_checkable(verdict) == ("b",) + + def test_empty_verdict_returns_empty(self) -> None: + verdict = _verdict() + assert violated_runtime_checkable(verdict) == () + + +class TestIsTypedRefusal: + def test_typed_refusal_detected(self) -> None: + surface = TYPED_REFUSAL_PREFIX + "preserve_versor_closure" + assert is_typed_refusal(surface) + + def test_ordinary_surface_not_a_refusal(self) -> None: + assert not is_typed_refusal("light is") + assert not is_typed_refusal("") + + +# ---------- ChatRuntime integration ---------- + + +class TestRuntimeRefusalMainPath: + def test_no_violation_returns_ordinary_surface(self) -> None: + rt = ChatRuntime(config=RuntimeConfig()) + resp = rt.chat("light is") + # No predicate forced to fail, so surface should NOT be a + # typed refusal. (Stub path may produce the unknown-domain + # surface; both are non-refusal.) + assert not is_typed_refusal(resp.surface) + + def test_forced_violation_emits_typed_refusal(self) -> None: + rt = ChatRuntime(config=RuntimeConfig()) + _force_violation(rt, "preserve_versor_closure") + resp = rt.chat("light is") + assert is_typed_refusal(resp.surface) + assert "preserve_versor_closure" in resp.surface + + def test_refusal_preserves_walk_surface(self) -> None: + """walk_surface retains the original token-walk evidence even + when the user-facing surface is replaced by a refusal.""" + rt = ChatRuntime(config=RuntimeConfig()) + _force_violation(rt, "preserve_versor_closure") + resp = rt.chat("light is") + if not is_typed_refusal(resp.surface): + return # stub path; tested separately + assert not is_typed_refusal(resp.walk_surface) + + def test_refusal_preserves_articulation_surface(self) -> None: + rt = ChatRuntime(config=RuntimeConfig()) + _force_violation(rt, "preserve_versor_closure") + resp = rt.chat("light is") + if not is_typed_refusal(resp.surface): + return + assert not is_typed_refusal(resp.articulation_surface) + + def test_refusal_still_attaches_verdicts(self) -> None: + rt = ChatRuntime(config=RuntimeConfig()) + _force_violation(rt, "preserve_versor_closure") + resp = rt.chat("light is") + assert resp.safety_verdict is not None + assert resp.ethics_verdict is not None + + def test_refusal_sets_last_refusal_was_typed(self) -> None: + rt = ChatRuntime(config=RuntimeConfig()) + # Sanity: default is True before any refusal. + assert rt._last_refusal_was_typed is True + _force_violation(rt, "preserve_versor_closure") + rt.chat("light is") + # After a typed refusal, the flag remains True so the + # ``no_silent_correction`` predicate has live evidence next turn. + assert rt._last_refusal_was_typed is True + + def test_turn_event_surface_is_refusal_when_refused(self) -> None: + rt = ChatRuntime(config=RuntimeConfig()) + _force_violation(rt, "preserve_versor_closure") + rt.chat("light is") + if not rt.turn_log: + return # stub path; bypasses turn_log + event = rt.turn_log[-1] + assert is_typed_refusal(event.surface) + # And walk_surface is retained on the event for audit. + assert not is_typed_refusal(event.walk_surface) + + +# ---------- Ethics violations must NOT trigger refusal ---------- + + +class TestEthicsViolationDoesNotRefuse: + def test_ethics_violation_audit_only(self) -> None: + """Ethics is observational at v1. Even a runtime-checkable + ethics violation must NOT produce a typed refusal surface — + the audit verdict still attaches but the user-facing surface + is unchanged.""" + rt = ChatRuntime(config=RuntimeConfig()) + # Force an ethics predicate to fail with runtime_checkable=True. + from packs.ethics.check import EthicsCheckResult + + def _failing_ethics(ctx): # noqa: ANN001 — predicate signature + return EthicsCheckResult( + commitment_id="acknowledge_uncertainty", + upheld=False, + reason="forced for test", + runtime_checkable=True, + ) + + rt.ethics_check.register("acknowledge_uncertainty", _failing_ethics) + resp = rt.chat("light is") + assert not is_typed_refusal(resp.surface) + + +# ---------- Stub path refusal ---------- + + +class TestStubPathRefusal: + def test_stub_path_refusal_replaces_unknown_domain_surface(self) -> None: + """When the stub path triggers AND safety violation fires, the + typed refusal replaces the unknown-domain surface.""" + rt = ChatRuntime(config=RuntimeConfig()) + _force_violation(rt, "preserve_versor_closure") + # ``light is`` may or may not hit the stub path depending on + # vault state; regardless, if a violation is forced, the + # response surface must be a typed refusal. + resp = rt.chat("light is") + assert is_typed_refusal(resp.surface) + + +# ---------- helpers ---------- + + +def _result( + boundary_id: str, + *, + upheld: bool, + runtime_checkable: bool, +) -> SafetyCheckResult: + return SafetyCheckResult( + boundary_id=boundary_id, + upheld=upheld, + reason="test", + runtime_checkable=runtime_checkable, + ) + + +def _verdict(*results: SafetyCheckResult) -> SafetyVerdict: + violated = frozenset(r.boundary_id for r in results if not r.upheld) + return SafetyVerdict( + pack_id="test_pack", + results=tuple(results), + upheld=not violated, + violated_boundaries=violated, + runtime_checkable_count=sum(1 for r in results if r.runtime_checkable), + ) + + +def _force_violation(rt: ChatRuntime, boundary_id: str) -> None: + """Register a synthetic predicate that always fails for ``boundary_id``.""" + + def _failing(ctx): # noqa: ANN001 — predicate signature + return SafetyCheckResult( + boundary_id=boundary_id, + upheld=False, + reason="forced for test", + runtime_checkable=True, + ) + + rt.safety_check.register(boundary_id, _failing)