diff --git a/README.md b/README.md index cf583b9..46767fa 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,11 @@ for the packaged no-device workflow test. See the [Phase 0 status](docs/status/phase-0.md) and [Rayforge GUI preflight](docs/research/rayforge-windows-gui-preflight.md). +The first M3 deliverable is also available as a deliberately non-executable +[hardware-research protocol](docs/research/m3-hardware-research-protocol.md). +Its public template and validator can establish only `ready_for_review`; they +never authorize USB access or controller commands. + ## The important discovery We do not need to begin with a blank-sheet LightBurn clone: @@ -80,8 +85,8 @@ hardware-free: installing a toolchain through this repository; 3. run and record the packaged no-device desktop interaction checklist; 4. accept, revise, or reject the proposed reuse architecture from evidence; -5. write and review a separate hardware protocol before any live controller - communication. +5. complete and independently review the M3 hardware-protocol template before + seeking separate approval for any live controller communication. An independent desktop application is the fallback only if both existing platforms fail concrete, documented requirements. diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md index 2f8a576..8f43a03 100644 --- a/docs/CODEX_HANDOFF.md +++ b/docs/CODEX_HANDOFF.md @@ -31,6 +31,9 @@ The repository now provides: - a fail-closed API-23 adapter isolating Rayforge's internal driver registry; - a proposed MIT project-license decision in ADR 0003; Kaz has not accepted it, so the repository remains unlicensed. +- a fail-closed M3 hardware-research protocol, incomplete public plan template + and validator; validation can reach only `ready_for_review` and never + authorizes hardware access. Run the ordinary checkpoint with the bundled Codex Python or another supported Python interpreter and a writable `TEMP`/`TMP`: @@ -67,6 +70,8 @@ order: If no artifact is available, do not install a build environment here. The next useful input is the private known-working `.lbrn2` collection or Kaz's explicit -decision on ADR 0003. Until either external gate arrives, ADR 0002 remains -Proposed and no controller backend, USB discovery, physical framing, galvo -motion or laser output is authorized. +decision on ADR 0003. The M3 protocol can also be reviewed offline, but its +operation allowlist must stay empty until exact read-only request evidence is +available. Until the external gates and separate hardware approval arrive, +ADR 0002 remains Proposed and no controller backend, USB endpoint access, +physical framing, galvo motion or laser output is authorized. diff --git a/docs/research/m3-hardware-research-protocol.md b/docs/research/m3-hardware-research-protocol.md new file mode 100644 index 0000000..6f1398b --- /dev/null +++ b/docs/research/m3-hardware-research-protocol.md @@ -0,0 +1,190 @@ +# M3 controller identity and status research protocol + +- Status: Draft; not approved for execution +- Date: 2026-10-21 +- Target: Makerspace Næstved JPT 100 W E2/M7 MOPA fiber laser +- Controller: JCZ/EZCAD2-compatible, observed USB `9588:9899` +- Scope: identity and read-only status only +- Hardware access authorized by this document: **no** + +## Purpose + +This protocol is the review gate between the completed no-device work and any +future controller access. It is deliberately narrower than a controller test: +it defines how a proposed session must be specified, reviewed, stopped and +recorded before an endpoint may be opened. + +The public template is +`tests/fixtures/m3-hardware-protocol-template.json`. It contains no allowed USB +operation because the repository does not yet have sufficient evidence to name +exact request bytes, response constraints and safety behavior. That absence is +a blocker, not a field to fill by inference. + +The validator can only report `incomplete`, `invalid`, or `ready_for_review`. +It always reports `hardware_access_authorized: false`. A later, session-specific +human approval must reference the immutable plan hash; neither this document +nor a successful validator run grants permission to touch hardware. + +## Known facts and unresolved assumptions + +Observed facts: + +- Windows passively enumerated `USBLMCV4`, VID/PID `9588:9899`, device + revision descriptor `0005`, with no compatible function driver installed. +- The LightBurn export identifies a `JCZFiber`/`LibUSB` JPT profile and a + 110 × 110 mm work area. +- BalorMK independently targets the same USB identity and provides a mock + transport, but its live commands include motion- and emission-capable paths. + +Not established: + +- that descriptor `0005` is a board or firmware revision; +- that any apparently read-only controller request cannot reset state, move a + galvo, arm the source or change an output; +- the currently mounted lens and correction profile by physical verification; +- a reviewed, reversible Windows driver strategy for this computer; +- the exact request/response bytes safe to use for identity or status. + +## Non-negotiable scope + +M3 may eventually cover controller enumeration, identity, firmware/capability +discovery and status. The following remain prohibited throughout this protocol: + +- persistent writes or configuration changes; +- job or list upload and execution; +- galvo motion and physical framing; +- source arming or laser emission; +- replay of an unknown request or response; +- automatic retry after an abort or unexpected response. + +M4 requires a new protocol. An M3 result cannot be stretched to authorize +motion, framing, marking, parameter sweeps or output-off assumptions. + +## Gates before a session can be reviewed + +### 1. Evidence gate + +Every proposed operation must be listed individually with: + +- a unique operation identifier and single purpose; +- public evidence provenance using an artifact ID; +- evidence level (`observed` or `independently-implemented`); +- exact request bytes—never wildcards, inferred fields or exploratory ranges; +- a bounded expected-response contract; +- one timeout and exactly one attempt; +- explicit `read-only-no-motion-no-emission` classification; +- `state_change_expected: false`. + +An empty allowlist keeps the plan incomplete. A malformed or state-changing +operation makes it invalid. + +### 2. Physical gate + +Before connecting the research computer, the session plan must record: + +- the reviewed physical method that prevents emission independently of + software, and confirmation that it was verified; +- a cleared work area that assumes unexpected galvo motion is still possible; +- an accessible and tested emergency stop; +- a named safety observer present for the whole live portion; +- physical confirmation of the machine/profile identity and mounted optics. + +“Laser power set to zero” or a UI toggle is not an independent emission +inhibition method. + +### 3. Software and driver gate + +- The production controller backend remains absent from The Pirate Ray. +- A narrow experiment harness must enforce the reviewed operation allowlist. +- Unknown responses are captured and stop the session; they are never replayed. +- No operation is retried automatically. +- The current Windows driver state is recorded privately before change. +- Driver installation, replacement and rollback require their own reviewed, + reversible plan. This repository does not install or switch drivers. +- The normal LightBurn control computer is not modified for convenience. + +### 4. People and review gate + +The plan names an operator, safety observer and protocol reviewer. One person +may not silently fill all roles during execution. The reviewer checks the +machine identity, evidence for every allowlisted operation, physical isolation, +driver rollback, capture destination and stop/recovery procedures. + +After `validate_m3_hardware_protocol.py` reports `ready_for_review`, freeze the +plan bytes and SHA-256. A separate approval record must identify that hash, +reviewer, approval time with timezone, location and exact session window. Any +plan edit invalidates that approval. + +## Session sequence + +The eventual reviewed session follows this order and stops at the first failed +gate: + +1. Brief operator and observer; verify emergency-stop access. +2. Confirm machine, source, lens and controller identity without opening an + endpoint. +3. Apply and verify the independent emission inhibition. +4. Clear the work area and assume motion remains possible. +5. Record the original Windows driver state and rollback evidence privately. +6. Verify hashes for the plan, harness and dependencies. +7. Start capture into the prepared private destination. +8. Connect only the allowlisted controller identity. +9. Execute one allowlisted operation once. +10. Compare the response only with its predeclared constraints. +11. Stop; do not improvise a follow-up request. +12. Disconnect, restore the reviewed driver state, and verify normal shutdown. +13. Hash raw evidence privately and create only sanitized derived public notes. + +No batch of “harmless queries” is permitted. Additional operations require a +new reviewed plan or a new version of the allowlist. + +## Immediate abort conditions + +Any of these ends the session without automatic retry: + +- unexpected galvo motion; +- any emission or source-arm indication; +- interlock state change; +- unexpected controller reset or disconnect; +- attempted transfer not present in the immutable allowlist; +- response outside the predeclared contract; +- stop request from either operator or observer. + +On abort: stop the harness, disconnect USB if safe, use the reviewed physical +power-down procedure, preserve logs, record an incident, and do not send a +speculative reset/stop command. A controller stop command is itself prohibited +until separately evidenced and allowlisted. + +## Evidence and publication + +Raw captures, serials, instance identifiers, driver exports, photos and local +paths go to `ThePirateRay-PrivateStash` with byte count, SHA-256, acquisition +time, origin and classification. The public repository receives sanitized +derived notes and synthetic regression fixtures only. Vendor binaries are not +executed or committed publicly. + +The session report must distinguish: + +- **observed** bytes or state; +- **inferred** interpretation; +- **unknown** behavior requiring more evidence. + +## Validate the draft plan + +```powershell +python scripts/validate_m3_hardware_protocol.py ` + tests/fixtures/m3-hardware-protocol-template.json +``` + +The committed template is expected to return `incomplete`: roles, physical +controls, recovery, driver plan, session metadata and exact evidence-backed +operations are intentionally absent. Do not change those fields merely to make +the validator green. + +## Current conclusion + +The written-protocol roadmap requirement is now represented and testable, but +the protocol is not executable. The next evidence needed for M3 is a reviewed +driver/rollback strategy and a justified exact read-only operation contract. +Until those exist and a specific plan receives separate human approval, no USB +endpoint may be opened and no controller bytes may be sent. diff --git a/docs/roadmap.md b/docs/roadmap.md index ef65b24..4f27ed1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -215,6 +215,12 @@ Before touching the connected machine, write a test protocol that specifies: - abort conditions and rollback/recovery steps; - what is captured and how it is sanitized. +The initial, non-executable protocol, machine-readable template and validator +are now recorded in the +[M3 hardware-research protocol](research/m3-hardware-research-protocol.md). +The committed template is intentionally incomplete. Validator success means +only `ready_for_review`; it never grants permission to access hardware. + Exit criteria: the app recognizes the correct controller and reports status without moving a galvo, arming the source, modifying persistent state, or requiring proprietary executable code in the public build. @@ -275,11 +281,14 @@ The next work package should be small enough to review in a few commits: buffering and explicit safe state ordering, still without transport; 5. exercise cancellation/recovery and the graphical workflow against mock backends, then accept, amend or reject ADR 0002; -6. separately produce a written M3 hardware-research protocol before any USB - endpoint access or controller command. +6. complete and independently review the written M3 hardware-research protocol + before seeking separate approval for any USB endpoint access or controller + command. -Hardware work cannot proceed until item 6 is reviewed and the physical setup -is ready. +The draft, template, validator and regression tests for item 6 are present. +Hardware work still cannot proceed until the exact read-only operation evidence, +driver/rollback plan, physical controls, distinct assigned roles and separate +human approval are all in place. Item 2 is now covered by the [synthetic LightBurn JCZ device-import spike](research/rayforge-lightburn-device-import-spike.md): diff --git a/docs/status/phase-0.md b/docs/status/phase-0.md index 8c3a9f7..be25d74 100644 --- a/docs/status/phase-0.md +++ b/docs/status/phase-0.md @@ -92,6 +92,10 @@ evidence for: and beside the installer manifest; - a single fail-closed Rayforge API-23 compatibility adapter around the internal driver registry, with collision, drift and symmetric-cleanup tests. +- a written M3 hardware-research protocol, intentionally incomplete public + plan template and standard-library validator that requires exact + evidence-backed request bytes, distinct safety roles, physical controls and + recovery planning while always denying hardware authorization. The remaining M1 gates are deliberately explicit: @@ -107,3 +111,8 @@ The remaining M1 gates are deliberately explicit: No item in this section authorizes USB access, controller commands or laser output. + +The M3 protocol can currently report only `incomplete`. It has no allowlisted +controller operation, reviewed driver/rollback plan, assigned people or +confirmed physical controls. Even a future `ready_for_review` result requires +separate human approval before hardware access. diff --git a/scripts/validate_m3_hardware_protocol.py b/scripts/validate_m3_hardware_protocol.py new file mode 100644 index 0000000..b39ce10 --- /dev/null +++ b/scripts/validate_m3_hardware_protocol.py @@ -0,0 +1,264 @@ +"""Validate an M3 hardware-research plan without authorizing or using hardware.""" + +from __future__ import annotations + +import argparse +from datetime import datetime +import json +import re +from pathlib import Path + + +PROTOCOL_NAME = "M3 controller identity and status research" +EXPECTED_TARGET = { + "profile_id": "makerspace-naestved-jpt-e2-mopa-100w", + "controller_family": "JCZ", + "usb_vid": "9588", + "usb_pid": "9899", + "device_description": "USBLMCV4", + "lens_field_mm": 110, + "lens_status": "assumed-pending-physical-verification", +} +PROHIBITED_CAPABILITIES = { + "persistent_write", + "job_upload", + "list_execution", + "galvo_motion", + "physical_framing", + "source_arm", + "laser_emission", +} +ABORT_CONDITIONS = { + "unexpected_motion", + "emission_or_source_arm_indication", + "interlock_state_change", + "unexpected_device_reset_or_disconnect", + "unallowlisted_transfer_attempt", + "unexpected_response", + "operator_or_observer_stop", +} +PHYSICAL_CHECKS = ( + "emission_inhibited_and_verified", + "work_area_cleared", + "emergency_stop_accessible_and_tested", + "observer_present", + "machine_profile_physically_confirmed", +) +SHA256_ARTIFACT = re.compile(r"^sha256-[0-9a-f]{20}$") +HEX_BYTES = re.compile(r"^(?:[0-9a-f]{2})+$") +OPERATION_KEYS = { + "operation_id", + "purpose", + "evidence_artifact_id", + "evidence_level", + "safety_class", + "direction", + "request_hex", + "expected_response", + "timeout_ms", + "maximum_attempts", + "state_change_expected", +} + + +def _timestamp_with_timezone(value: object) -> bool: + if not isinstance(value, str): + return False + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return False + return parsed.tzinfo is not None + + +def _validate_operation(operation: dict, seen: set[str]) -> list[str]: + errors = [] + if set(operation) != OPERATION_KEYS: + errors.append("allowed operation fields do not match the protocol contract") + return errors + operation_id = operation["operation_id"] + if not isinstance(operation_id, str) or not operation_id.strip(): + errors.append("allowed operation requires a non-empty operation_id") + elif operation_id in seen: + errors.append(f"duplicate allowed operation: {operation_id}") + else: + seen.add(operation_id) + if not isinstance(operation["purpose"], str) or not operation["purpose"].strip(): + errors.append(f"allowed operation {operation_id!r} requires a purpose") + evidence_id = operation["evidence_artifact_id"] + if not isinstance(evidence_id, str) or SHA256_ARTIFACT.fullmatch(evidence_id) is None: + errors.append(f"allowed operation {operation_id!r} lacks public evidence provenance") + if operation["evidence_level"] not in {"observed", "independently-implemented"}: + errors.append(f"allowed operation {operation_id!r} has unsupported evidence level") + if operation["safety_class"] != "read-only-no-motion-no-emission": + errors.append(f"allowed operation {operation_id!r} has an unsafe classification") + if operation["direction"] != "host-to-controller-request-response": + errors.append(f"allowed operation {operation_id!r} has an unsupported direction") + request_hex = operation["request_hex"] + if not isinstance(request_hex, str) or HEX_BYTES.fullmatch(request_hex) is None: + errors.append(f"allowed operation {operation_id!r} lacks exact request bytes") + if not isinstance(operation["expected_response"], str) or not operation[ + "expected_response" + ].strip(): + errors.append(f"allowed operation {operation_id!r} lacks response constraints") + if type(operation["timeout_ms"]) is not int or not 1 <= operation["timeout_ms"] <= 5000: + errors.append(f"allowed operation {operation_id!r} has an invalid timeout") + if type(operation["maximum_attempts"]) is not int or operation["maximum_attempts"] != 1: + errors.append(f"allowed operation {operation_id!r} permits retry") + if operation["state_change_expected"] is not False: + errors.append(f"allowed operation {operation_id!r} expects a state change") + return errors + + +def validate(path: Path) -> dict: + plan = json.loads(path.resolve().read_text(encoding="utf-8-sig")) + errors = [] + incomplete = [] + + if not isinstance(plan, dict): + return { + "status": "invalid", + "errors": ["protocol plan must be a JSON object"], + "incomplete": [], + "validated_operations": 0, + "hardware_access_authorized": False, + "separate_human_approval_required": True, + } + + if plan.get("schema_version") != 1: + errors.append("unsupported protocol schema") + if plan.get("protocol") != PROTOCOL_NAME: + errors.append("unexpected protocol identity") + if plan.get("protocol_status") != "draft": + errors.append("public protocol plan must remain draft") + if plan.get("target") != EXPECTED_TARGET: + errors.append("target does not match the sanitized machine profile") + + scope = plan.get("scope", {}) + if not isinstance(scope, dict): + errors.append("scope must be an object") + scope = {} + if scope.get("milestone") != "M3": + errors.append("protocol is outside M3") + prohibited = scope.get("prohibited_capabilities", []) + if not isinstance(prohibited, list) or set(prohibited) != PROHIBITED_CAPABILITIES: + errors.append("prohibited capability set does not match the safety boundary") + abort_conditions = plan.get("abort_conditions", []) + if not isinstance(abort_conditions, list) or set(abort_conditions) != ABORT_CONDITIONS: + errors.append("abort-condition set does not match the safety boundary") + + roles = plan.get("roles", {}) + if not isinstance(roles, dict): + errors.append("roles must be an object") + roles = {} + assigned_roles = [] + for key in ("operator", "safety_observer", "protocol_reviewer"): + if not isinstance(roles.get(key), str) or not roles.get(key, "").strip(): + incomplete.append(f"role not assigned: {key}") + else: + assigned_roles.append(roles[key].strip().casefold()) + if len(assigned_roles) == 3 and len(set(assigned_roles)) != 3: + errors.append("operator, safety observer and protocol reviewer must be distinct") + + physical = plan.get("physical_controls", {}) + if not isinstance(physical, dict): + errors.append("physical_controls must be an object") + physical = {} + for key in PHYSICAL_CHECKS: + if physical.get(key) is not True: + incomplete.append(f"physical control not confirmed: {key}") + if not isinstance(physical.get("emission_inhibition_method"), str) or not physical.get( + "emission_inhibition_method", "" + ).strip(): + incomplete.append("emission inhibition method is not recorded") + + software = plan.get("software_controls", {}) + if not isinstance(software, dict): + errors.append("software_controls must be an object") + software = {} + if software.get("production_backend_absent") is not True: + errors.append("production controller backend must remain absent") + if software.get("unknown_responses_not_replayed") is not True: + errors.append("unknown responses must never be replayed") + if software.get("maximum_attempts_per_operation") != 1: + errors.append("automatic operation retry is prohibited") + for key in ("operation_allowlist_enforced", "reversible_driver_plan_reviewed"): + if software.get(key) is not True: + incomplete.append(f"software control not confirmed: {key}") + + operations = plan.get("allowed_operations", []) + if not isinstance(operations, list): + errors.append("allowed_operations must be a list") + operations = [] + if not operations: + incomplete.append("no evidence-backed USB operation is allowlisted") + seen = set() + for operation in operations: + if not isinstance(operation, dict): + errors.append("allowed operation must be an object") + else: + errors.extend(_validate_operation(operation, seen)) + + recovery = plan.get("recovery", {}) + if not isinstance(recovery, dict): + errors.append("recovery must be an object") + recovery = {} + if recovery.get("no_automatic_retry_after_abort") is not True: + errors.append("automatic retry after abort is prohibited") + for key in ( + "power_down_procedure_reviewed", + "driver_rollback_procedure_reviewed", + "incident_record_destination_prepared", + ): + if recovery.get(key) is not True: + incomplete.append(f"recovery control not confirmed: {key}") + + evidence = plan.get("evidence_handling", {}) + expected_evidence = { + "raw_capture_classification": "private-sensitive", + "raw_capture_destination": "ThePirateRay-PrivateStash", + "public_output": "sanitized-derived-notes-only", + "sha256_manifest_required": True, + "private_identifiers_redacted_publicly": True, + } + if evidence != expected_evidence: + errors.append("evidence handling does not match the public/private boundary") + + session = plan.get("session", {}) + if not isinstance(session, dict): + errors.append("session must be an object") + session = {} + if not isinstance(session.get("machine_location"), str) or not session.get( + "machine_location", "" + ).strip(): + incomplete.append("machine location is not recorded") + if not _timestamp_with_timezone(session.get("planned_at")): + incomplete.append("planned_at is not an ISO-8601 timestamp with timezone") + + status = "invalid" if errors else "ready_for_review" if not incomplete else "incomplete" + return { + "status": status, + "errors": errors, + "incomplete": incomplete, + "validated_operations": len(operations) if not errors else 0, + "hardware_access_authorized": False, + "separate_human_approval_required": True, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("plan", type=Path) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + result = validate(args.plan) + rendered = json.dumps(result, indent=2, sort_keys=True) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(rendered, encoding="utf-8", newline="\n") + print(rendered, end="") + return 0 if result["status"] == "ready_for_review" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/m3-hardware-protocol-template.json b/tests/fixtures/m3-hardware-protocol-template.json new file mode 100644 index 0000000..7d99b52 --- /dev/null +++ b/tests/fixtures/m3-hardware-protocol-template.json @@ -0,0 +1,75 @@ +{ + "schema_version": 1, + "protocol": "M3 controller identity and status research", + "protocol_status": "draft", + "target": { + "profile_id": "makerspace-naestved-jpt-e2-mopa-100w", + "controller_family": "JCZ", + "usb_vid": "9588", + "usb_pid": "9899", + "device_description": "USBLMCV4", + "lens_field_mm": 110, + "lens_status": "assumed-pending-physical-verification" + }, + "scope": { + "milestone": "M3", + "goal": "read-only controller identity and status evidence", + "prohibited_capabilities": [ + "persistent_write", + "job_upload", + "list_execution", + "galvo_motion", + "physical_framing", + "source_arm", + "laser_emission" + ] + }, + "roles": { + "operator": null, + "safety_observer": null, + "protocol_reviewer": null + }, + "physical_controls": { + "emission_inhibited_and_verified": null, + "emission_inhibition_method": null, + "work_area_cleared": null, + "emergency_stop_accessible_and_tested": null, + "observer_present": null, + "machine_profile_physically_confirmed": null + }, + "software_controls": { + "production_backend_absent": true, + "operation_allowlist_enforced": null, + "unknown_responses_not_replayed": true, + "maximum_attempts_per_operation": 1, + "reversible_driver_plan_reviewed": null + }, + "allowed_operations": [], + "abort_conditions": [ + "unexpected_motion", + "emission_or_source_arm_indication", + "interlock_state_change", + "unexpected_device_reset_or_disconnect", + "unallowlisted_transfer_attempt", + "unexpected_response", + "operator_or_observer_stop" + ], + "recovery": { + "power_down_procedure_reviewed": null, + "driver_rollback_procedure_reviewed": null, + "incident_record_destination_prepared": null, + "no_automatic_retry_after_abort": true + }, + "evidence_handling": { + "raw_capture_classification": "private-sensitive", + "raw_capture_destination": "ThePirateRay-PrivateStash", + "public_output": "sanitized-derived-notes-only", + "sha256_manifest_required": true, + "private_identifiers_redacted_publicly": true + }, + "session": { + "machine_location": null, + "planned_at": null, + "notes": "" + } +} diff --git a/tests/test_m3_hardware_protocol.py b/tests/test_m3_hardware_protocol.py new file mode 100644 index 0000000..c945b88 --- /dev/null +++ b/tests/test_m3_hardware_protocol.py @@ -0,0 +1,143 @@ +import copy +import json +import tempfile +import unittest +from pathlib import Path + +from scripts.validate_m3_hardware_protocol import validate + + +ROOT = Path(__file__).resolve().parents[1] +TEMPLATE = ROOT / "tests" / "fixtures" / "m3-hardware-protocol-template.json" + + +class M3HardwareProtocolTests(unittest.TestCase): + def setUp(self): + self.template = json.loads(TEMPLATE.read_text(encoding="utf-8")) + + def _write(self, root: Path, plan: dict) -> Path: + path = root / "plan.json" + path.write_text(json.dumps(plan), encoding="utf-8") + return path + + def _complete(self) -> dict: + plan = copy.deepcopy(self.template) + plan["roles"] = { + "operator": "Synthetic Operator", + "safety_observer": "Synthetic Observer", + "protocol_reviewer": "Synthetic Reviewer", + } + plan["physical_controls"] = { + key: True for key in plan["physical_controls"] + if key != "emission_inhibition_method" + } + plan["physical_controls"]["emission_inhibition_method"] = ( + "Synthetic reviewed isolation method" + ) + plan["software_controls"]["operation_allowlist_enforced"] = True + plan["software_controls"]["reversible_driver_plan_reviewed"] = True + plan["allowed_operations"] = [{ + "operation_id": "synthetic-read-identity", + "purpose": "Synthetic validator fixture only", + "evidence_artifact_id": "sha256-0123456789abcdef0123", + "evidence_level": "independently-implemented", + "safety_class": "read-only-no-motion-no-emission", + "direction": "host-to-controller-request-response", + "request_hex": "0102", + "expected_response": "Synthetic fixed response contract", + "timeout_ms": 1000, + "maximum_attempts": 1, + "state_change_expected": False, + }] + plan["recovery"].update({ + "power_down_procedure_reviewed": True, + "driver_rollback_procedure_reviewed": True, + "incident_record_destination_prepared": True, + }) + plan["session"] = { + "machine_location": "Synthetic test location", + "planned_at": "2026-10-21T12:00:00+02:00", + "notes": "No hardware is used by this unit test.", + } + return plan + + def test_public_template_is_incomplete_and_never_authorizes_hardware(self): + result = validate(TEMPLATE) + self.assertEqual("incomplete", result["status"]) + self.assertFalse(result["hardware_access_authorized"]) + self.assertTrue(result["separate_human_approval_required"]) + self.assertIn( + "no evidence-backed USB operation is allowlisted", + result["incomplete"], + ) + + def test_complete_plan_is_only_ready_for_review(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + result = validate(self._write(Path(directory), self._complete())) + self.assertEqual("ready_for_review", result["status"]) + self.assertEqual(1, result["validated_operations"]) + self.assertFalse(result["hardware_access_authorized"]) + + def test_roles_must_be_distinct(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + plan = self._complete() + plan["roles"]["safety_observer"] = plan["roles"]["operator"] + result = validate(self._write(Path(directory), plan)) + self.assertEqual("invalid", result["status"]) + self.assertIn( + "operator, safety observer and protocol reviewer must be distinct", + result["errors"], + ) + self.assertFalse(result["hardware_access_authorized"]) + + def test_safety_boundary_cannot_be_narrowed(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + plan = self._complete() + plan["scope"]["prohibited_capabilities"].remove("laser_emission") + result = validate(self._write(Path(directory), plan)) + self.assertEqual("invalid", result["status"]) + self.assertIn( + "prohibited capability set does not match the safety boundary", + result["errors"], + ) + + def test_operation_requires_exact_bytes_provenance_and_no_retry(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + plan = self._complete() + operation = plan["allowed_operations"][0] + operation["request_hex"] = "unknown" + operation["evidence_artifact_id"] = "private-path" + operation["maximum_attempts"] = 2 + result = validate(self._write(Path(directory), plan)) + self.assertEqual("invalid", result["status"]) + self.assertGreaterEqual(len(result["errors"]), 3) + self.assertFalse(result["hardware_access_authorized"]) + + def test_motion_or_state_change_operation_is_rejected(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + plan = self._complete() + operation = plan["allowed_operations"][0] + operation["safety_class"] = "motion" + operation["state_change_expected"] = True + result = validate(self._write(Path(directory), plan)) + self.assertEqual("invalid", result["status"]) + self.assertTrue(any("unsafe classification" in item for item in result["errors"])) + self.assertTrue(any("expects a state change" in item for item in result["errors"])) + + def test_malformed_types_fail_closed_instead_of_crashing(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + plan = self._complete() + plan["scope"] = None + plan["roles"] = [] + operation = plan["allowed_operations"][0] + operation["request_hex"] = 102 + operation["evidence_artifact_id"] = False + operation["timeout_ms"] = True + result = validate(self._write(Path(directory), plan)) + self.assertEqual("invalid", result["status"]) + self.assertGreaterEqual(len(result["errors"]), 6) + self.assertFalse(result["hardware_access_authorized"]) + + +if __name__ == "__main__": + unittest.main()