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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue