diff --git a/docs/decisions/ADR-0198-motor-efferent-decoder-spike.md b/docs/decisions/ADR-0198-motor-efferent-decoder-spike.md index d4f86dcd..af84c400 100644 --- a/docs/decisions/ADR-0198-motor-efferent-decoder-spike.md +++ b/docs/decisions/ADR-0198-motor-efferent-decoder-spike.md @@ -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). +**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:` 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:** 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). +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" diff --git a/sensorium/logs/importer.py b/sensorium/logs/importer.py index bc4eca27..e7fa9791 100644 --- a/sensorium/logs/importer.py +++ b/sensorium/logs/importer.py @@ -3,6 +3,14 @@ Witness logs are evidence transport, not truth. The importer accepts only payload references and uses a caller-provided resolver to obtain already 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 @@ -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"} _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) class WitnessLogManifest: @@ -118,15 +137,20 @@ def _reject_raw_or_live(value: object) -> None: 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) - 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: 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: raise ValueError(f"unsupported witness modality: {modality}") - slot_id = _safe_ref(row["slot_id"], field="slot_id") - payload_ref = _safe_ref(row["payload_ref"], field="payload_ref") + slot_id = _safe_ref(_require(row, "slot_id"), field="slot_id") + payload_ref = _safe_ref(_require(row, "payload_ref"), field="payload_ref") source_clock = _safe_ref(row.get("source_clock", "witness-jsonl"), field="source_clock") payload = { "kind": "WitnessRecord", @@ -232,11 +256,21 @@ def import_witness_records( 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: raise ValueError("witness log path must not contain path traversal") resolved = path.resolve() if not resolved.is_file(): 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 @@ -246,12 +280,24 @@ def import_witness_jsonl( resolve_payload_ref: Callable[[str], CompilationUnitLike], schema_version: str = "1", ) -> 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)) - rows = [ - json.loads(line) - for line in log_path.read_text(encoding="utf-8").splitlines() - if line.strip() - ] + rows: list[Any] = [] + for lineno, line in enumerate( + log_path.read_text(encoding="utf-8").splitlines(), start=1 + ): + 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( rows, resolve_payload_ref=resolve_payload_ref, diff --git a/tests/test_witness_log_importer.py b/tests/test_witness_log_importer.py index f8acfdf9..7cfa2234 100644 --- a/tests/test_witness_log_importer.py +++ b/tests/test_witness_log_importer.py @@ -92,6 +92,43 @@ def test_witness_jsonl_repeated_import_is_identical(tmp_path): 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): original_import = builtins.__import__