chore(sensorium): ratify environment-falsification roadmap — witness-importer hardening + ADR-0198 reconciliation

Review/ratification pass over the 6-phase afferent environment-falsification
roadmap (rebased onto D-inclusive main). The roadmap validated green: smoke (90),
architectural invariants (56), the full sensorium suite, all phase tests, and the
four eval lanes (environment-falsification / event-vision / vision / sensorimotor)
report failed:0. A five-lens adversarial review found the efferent gate
fail-closed (default-deny, conjunctive safety∧ethics∧tool_scope, refuses before
the decoder, hash-only traces, no real decoder, no overclaim), the afferent
Phases 1-5 read-only (runtime import-guards + frozen dataclasses), and the
expected-artifact obligations deterministic + meaningfully-failing. Two
reconciliations from that review:

1. Witness-log importer trust boundary (sensorium/logs/importer.py). The offline
   JSONL importer now rejects malformed input with a deterministic ValueError
   instead of a raw JSONDecodeError/KeyError, names the 1-based line on a bad
   JSON line, rejects non-object records and non-integer ticks, and size-caps the
   file (8 MiB) so an oversized log is refused rather than read whole. The trust
   boundary is now stated in the module + function docstrings. Behavior for VALID
   logs is byte-identical (the environment-falsification lane hashes are
   unchanged). New rejection-path tests added.

2. ADR-0198 reconciliation. The §3 verdict-enforcing GATE mechanism
   (VerdictEnforcingEfferentGate + MotorActionIntent) has now landed, so the
   Implementation Status is amended to say so — while being explicit that the §3
   lowering itself (deriving verdicts from the ADR-0029/0033/0036/0037 governance
   packs), the motor decoder (every adapter is decoder=None), and ratification by
   the dedicated motor governance ADR (now drafted as ADR-0216, Proposed) all
   remain deferred. No physical motor decode is authorized.

Disjoint from the GSM8K serving path and from Step D (generate/determine|realize,
chat/runtime, core/config) — clean rebase, no overlap.
This commit is contained in:
Shay 2026-06-06 12:54:31 -07:00
parent d67c83b794
commit 7f5958dbd5
3 changed files with 96 additions and 11 deletions

View file

@ -9,15 +9,17 @@
--- ---
## Implementation Status (reconciled 2026-06-04) ## Implementation Status (reconciled 2026-06-04; amended 2026-06-06)
**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). **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).
**2026-06-06 amendment — the §3 verdict-enforcing *gate mechanism* has landed (still fail-closed, still no decoder).** `sensorium/efferent.py` now ships a concrete `VerdictEnforcingEfferentGate` (`enforces_action_verdicts=True`) plus `MotorActionIntent` (a hash-only semantic predicate lowering — *not* an actuator command) and `lower_motor_action_intent`. The gate admits only when authority carries the `decode:<pack_id>` capability AND a present-and-admitted `ActionVerdictRecord` covers each of `(safety, ethics, tool_scope)` for that exact intent hash; empty/missing/failed verdicts refuse before the decoder runs (`tests/test_efferent_gate.py::test_verdict_enforcing_gate_*`). This closes item 1's "the §3 verdict-lowering gate must be built" — the *gate* exists. What remains deferred: (a) the §3 **lowering itself** — the gate validates *caller-supplied* verdict coverage; it does not yet derive those verdicts from the ADR-0029/0033/0036/0037 governance packs; (b) the motor compiler/decoder (every shipped adapter is `decoder=None`); (c) ratification by the dedicated motor governance ADR, now drafted as **[ADR-0216](ADR-0216-motor-verdict-lowering.md)** (Status: Proposed). No physical motor decode is authorized.
**Deferred — blocking obligations before any motor decoder mounts:** **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). 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). 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). 3. **A dedicated motor governance ADR** ratifying the §3 lowering against ADR-0029/0033/0036/0037 remains a prerequisite (per §5). Drafted 2026-06-06 as **[ADR-0216: Motor Verdict Lowering Prerequisite](ADR-0216-motor-verdict-lowering.md)** (Status: Proposed) — it names the lowering pipeline and its Non-Goals (no actuator driver, no robot interface, no trajectory executor, no sandbox opt-in on a physical path) and must be Accepted before any motor decoder mounts.
## 1. Why motor is not "just another modality" ## 1. Why motor is not "just another modality"

