Closes the 'boundaries are checked at scattered call sites' gap noted
in ADR-0029. Adds a centralized observational surface parallel in
shape to IdentityCheck — produces a verdict, does not refuse. Wiring
verdicts into refusal paths is a future ADR.
Shape (parallel to IdentityCheck, different in mechanism):
SafetyContext — duck-typed input bag (field_state, citations,
refusal-was-typed flag, identity manifold hashes
before/after). Every field optional with safe
defaults; absence of evidence is not evidence of
violation.
SafetyCheckResult — per-boundary: boundary_id, upheld, reason,
runtime_checkable, evidence tuple.
SafetyVerdict — aggregate: pack_id, results (lex order on
boundary_id), upheld, violated_boundaries,
runtime_checkable_count.
SafetyCheck — registry of predicates; check(ctx, pack) returns
SafetyVerdict. register(boundary_id, predicate)
adds custom predicates.
Five default predicates for v1 boundaries:
preserve_versor_closure runtime_checkable=True field.versor_condition < 1e-6
no_fabricated_source runtime_checkable=True* cited ⊆ allowed
no_silent_correction runtime_checkable=True last refusal was typed
no_identity_override runtime_checkable=True* hash before == hash after
no_hot_path_repair runtime_checkable=FALSE code-path; static-analysis
*Conditional on the caller supplying the necessary fields.
The honest answer on no_hot_path_repair: it is a code-path boundary
enforced by static analysis + code review. Runtime cannot judge it.
A predicate that silently reported upheld=True would be a small lie —
exactly the kind of thing CLAUDE.md forbids. SafetyCheck reports
runtime_checkable=False with a clear reason so auditors see the truth.
ChatRuntime integration:
ChatRuntime.__init__ now constructs self.safety_check = SafetyCheck()
alongside self._identity_check. Turn loop does NOT auto-invoke at
v1 — operators and future ADRs decide when/where to call it.
Files:
packs/safety/check.py new — SafetyCheck + value types +
default predicates
packs/safety/__init__.py re-exports the new public surface
chat/runtime.py constructs self.safety_check
tests/test_safety_check.py new — 20 tests covering each
default predicate (positive +
negative), unknown-boundary
fallback, custom registration,
defensive boundary-id rebinding,
verdict aggregation, ChatRuntime
integration
docs/decisions/ADR-0032-safety-check-surface.md Accepted
docs/safety_packs.md §SafetyCheck section added,
known-limit #1 struck through
memory/safety-pack.md refreshed; new follow-up about
turn-loop auto-invocation
Suite status (all green):
cognition 121, teaching 17, runtime 19, formation 182, smoke 67
identity / safety / surface divergence suites: 108 tests passing
(was 88 before this ADR; +20 safety-check tests)
Scope limits (documented):
- No auto-invocation in the turn loop.
- No refusal wiring on violation.
- No refactoring of existing scattered enforcement sites.
- Defensive boundary-id rebinding masks predicate bugs; debug-mode
surfacing is a future enhancement.
39 lines
982 B
Python
39 lines
982 B
Python
"""Safety-pack loader.
|
|
|
|
Reads the single shipping safety pack and returns its boundary set for
|
|
composition into the runtime ``IdentityManifold``. Safety packs are
|
|
**not** swappable: there is exactly one safety pack per installation,
|
|
loaded unconditionally.
|
|
|
|
The loader fails closed. Missing file, malformed JSON, empty
|
|
``boundary_ids``, or — in production mode — unverified self-seal all
|
|
cause ``SafetyPackError`` and prevent ``ChatRuntime`` startup.
|
|
|
|
See ``docs/decisions/ADR-0029-safety-packs.md``.
|
|
"""
|
|
|
|
from packs.safety.check import (
|
|
SafetyCheck,
|
|
SafetyCheckResult,
|
|
SafetyContext,
|
|
SafetyPredicate,
|
|
SafetyVerdict,
|
|
)
|
|
from packs.safety.loader import (
|
|
DEFAULT_SAFETY_PACK,
|
|
SafetyPack,
|
|
SafetyPackError,
|
|
load_safety_pack,
|
|
)
|
|
|
|
__all__ = [
|
|
"DEFAULT_SAFETY_PACK",
|
|
"SafetyCheck",
|
|
"SafetyCheckResult",
|
|
"SafetyContext",
|
|
"SafetyPack",
|
|
"SafetyPackError",
|
|
"SafetyPredicate",
|
|
"SafetyVerdict",
|
|
"load_safety_pack",
|
|
]
|