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)
71 lines
2.6 KiB
Python
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)
|