core/docs/decisions/ADR-0198-motor-efferent-decoder-spike.md
Shay b4fb9df8e7 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.
2026-06-03 22:03:35 -07:00

10 KiB

ADR-0198: Motor as Efferent Modality — Protocol Gap & Governance (Design Spike)

Status: Accepted (design spike) — Gap A protocol change + a baseline efferent gate have landed; the §3 verdict-lowering and the motor compiler/decoder remain deferred. See Implementation Status below. Date: 2026-05-31 Authors: Joshua M. Shay, Core R&D Engine Domains: sensorium/protocol.py, sensorium/registry.py, packs/motor/ (future), governance packs (ADR-0029/0033/0036/0037) Depends on: ADR-0013 (Sensorium Multimodal Protocol), ADR-0017 (Agency Scope), ADR-0018 (Tool-Use Scope) Relationship to vision: intentionally runs in parallel with vision_core_v1 implementation (ADR-0197 / vision-compiler-spec) because it may force a sensorium/ protocol change, and that change is cheaper to land before vision concrete sets.


Implementation Status (reconciled 2026-06-04)

Gap A has landed. ModalityRegistry.decode(pack_id, mv, *, authority) with a required, never-optional AuthorityToken, plus a baseline DefaultEfferentGate (sensorium/efferent.py, PR #541). The §1.2 Gap B ordering is proven: tests/test_efferent_gate.py::test_registry_uses_default_efferent_gate_before_decoder asserts decoder.calls == 0 on refusal — the gate refuses in the manifold, before any command is formed. Traces are hash-only (no mv, no ndarray/bytes).

Deferred — blocking obligations before any motor decoder mounts:

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

1. Why motor is not "just another modality"

Every modality landed so far is afferent: signal comes in, crosses the ProjectionHead boundary, becomes a (32,) versor. Motor is efferent: meaning leaves the manifold and becomes action in the world. The protocol already anticipates this asymmetry — sensorium/protocol.py defines:

@runtime_checkable
class SurfaceDecoder(Protocol[S]):
    modality: Modality
    def decode(self, mv: np.ndarray) -> S: ...
    def decode_batch(self, mvs: np.ndarray) -> list[S]: ...

and ModalityPack already carries a decoder: SurfaceDecoder | None slot. So the type-level substrate is ready. But the spike's finding is that the runtime substrate is not, in two concrete ways.

1.1 Gap A — the registry has no efferent path

ModalityRegistry (sensorium/registry.py) implements mount, get, project, project_batch. There is no decode / decode_batch. project() refuses a closed gate and validates output shape; nothing analogous exists for emission. A motor pack could be mounted today, but there is no governed call site through which a (32,) versor could become an action. Adding one is a sensorium/ protocol change — the thing this spike exists to surface before vision implementation locks the layer.

1.2 Gap B — the input mount-gate does not transfer to output

For afferent modalities the mount-time gate is verify_unitarity — a property of the projection's construction (V·reverse(V) = ±1). It is correctly run once, at mount, never in the hot path.

An efferent gate cannot be mount-time only. Whether an action is admissible depends on the content of the decoded command, not on whether the decoder was built correctly. A decoder can be perfectly unitary-inverse and still emit an action that must be refused. Therefore motor needs two gates, not one:

Gate When Checks Analog
Decoder validity mount-time decoder is well-formed; round-trips within tolerance on a probe (project(decode(mv)) ≈ mv) verify_unitarity
Efferent admissibility runtime, per-decode the decoded action is within authority + passes safety/ethics verdicts before it leaves the boundary (new — no afferent analog)

Gap B is the load-bearing governance finding: output gating is per-action, afferent gating is per-mount. Any design that reuses verify_unitarity's shape for motor is wrong.

2. Proposed protocol change (minimal, additive)

Add an efferent path to ModalityRegistry that mirrors project()'s refusal discipline and adds the runtime admissibility gate:

def decode(self, pack_id: str, mv: np.ndarray, *, authority: AuthorityToken) -> Any:
    pack = self.get(pack_id)
    if not pack.gate_engaged:
        raise RuntimeError(f"Pack '{pack_id}' gate is not engaged.")
    if pack.decoder is None:
        raise RuntimeError(f"Pack '{pack_id}' has no SurfaceDecoder.")
    # NEW: runtime efferent admissibility — BEFORE any surface action is produced.
    verdict = self._efferent_gate.admit(pack_id, mv, authority)   # §3
    if not verdict.admitted:
        raise EfferentRefusal(pack_id, verdict)                    # fail closed
    return pack.decoder.decode(mv)

Notes:

  • gate_engaged / decoder is None refusals mirror project() exactly — no new gating machinery there, just the symmetric method.
  • authority: AuthorityToken is required, never optional — there is no unauthenticated emission path. This is the wiring point for ADR-0017 (agency scope) and ADR-0018 (tool-use scope).
  • The admissibility check runs before decoder.decode — the boundary refuses in the manifold, not after a command has been formed.

3. The efferent gate reuses existing governance — it is not a new bespoke checker

The repo already has the verdict machinery this needs; the spike's recommendation is to route decoded-action admissibility through it, not invent a parallel system:

  • ADR-0029 (safety packs) / ADR-0036 (safety-refusal policy) — the decoded action is checked against safety packs; refusal is the existing fail-closed path.
  • ADR-0033 (ethics packs) / ADR-0037 (per-predicate ethics refusal) — per-predicate ethics verdicts apply to the action predicate, not to text.
  • ADR-0017 / ADR-0018 — authority and tool-use scope bound what the AuthorityToken may authorize.

_efferent_gate.admit() is therefore a thin adapter that lowers a (32,) motor versor into the action predicate(s) those packs already evaluate, collects their verdicts, and fails closed on any refusal or on absent authority. The novel surface is the lowering + fail-closed composition, not the ethics logic.

4. Open questions to red-line (this is a spike — these are the decisions, not conclusions)

  1. S for motor. Is the surface type a single action command, a bounded trajectory, or a parameterized skill invocation? This determines whether decode is one-shot or streaming, and whether a proprioceptive feedback loop is in-scope for v1.
  2. Feedback is afferent. Proprioception/result-of-action re-enters as a sensor modality. Decision: is the loop composed at a higher layer (motor-decode + sensor-encode) or internal to the motor pack? Recommendation: composed at a higher layer, to keep SurfaceDecoder one-directional and the protocol symmetric.
  3. AuthorityToken shape & lifetime. Per-call, per-session, or capability-scoped? How does it bind to ADR-0017/0018 scopes?
  4. Decoder-validity round-trip tolerance. What is the mount-time project(decode(mv)) ≈ mv tolerance, and is round-trip even required for actions that are intentionally lossy?
  5. Control rate. Does v1 emit at a fixed control rate (and therefore need a clock/barrier story like the compilers), or is it event-driven one-shot? Recommendation: event-driven one-shot in v1; rate-controlled emission deferred.
  6. Does the protocol change touch the CRDT substrate at all? Efferent actions are emitted, not stored as afferent deltas — but their occurrence is trace evidence. Decision: motor writes a TurnEvent emission record (hashes + authority + verdict), never a Vault delta. Confirm this keeps ADR-0180 untouched.

5. Sequencing recommendation

  • This spike's Gap A protocol change (the decode() method + AuthorityToken) should be reviewed before vision_core_v1 PR-2, because it modifies sensorium/registry.py / sensorium/protocol.py — the layer vision builds against. If accepted, vision implements against the post-change protocol and avoids a later migration.
  • The motor compiler/decoder itself is explicitly out of scope here and must not begin until: (a) this protocol change is merged, and (b) a dedicated motor governance ADR ratifies the §3 efferent-gate lowering against ADR-0029/0033/0036/0037. Per the project's order, governance precedes the efferent compiler — not because of politeness, but because §1.2 Gap B means there is no gate to engage until it exists.

6. Cross-References

  • ADR-0013 — projection boundary; SurfaceDecoder / ModalityPack.decoder slot this spike activates.
  • ADR-0017, ADR-0018 — agency and tool-use scope; the authority model decode() requires.
  • ADR-0029, ADR-0033, ADR-0036, ADR-0037 — safety/ethics pack verdicts the efferent gate reuses.
  • ADR-0197 — vision compiler; runs in parallel and consumes the protocol change this spike proposes.
  • sensorium/protocol.py, sensorium/registry.py — the contracts amended in §2.