feat(sensorium): fail-closed efferent gate for actuating decode (ADR-0198 §3)

DefaultEfferentGate is a capability/shape pre-filter only; it does not
lower decoded actions into safety/ethics pack verdicts (ADR-0198 §3 /
§1.2 Gap B). ModalityRegistry.decode/decode_batch now refuse fail-closed
any emission through a gate whose enforces_action_verdicts is False,
unless an explicit allow_unverified_efferent sandbox opt-in is set.

A real motor decoder thus cannot emit through the capability-only gate;
the §3 verdict-lowering gate must be built and installed first. No
production caller of the decode path exists today, so this closes the
latent hazard before a motor decoder makes it load-bearing. Adds two
falsifiable tests (fail-closed refusal; verdict-enforcing gate allowed).
Disjoint from the GSM8K serving path.
This commit is contained in:
Shay 2026-06-03 22:03:35 -07:00
parent e5bc73ebb9
commit b4fb9df8e7
6 changed files with 100 additions and 8 deletions

View file

@ -15,7 +15,7 @@
**Deferred — blocking obligations before any motor decoder mounts:**
1. **§3 verdict-lowering is NOT implemented.** `DefaultEfferentGate` admits on capability-token + `(32,)` vector-shape only; it does **not** lower the decoded motor versor into the safety/ethics pack verdicts of ADR-0029/0033/0036/0037. A valid capability token alone currently passes the gate. This is admissible **only because no real motor decoder exists** — the sole `SurfaceDecoder` in the tree is a test fixture that returns the string `"decoded"`. The §1.2 Gap B guarantee ("passes safety/ethics verdicts before it leaves the boundary") is therefore *partially* realized. Before any pack mounts a decoder that actuates, the gate MUST be extended to the verdict-lowering path; this obligation is load-bearing and must not be silently skipped.
1. **§3 verdict-lowering is NOT implemented — but is now enforced fail-closed.** `DefaultEfferentGate` admits on capability-token + `(32,)` vector-shape only (`enforces_action_verdicts` is `False`); it does **not** lower the decoded motor versor into the safety/ethics pack verdicts of ADR-0029/0033/0036/0037. To keep §1.2 Gap B from silently degrading, `ModalityRegistry.decode`/`decode_batch` now **refuse fail-closed** any emission through a gate whose `enforces_action_verdicts` is False, unless an explicit `allow_unverified_efferent=True` sandbox opt-in is set (tests only). A real motor decoder therefore *cannot* emit through the capability-only gate — the §3 verdict-lowering gate must be built and installed first. Proven by `tests/test_efferent_gate.py::test_registry_fails_closed_for_actuating_decode_through_capability_only_gate` (the decoder never runs) and `::test_registry_admits_decode_through_verdict_enforcing_gate`. Implementing the §3 lowering itself remains deferred behind the dedicated motor governance ADR (item 3).
2. **The motor compiler/decoder itself remains out of scope** (per §5).
3. **A dedicated motor governance ADR** ratifying the §3 lowering against ADR-0029/0033/0036/0037 remains a prerequisite (per §5).

View file

@ -53,6 +53,15 @@ class DefaultEfferentGate:
"shape": [CL41_DIM],
})
@property
def enforces_action_verdicts(self) -> bool:
"""Capability/shape pre-filter only — does NOT lower the decoded action
into the safety/ethics pack verdicts required by ADR-0198 §3. The
registry refuses actuating emission through this gate unless an explicit
sandbox opt-in is set. A future §3 verdict-enforcing gate returns True.
"""
return False
def admit(
self,
pack_id: str,

View file

@ -107,7 +107,14 @@ class EfferentVerdict:
@runtime_checkable
class EfferentGate(Protocol):
"""Runtime gate for output actions. Runs before SurfaceDecoder.decode."""
"""Runtime gate for output actions. Runs before SurfaceDecoder.decode.
Optional attribute ``enforces_action_verdicts: bool`` (absent False):
True only for a gate that lowers the decoded action into the safety/ethics
pack verdicts required by ADR-0198 §3. ``ModalityRegistry`` fails closed and
refuses actuating emission through any gate where this is False, unless an
explicit ``allow_unverified_efferent`` sandbox opt-in is set.
"""
def admit(
self,

View file

@ -36,9 +36,19 @@ class ModalityRegistry:
mv = registry.project("en", "beginning")
"""
def __init__(self, *, efferent_gate: EfferentGate | None = None) -> None:
def __init__(
self,
*,
efferent_gate: EfferentGate | None = None,
allow_unverified_efferent: bool = False,
) -> None:
self._packs: dict[str, ModalityPack] = {}
self._efferent_gate = efferent_gate
# Fail-closed (ADR-0198 §3 / §1.2 Gap B): an efferent gate that does not
# lower the decoded action into safety/ethics pack verdicts may not
# authorize a real emission. This opt-in exercises the capability
# pre-filter in isolation (tests only); never set it on an actuating path.
self._allow_unverified_efferent = allow_unverified_efferent
# ------------------------------------------------------------------
# Mount
@ -123,6 +133,28 @@ class ModalityRegistry:
)
return mvs.astype(np.float32)
def _refuse_unverified_efferent(self, pack_id: str, authority: AuthorityToken) -> None:
"""Fail closed unless the gate enforces ADR-0198 §3 action verdicts.
``DefaultEfferentGate`` is a capability/shape pre-filter only; it does
not lower the decoded action into the safety/ethics pack verdicts that
§3 requires. Until a verdict-enforcing gate exists, an actuating
emission must be refused no motor decoder may rely on capability
tokens alone. ``allow_unverified_efferent`` is a sandbox/testing escape
hatch for exercising the pre-filter in isolation.
"""
if self._allow_unverified_efferent:
return
if getattr(self._efferent_gate, "enforces_action_verdicts", False):
return
verdict = EfferentVerdict(
admitted=False,
reason="efferent gate does not enforce ADR-0198 §3 action verdicts",
authority_sha256=authority.authority_sha256,
policy_sha256="deny-unverified-efferent",
)
raise EfferentRefusal(pack_id, verdict)
def decode(self, pack_id: str, mv: np.ndarray, *, authority: AuthorityToken) -> Any:
"""Decode a manifold vector through a governed SurfaceDecoder.
@ -147,6 +179,7 @@ class ModalityRegistry:
policy_sha256="deny-by-default",
)
raise EfferentRefusal(pack_id, verdict)
self._refuse_unverified_efferent(pack_id, authority)
verdict = self._efferent_gate.admit(pack_id, vec, authority)
if not verdict.admitted:
raise EfferentRefusal(pack_id, verdict)
@ -179,6 +212,7 @@ class ModalityRegistry:
policy_sha256="deny-by-default",
)
raise EfferentRefusal(pack_id, verdict)
self._refuse_unverified_efferent(pack_id, authority)
for vec in arr:
verdict = self._efferent_gate.admit(pack_id, vec, authority)
if not verdict.admitted:

