test: require human review for M1 acceptance

This commit is contained in:
Codex 2026-10-21 18:37:50 +02:00
parent 3c26e1bdab
commit a5a8e4086d
6 changed files with 80 additions and 11 deletions

View file

@ -23,6 +23,8 @@ The repository now provides:
non-executing artifact-verification report, including the Rayforge pin and non-executing artifact-verification report, including the Rayforge pin and
bundle, installer, addon-verification and source checkpoint-manifest fields; bundle, installer, addon-verification and source checkpoint-manifest fields;
- a fail-closed manual desktop-acceptance report and validator; - 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; - pinned upstream contract/license manifests and a drift checker;
- 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

View file

@ -137,11 +137,12 @@ python scripts/validate_m1_manual_acceptance.py `
``` ```
The validator verifies the kit manifest, repository commit, artifact report, The validator verifies the kit manifest, repository commit, artifact report,
evidence hashes and relative paths. It reports evidence hashes and relative paths. It reports `incomplete` for any unrun or
`incomplete` for any unrun or blocked scenario and `failed` for a failed step, blocked scenario, `ready_for_review` when every operator step passes but the
connected hardware, non-isolated app data, changed evidence or any stop review record is still empty, and `failed` for a failed step, connected
condition. Even a complete `passed` report still requires human review and hardware, non-isolated app data, changed evidence, malformed review or any
does not authorize hardware access. 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 ## Remaining external gates

View file

@ -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 rejects omitted manifest entries and extra files hidden in the packaged addon
directory. directory.
The manual checklist now also has a versioned, fail-closed report template and The manual checklist now also has a versioned, fail-closed report template and
validator. Missing steps remain incomplete, stop conditions fail the run, and validator. Missing steps remain incomplete, a complete operator run remains
hashed screenshots stay outside the public repository pending sanitization. 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 A reproducible kit generator copies only allowlisted synthetic inputs into the
ignored build area, records their hashes and refuses to overwrite an existing 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 test run. Its schema-2 provenance chain binds the editable manual report to the

View file

@ -81,7 +81,8 @@ evidence for:
archive safety, sizes, hashes, the pinned Rayforge revision and every archive safety, sizes, hashes, the pinned Rayforge revision and every
packaged addon source while retaining the manual GUI gate; packaged addon source while retaining the manual GUI gate;
- a versioned manual desktop-acceptance template and validator that cannot - 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 - a deterministic acceptance-kit generator that copies only four allowlisted
public template/input sources, hashes the three immutable inputs, refuses to public template/input sources, hashes the three immutable inputs, refuses to
overwrite an existing run directory, and binds the report to the exact kit, overwrite an existing run directory, and binds the report to the exact kit,

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from datetime import datetime
import hashlib import hashlib
import json import json
import re import re
@ -171,6 +172,26 @@ def _validate_artifact_report(report_root: Path, binding: dict) -> list[str]:
return errors 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: def validate(report_path: Path) -> dict:
report_path = report_path.resolve() report_path = report_path.resolve()
report = json.loads(report_path.read_text(encoding="utf-8-sig")) 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) artifact_errors = _validate_artifact_report(report_path.parent, artifact)
errors.extend(artifact_errors) errors.extend(artifact_errors)
errors.extend(_validate_evidence(report_path.parent, report.get("evidence", []))) errors.extend(_validate_evidence(report_path.parent, report.get("evidence", [])))
reviewed, review_errors = _review_state(report.get("review", {}))
errors.extend(review_errors)
explicit_failure = ( explicit_failure = (
any(step.get("status") == "failed" for step in steps) 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 environment.get("fresh_app_data") is False
or artifact.get("status") == "failed" or artifact.get("status") == "failed"
) )
complete = ( operator_complete = (
not errors not errors
and step_ids == list(STEP_IDS) and step_ids == list(STEP_IDS)
and all(step.get("status") == "passed" for step in steps) 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 artifact.get("status") == "passed"
and not artifact_errors 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 { return {
"status": status, "status": status,
"schema_valid": not errors, "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), "passed_steps": sum(step.get("status") == "passed" for step in steps),
"required_steps": len(STEP_IDS), "required_steps": len(STEP_IDS),
"hardware_access_authorized": False, "hardware_access_authorized": False,
"manual_review_required": True, "review_recorded": reviewed,
"manual_review_required": status != "passed",
} }

View file

@ -99,6 +99,11 @@ class M1ManualAcceptanceTests(unittest.TestCase):
"path": "evidence/offline-state.png", "path": "evidence/offline-state.png",
"sha256": hashlib.sha256(screenshot.read_bytes()).hexdigest(), "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 return report
def test_template_is_valid_but_incomplete(self): def test_template_is_valid_but_incomplete(self):
@ -118,7 +123,34 @@ class M1ManualAcceptanceTests(unittest.TestCase):
result = validate(self._write(root, self._passing(root))) result = validate(self._write(root, self._passing(root)))
self.assertEqual(result["status"], "passed") self.assertEqual(result["status"], "passed")
self.assertFalse(result["hardware_access_authorized"]) 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.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): def test_any_stop_condition_fails_checkpoint(self):
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: