safety: define fail-closed M3 research protocol
This commit is contained in:
parent
7131b50e2e
commit
ed59b928c9
8 changed files with 709 additions and 9 deletions
|
|
@ -50,6 +50,11 @@ for the packaged no-device workflow test. See the
|
||||||
[Phase 0 status](docs/status/phase-0.md) and
|
[Phase 0 status](docs/status/phase-0.md) and
|
||||||
[Rayforge GUI preflight](docs/research/rayforge-windows-gui-preflight.md).
|
[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
|
## The important discovery
|
||||||
|
|
||||||
We do not need to begin with a blank-sheet LightBurn clone:
|
We do not need to begin with a blank-sheet LightBurn clone:
|
||||||
|
|
@ -80,8 +85,8 @@ hardware-free:
|
||||||
installing a toolchain through this repository;
|
installing a toolchain through this repository;
|
||||||
3. run and record the packaged no-device desktop interaction checklist;
|
3. run and record the packaged no-device desktop interaction checklist;
|
||||||
4. accept, revise, or reject the proposed reuse architecture from evidence;
|
4. accept, revise, or reject the proposed reuse architecture from evidence;
|
||||||
5. write and review a separate hardware protocol before any live controller
|
5. complete and independently review the M3 hardware-protocol template before
|
||||||
communication.
|
seeking separate approval for any live controller communication.
|
||||||
|
|
||||||
An independent desktop application is the fallback only if both existing
|
An independent desktop application is the fallback only if both existing
|
||||||
platforms fail concrete, documented requirements.
|
platforms fail concrete, documented requirements.
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ The repository now provides:
|
||||||
- a fail-closed API-23 adapter isolating Rayforge's internal driver registry;
|
- 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
|
- a proposed MIT project-license decision in ADR 0003; Kaz has not accepted
|
||||||
it, so the repository remains unlicensed.
|
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
|
Run the ordinary checkpoint with the bundled Codex Python or another supported
|
||||||
Python interpreter and a writable `TEMP`/`TMP`:
|
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
|
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
|
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
|
decision on ADR 0003. The M3 protocol can also be reviewed offline, but its
|
||||||
Proposed and no controller backend, USB discovery, physical framing, galvo
|
operation allowlist must stay empty until exact read-only request evidence is
|
||||||
motion or laser output is authorized.
|
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.
|
||||||
|
|
|
||||||
190
docs/research/m3-hardware-research-protocol.md
Normal file
190
docs/research/m3-hardware-research-protocol.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -215,6 +215,12 @@ Before touching the connected machine, write a test protocol that specifies:
|
||||||
- abort conditions and rollback/recovery steps;
|
- abort conditions and rollback/recovery steps;
|
||||||
- what is captured and how it is sanitized.
|
- 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
|
Exit criteria: the app recognizes the correct controller and reports status
|
||||||
without moving a galvo, arming the source, modifying persistent state, or
|
without moving a galvo, arming the source, modifying persistent state, or
|
||||||
requiring proprietary executable code in the public build.
|
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;
|
buffering and explicit safe state ordering, still without transport;
|
||||||
5. exercise cancellation/recovery and the graphical workflow against mock
|
5. exercise cancellation/recovery and the graphical workflow against mock
|
||||||
backends, then accept, amend or reject ADR 0002;
|
backends, then accept, amend or reject ADR 0002;
|
||||||
6. separately produce a written M3 hardware-research protocol before any USB
|
6. complete and independently review the written M3 hardware-research protocol
|
||||||
endpoint access or controller command.
|
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
|
The draft, template, validator and regression tests for item 6 are present.
|
||||||
is ready.
|
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
|
Item 2 is now covered by the
|
||||||
[synthetic LightBurn JCZ device-import spike](research/rayforge-lightburn-device-import-spike.md):
|
[synthetic LightBurn JCZ device-import spike](research/rayforge-lightburn-device-import-spike.md):
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,10 @@ evidence for:
|
||||||
and beside the installer manifest;
|
and beside the installer manifest;
|
||||||
- a single fail-closed Rayforge API-23 compatibility adapter around the
|
- a single fail-closed Rayforge API-23 compatibility adapter around the
|
||||||
internal driver registry, with collision, drift and symmetric-cleanup tests.
|
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:
|
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
|
No item in this section authorizes USB access, controller commands or laser
|
||||||
output.
|
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.
|
||||||
|
|
|
||||||
264
scripts/validate_m3_hardware_protocol.py
Normal file
264
scripts/validate_m3_hardware_protocol.py
Normal file
|
|
@ -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())
|
||||||
75
tests/fixtures/m3-hardware-protocol-template.json
vendored
Normal file
75
tests/fixtures/m3-hardware-protocol-template.json
vendored
Normal file
|
|
@ -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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
143
tests/test_m3_hardware_protocol.py
Normal file
143
tests/test_m3_hardware_protocol.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Add table
Reference in a new issue