View file

@ -4,7 +4,14 @@ import numpy as np
import pytest
from sensorium.efferent import DefaultEfferentGate
from sensorium.protocol import AuthorityToken, EfferentRefusal, Modality, ModalityPack, ModalityVocabulary
from sensorium.protocol import (
AuthorityToken,
EfferentRefusal,
EfferentVerdict,
Modality,
ModalityPack,
ModalityVocabulary,
)
from sensorium.registry import ModalityRegistry
@ -81,7 +88,9 @@ def test_default_efferent_trace_is_hash_only():
def test_registry_uses_default_efferent_gate_before_decoder():
decoder = _Decoder()
reg = ModalityRegistry(efferent_gate=DefaultEfferentGate())
# Exercises the capability pre-filter in isolation: the capability/shape
# gate is not verdict-enforcing, so the sandbox opt-in is required here.
reg = ModalityRegistry(efferent_gate=DefaultEfferentGate(), allow_unverified_efferent=True)
reg.mount(_pack(decoder))
with pytest.raises(EfferentRefusal, match="missing capability"):
@ -90,3 +99,36 @@ def test_registry_uses_default_efferent_gate_before_decoder():
assert reg.decode("motor_test", _mv(), authority=_authority("decode:motor_test")) == "decoded"
assert decoder.calls == 1
def test_registry_fails_closed_for_actuating_decode_through_capability_only_gate():
"""ADR-0198 §3 / §1.2 Gap B: a capability/shape gate must not authorize a
real emission. With no sandbox opt-in the decode fails closed and the
decoder never runs."""
decoder = _Decoder()
reg = ModalityRegistry(efferent_gate=DefaultEfferentGate())
reg.mount(_pack(decoder))
with pytest.raises(EfferentRefusal, match="action verdicts"):
reg.decode("motor_test", _mv(), authority=_authority("decode:motor_test"))
assert decoder.calls == 0
def test_registry_admits_decode_through_verdict_enforcing_gate():
"""A gate that enforces ADR-0198 §3 action verdicts needs no sandbox opt-in."""
class _VerdictGate:
enforces_action_verdicts = True
def admit(self, pack_id: str, mv: np.ndarray, authority: AuthorityToken) -> EfferentVerdict:
return EfferentVerdict(
admitted=True,
reason="admitted",
authority_sha256=authority.authority_sha256,
policy_sha256="verdict-test",
)
decoder = _Decoder()
reg = ModalityRegistry(efferent_gate=_VerdictGate())
reg.mount(_pack(decoder))
assert reg.decode("motor_test", _mv(), authority=_authority("decode:motor_test")) == "decoded"
assert decoder.calls == 1

View file

@ -80,7 +80,7 @@ def test_decode_denies_by_default_before_decoder_runs():
def test_decode_requires_admitting_gate_before_surface_decode():
decoder = _Decoder()
gate = _Gate(admitted=True)
reg = ModalityRegistry(efferent_gate=gate)
reg = ModalityRegistry(efferent_gate=gate, allow_unverified_efferent=True)
reg.mount(_pack(decoder))
assert reg.decode("motor_test", _mv(), authority=_authority()) == "decoded:1.0"
assert gate.calls == 1
@ -89,7 +89,7 @@ def test_decode_requires_admitting_gate_before_surface_decode():
def test_decode_refusal_does_not_call_decoder():
decoder = _Decoder()
reg = ModalityRegistry(efferent_gate=_Gate(admitted=False))
reg = ModalityRegistry(efferent_gate=_Gate(admitted=False), allow_unverified_efferent=True)
reg.mount(_pack(decoder))
with pytest.raises(EfferentRefusal, match="denied"):
reg.decode("motor_test", _mv(), authority=_authority())
@ -99,7 +99,7 @@ def test_decode_refusal_does_not_call_decoder():
def test_decode_batch_admits_all_before_decoding_any_surface():
decoder = _Decoder()
gate = _Gate(admitted=True, deny_after=1)
reg = ModalityRegistry(efferent_gate=gate)
reg = ModalityRegistry(efferent_gate=gate, allow_unverified_efferent=True)
reg.mount(_pack(decoder))
batch = np.stack([_mv(), _mv()])
with pytest.raises(EfferentRefusal, match="denied"):