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:
parent
d67c83b794
commit
7f5958dbd5
3 changed files with 96 additions and 11 deletions
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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__
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue