core/chat/refusal.py
Shay a0372c951f feat(adr-0036): safety-only typed refusal policy
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)
2026-05-17 21:10:52 -07:00

71 lines
2.6 KiB
Python

"""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)