View file

@ -3,6 +3,14 @@
Witness logs are evidence transport, not truth. The importer accepts only Witness logs are evidence transport, not truth. The importer accepts only
payload references and uses a caller-provided resolver to obtain already payload references and uses a caller-provided resolver to obtain already
bounded afferent compilation units. bounded afferent compilation units.
Trust boundary: a witness log is UNTRUSTED, operator-supplied OFFLINE input
(never a live device or network handle those fields are rejected). Every
record is validated at this boundary and a malformed/oversized/ill-typed input
is rejected with a deterministic ``ValueError`` (or ``FileNotFoundError`` for a
missing path) BEFORE any frame is built it never partially imports, mutates no
runtime/vault/identity state, opens no decode path, and the file is size-capped
so an oversized log cannot exhaust memory.
""" """
from __future__ import annotations from __future__ import annotations
@ -22,6 +30,17 @@ _RAW_KEYS = {"pixels", "samples", "pcm", "waveform", "raw_bytes", "action_trace"
_LIVE_KEYS = {"device", "device_handle", "socket", "url", "network", "ros_node", "mcap_reader"} _LIVE_KEYS = {"device", "device_handle", "socket", "url", "network", "ros_node", "mcap_reader"}
_ALLOWED_MODALITIES = {"audio", "vision", "event-vision", "sensorimotor"} _ALLOWED_MODALITIES = {"audio", "vision", "event-vision", "sensorimotor"}
#: Hard cap on an offline witness log (generous for a lab session; bounds memory
#: so an oversized/accidental file is rejected deterministically, never read whole).
_MAX_WITNESS_LOG_BYTES = 8 * 1024 * 1024
def _require(row: Mapping[str, Any], key: str) -> Any:
"""Return ``row[key]`` or raise a deterministic ``ValueError`` (never KeyError)."""
if key not in row:
raise ValueError(f"witness record missing required field: {key}")
return row[key]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class WitnessLogManifest: class WitnessLogManifest:
@ -118,15 +137,20 @@ def _reject_raw_or_live(value: object) -> None:
def _record_from_mapping(row: Mapping[str, Any]) -> WitnessRecord: def _record_from_mapping(row: Mapping[str, Any]) -> WitnessRecord:
if not isinstance(row, Mapping):
raise ValueError(f"witness record must be a JSON object, got {type(row).__name__}")
_reject_raw_or_live(row) _reject_raw_or_live(row)
tick = int(row["tick"]) try:
tick = int(_require(row, "tick"))
except (TypeError, ValueError) as exc:
raise ValueError(f"witness tick must be an integer: {exc}") from exc
if tick < 0: if tick < 0:
raise ValueError("witness tick must be non-negative") raise ValueError("witness tick must be non-negative")
modality = _safe_ref(row["modality"], field="modality") modality = _safe_ref(_require(row, "modality"), field="modality")
if modality not in _ALLOWED_MODALITIES: if modality not in _ALLOWED_MODALITIES:
raise ValueError(f"unsupported witness modality: {modality}") raise ValueError(f"unsupported witness modality: {modality}")
slot_id = _safe_ref(row["slot_id"], field="slot_id") slot_id = _safe_ref(_require(row, "slot_id"), field="slot_id")
payload_ref = _safe_ref(row["payload_ref"], field="payload_ref") payload_ref = _safe_ref(_require(row, "payload_ref"), field="payload_ref")
source_clock = _safe_ref(row.get("source_clock", "witness-jsonl"), field="source_clock") source_clock = _safe_ref(row.get("source_clock", "witness-jsonl"), field="source_clock")
payload = { payload = {
"kind": "WitnessRecord", "kind": "WitnessRecord",
@ -232,11 +256,21 @@ def import_witness_records(
def _validate_offline_path(path: Path) -> Path: def _validate_offline_path(path: Path) -> Path:
"""Resolve an OFFLINE witness-log path, rejecting traversal and oversized files.
A witness log is untrusted operator input; the size cap bounds memory so an
oversized/accidental file is rejected deterministically rather than read whole.
"""
if ".." in path.parts: if ".." in path.parts:
raise ValueError("witness log path must not contain path traversal") raise ValueError("witness log path must not contain path traversal")
resolved = path.resolve() resolved = path.resolve()
if not resolved.is_file(): if not resolved.is_file():
raise FileNotFoundError(resolved) raise FileNotFoundError(resolved)
size = resolved.stat().st_size
if size > _MAX_WITNESS_LOG_BYTES:
raise ValueError(
f"witness log too large: {size} bytes exceeds cap {_MAX_WITNESS_LOG_BYTES}"
)
return resolved return resolved
@ -246,12 +280,24 @@ def import_witness_jsonl(
resolve_payload_ref: Callable[[str], CompilationUnitLike], resolve_payload_ref: Callable[[str], CompilationUnitLike],
schema_version: str = "1", schema_version: str = "1",
) -> ImportedObservationSequence: ) -> ImportedObservationSequence:
"""Import an OFFLINE JSONL witness log (one JSON object per non-blank line).
Trust boundary (see module docstring): the path is traversal-checked and
size-capped; a malformed line is rejected with a deterministic ``ValueError``
naming the 1-based line number, BEFORE any frame is built. Valid logs import
byte-identically to the equivalent ``import_witness_records`` call.
"""
log_path = _validate_offline_path(Path(path)) log_path = _validate_offline_path(Path(path))
rows = [ rows: list[Any] = []
json.loads(line) for lineno, line in enumerate(
for line in log_path.read_text(encoding="utf-8").splitlines() log_path.read_text(encoding="utf-8").splitlines(), start=1
if line.strip() ):
] if not line.strip():
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError as exc:
raise ValueError(f"witness log line {lineno}: malformed JSON: {exc}") from exc
return import_witness_records( return import_witness_records(
rows, rows,
resolve_payload_ref=resolve_payload_ref, resolve_payload_ref=resolve_payload_ref,

View file

@ -92,6 +92,43 @@ def test_witness_jsonl_repeated_import_is_identical(tmp_path):
assert first.manifest.manifest_sha256 == second.manifest.manifest_sha256 assert first.manifest.manifest_sha256 == second.manifest.manifest_sha256
def test_witness_jsonl_rejects_malformed_json_line(tmp_path):
path = tmp_path / "witness.jsonl"
good = json.dumps(_rows()[0], sort_keys=True)
path.write_text(good + "\n{ this is not json\n")
with pytest.raises(ValueError, match="line 2: malformed JSON"):
import_witness_jsonl(path, resolve_payload_ref=_resolver)
def test_witness_record_missing_field_is_clean_value_error():
bad = dict(_rows()[0])
del bad["payload_ref"]
with pytest.raises(ValueError, match="missing required field: payload_ref"):
import_witness_records([bad], resolve_payload_ref=_resolver)
def test_witness_record_non_object_is_rejected():
with pytest.raises(ValueError, match="must be a JSON object"):
import_witness_records([[1, 2, 3]], resolve_payload_ref=_resolver)
def test_witness_record_non_integer_tick_is_clean_value_error():
bad = dict(_rows()[0])
bad["tick"] = "not-an-int"
with pytest.raises(ValueError, match="tick must be an integer"):
import_witness_records([bad], resolve_payload_ref=_resolver)
def test_witness_jsonl_rejects_oversized_log(tmp_path, monkeypatch):
from sensorium.logs import importer as _importer
monkeypatch.setattr(_importer, "_MAX_WITNESS_LOG_BYTES", 8)
path = tmp_path / "witness.jsonl"
path.write_text("\n".join(json.dumps(row, sort_keys=True) for row in _rows()) + "\n")
with pytest.raises(ValueError, match="witness log too large"):
import_witness_jsonl(path, resolve_payload_ref=_resolver)
def test_witness_importer_does_not_import_generate_or_call_decode(monkeypatch): def test_witness_importer_does_not_import_generate_or_call_decode(monkeypatch):
original_import = builtins.__import__ original_import = builtins.__import__