diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md index 2aeb715..9f7d919 100644 --- a/docs/CODEX_HANDOFF.md +++ b/docs/CODEX_HANDOFF.md @@ -23,6 +23,8 @@ The repository now provides: non-executing artifact-verification report, including the Rayforge pin and bundle, installer, addon-verification and source checkpoint-manifest fields; - a fail-closed manual desktop-acceptance report and validator; + complete operator evidence is only ready for review until a named, + timezone-stamped human review is recorded; - pinned upstream contract/license manifests and a drift checker; - 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 diff --git a/docs/research/m1-no-device-acceptance.md b/docs/research/m1-no-device-acceptance.md index dc94504..2f0edfb 100644 --- a/docs/research/m1-no-device-acceptance.md +++ b/docs/research/m1-no-device-acceptance.md @@ -137,11 +137,12 @@ python scripts/validate_m1_manual_acceptance.py ` ``` The validator verifies the kit manifest, repository commit, artifact report, -evidence hashes and relative paths. It reports -`incomplete` for any unrun or blocked scenario and `failed` for a failed step, -connected hardware, non-isolated app data, changed evidence or any stop -condition. Even a complete `passed` report still requires human review and -does not authorize hardware access. +evidence hashes and relative paths. It reports `incomplete` for any unrun or +blocked scenario, `ready_for_review` when every operator step passes but the +review record is still empty, and `failed` for a failed step, connected +hardware, non-isolated app data, changed evidence, malformed review or any +stop condition. Final `passed` requires a non-empty reviewer name and an +ISO-8601 review timestamp with timezone. No status authorizes hardware access. ## Remaining external gates diff --git a/docs/roadmap.md b/docs/roadmap.md index b711838..798a007 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -328,8 +328,10 @@ packaged addon source before the manual GUI checklist may begin. It also rejects omitted manifest entries and extra files hidden in the packaged addon directory. The manual checklist now also has a versioned, fail-closed report template and -validator. Missing steps remain incomplete, stop conditions fail the run, and -hashed screenshots stay outside the public repository pending sanitization. +validator. Missing steps remain incomplete, a complete operator run remains +ready for review until a named, timezone-stamped human review is recorded, +stop conditions fail the run, and hashed screenshots stay outside the public +repository pending sanitization. A reproducible kit generator copies only allowlisted synthetic inputs into the ignored build area, records their hashes and refuses to overwrite an existing test run. Its schema-2 provenance chain binds the editable manual report to the diff --git a/docs/status/phase-0.md b/docs/status/phase-0.md index 63dcd1d..8c3a9f7 100644 --- a/docs/status/phase-0.md +++ b/docs/status/phase-0.md @@ -81,7 +81,8 @@ evidence for: archive safety, sizes, hashes, the pinned Rayforge revision and every packaged addon source while retaining the manual GUI gate; - a versioned manual desktop-acceptance template and validator that cannot - pass missing steps, connected hardware, changed evidence or stop conditions; + pass missing steps, connected hardware, changed evidence, stop conditions or + an absent/malformed human review record; - a deterministic acceptance-kit generator that copies only four allowlisted public template/input sources, hashes the three immutable inputs, refuses to overwrite an existing run directory, and binds the report to the exact kit, diff --git a/scripts/validate_m1_manual_acceptance.py b/scripts/validate_m1_manual_acceptance.py index 6f257b2..7ca1851 100644 --- a/scripts/validate_m1_manual_acceptance.py +++ b/scripts/validate_m1_manual_acceptance.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +from datetime import datetime import hashlib import json import re @@ -171,6 +172,26 @@ def _validate_artifact_report(report_root: Path, binding: dict) -> list[str]: return errors +def _review_state(review: dict) -> tuple[bool, list[str]]: + reviewer = review.get("reviewer") + reviewed_at = review.get("reviewed_at") + if reviewer is None and reviewed_at is None: + return False, [] + errors = [] + if not isinstance(reviewer, str) or not reviewer.strip(): + errors.append("reviewer must be a non-empty name") + if not isinstance(reviewed_at, str): + errors.append("reviewed_at must be an ISO-8601 timestamp with timezone") + else: + try: + timestamp = datetime.fromisoformat(reviewed_at.replace("Z", "+00:00")) + if timestamp.tzinfo is None: + raise ValueError + except ValueError: + errors.append("reviewed_at must be an ISO-8601 timestamp with timezone") + return not errors, errors + + def validate(report_path: Path) -> dict: report_path = report_path.resolve() report = json.loads(report_path.read_text(encoding="utf-8-sig")) @@ -201,6 +222,8 @@ def validate(report_path: Path) -> dict: artifact_errors = _validate_artifact_report(report_path.parent, artifact) errors.extend(artifact_errors) errors.extend(_validate_evidence(report_path.parent, report.get("evidence", []))) + reviewed, review_errors = _review_state(report.get("review", {})) + errors.extend(review_errors) explicit_failure = ( any(step.get("status") == "failed" for step in steps) @@ -209,7 +232,7 @@ def validate(report_path: Path) -> dict: or environment.get("fresh_app_data") is False or artifact.get("status") == "failed" ) - complete = ( + operator_complete = ( not errors and step_ids == list(STEP_IDS) and all(step.get("status") == "passed" for step in steps) @@ -220,7 +243,14 @@ def validate(report_path: Path) -> dict: and artifact.get("status") == "passed" and not artifact_errors ) - status = "failed" if explicit_failure or errors else "passed" if complete else "incomplete" + if explicit_failure or errors: + status = "failed" + elif operator_complete and reviewed: + status = "passed" + elif operator_complete: + status = "ready_for_review" + else: + status = "incomplete" return { "status": status, "schema_valid": not errors, @@ -228,7 +258,8 @@ def validate(report_path: Path) -> dict: "passed_steps": sum(step.get("status") == "passed" for step in steps), "required_steps": len(STEP_IDS), "hardware_access_authorized": False, - "manual_review_required": True, + "review_recorded": reviewed, + "manual_review_required": status != "passed", } diff --git a/tests/test_m1_manual_acceptance.py b/tests/test_m1_manual_acceptance.py index 7a1ed3d..9f19df7 100644 --- a/tests/test_m1_manual_acceptance.py +++ b/tests/test_m1_manual_acceptance.py @@ -99,6 +99,11 @@ class M1ManualAcceptanceTests(unittest.TestCase): "path": "evidence/offline-state.png", "sha256": hashlib.sha256(screenshot.read_bytes()).hexdigest(), }] + report["review"] = { + "reviewer": "Synthetic Test Reviewer", + "reviewed_at": "2026-10-21T12:00:00+02:00", + "notes": "Synthetic unit-test review only.", + } return report def test_template_is_valid_but_incomplete(self): @@ -118,7 +123,34 @@ class M1ManualAcceptanceTests(unittest.TestCase): result = validate(self._write(root, self._passing(root))) self.assertEqual(result["status"], "passed") self.assertFalse(result["hardware_access_authorized"]) + self.assertFalse(result["manual_review_required"]) + self.assertTrue(result["review_recorded"]) + + def test_complete_operator_report_waits_for_human_review(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + root = Path(directory) + report = self._passing(root) + report["review"] = {"reviewer": None, "reviewed_at": None, "notes": ""} + result = validate(self._write(root, report)) + self.assertEqual("ready_for_review", result["status"]) self.assertTrue(result["manual_review_required"]) + self.assertFalse(result["review_recorded"]) + + def test_partial_or_timezone_free_review_is_rejected(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + root = Path(directory) + report = self._passing(root) + report["review"] = { + "reviewer": "Synthetic Test Reviewer", + "reviewed_at": "2026-10-21T12:00:00", + "notes": "", + } + result = validate(self._write(root, report)) + self.assertEqual("failed", result["status"]) + self.assertIn( + "reviewed_at must be an ISO-8601 timestamp with timezone", + result["errors"], + ) def test_any_stop_condition_fails_checkpoint(self): with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: