test: require human review for M1 acceptance
This commit is contained in:
parent
3c26e1bdab
commit
a5a8e4086d
6 changed files with 80 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue