From 0c3ea1f167a42aaa5a8a955fbe7b1cc38d01bcb7 Mon Sep 17 00:00:00 2001 From: datacas <4690232+datacas@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:48:18 +0100 Subject: [PATCH 1/3] fix(history): plan projection tolerates malformed evidence, attempts and items (#469) attempts_normalized is no longer persisted, non-object evidence entries are ignored and a non-list items value projects as empty, instead of raising AttributeError and losing a finished stage's report. Closes #469 --- docs/execution-plan.md | 4 +++- scripts/cycle_history.py | 14 +++++++++++--- tests/test_verification_decisions.py | 23 +++++++++++++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/docs/execution-plan.md b/docs/execution-plan.md index d5cc116..ca7e9cc 100644 --- a/docs/execution-plan.md +++ b/docs/execution-plan.md @@ -162,7 +162,9 @@ Schema 25 stores append-only revisions in `cycle_execution_plans` in the history database. The version-1 metadata projection removes prompts, criterion prose, reasoning, commands and output. Commands become SHA-256 bindings; criteria become stable `AC-n` identities. Check histories retain HEAD, results, closed cause -tokens and evidence fingerprints. Canonical JSON uses sorted keys, compact +tokens and evidence fingerprints; the raw reported `attempts` kept as +`attempts_normalized` and any evidence entry that is not an object are not +persisted. Canonical JSON uses sorted keys, compact separators and ASCII escaping; its SHA-256 is `digest`. Every stage receives `plan_id`, `revision` and `digest` for its schema-2 receipt. A receipt supplements the existing attempt, head and `REV-*` contracts; it grants no execution rights. diff --git a/scripts/cycle_history.py b/scripts/cycle_history.py index f478504..434d150 100644 --- a/scripts/cycle_history.py +++ b/scripts/cycle_history.py @@ -259,7 +259,8 @@ def clean(value, key=""): if key in {"id", "executor", "profile", "context"} and isinstance(value, str): return persisted_token(value) if key == "items": - return [f"AC-{i}" for i in range(1, len(value) + 1)] + return ([f"AC-{i}" for i in range(1, len(value) + 1)] + if isinstance(value, (list, tuple)) else []) if key == "cause": if value in {"defect", "blocked", "omitted", "missing_tool", "inconclusive", "environment", "stale", "unverified", "time_limit", ""}: @@ -278,12 +279,19 @@ def clean(value, key=""): conclusion = value.get("verification_conclusion") return ({"verification_conclusion": conclusion} if conclusion in { "verified", "verified_with_reservations", "not_verified", "failed"} else {}) - if key in {"attempts", "review"}: + if key in {"attempts", "attempts_normalized", "review"}: + # attempts_normalized keeps the raw reported attempts, with their + # prose and output; the projection never carries either. return [] if key == "attempts" else {} if key == "evidence": + # Only structured entries carry projectable fields; a prose entry + # (a reviewer may report evidence as plain strings) has none. + if not isinstance(value, (list, tuple)): + return [] return [{k: clean(v, k) for k, v in item.items() if k in {"command", "level", "exit_code", "executed", "test_sha256", - "output_sha256", "context_sha256", "limit_seconds"}} for item in value] + "output_sha256", "context_sha256", "limit_seconds"}} + for item in value if isinstance(item, dict)] if isinstance(value, dict): return {k: clean(v, k) for k, v in value.items()} if isinstance(value, (list, tuple)): diff --git a/tests/test_verification_decisions.py b/tests/test_verification_decisions.py index 0c95f1d..b0658e9 100644 --- a/tests/test_verification_decisions.py +++ b/tests/test_verification_decisions.py @@ -122,6 +122,29 @@ def test_timeout_records_command_limit_and_evidence(self): self.assertEqual(restored.verification.results[-1].cause, "time_limit") self.assertEqual(restored.verification.results[-1].evidence[0]["limit_seconds"], 600) + def test_projection_ignores_prose_evidence_entries(self): + entry = {"command": self.check.command, "exit_code": 1, "executed": 1} + payload = {"verification": {"results": [ + {"id": "x", "evidence": ["reviewer prose", entry, 3]}, + {"id": "y", "evidence": "plain text"}]}} + projected = plan_projection(SimpleNamespace(to_dict=lambda: payload)) + results = projected["plan"]["verification"]["results"] + self.assertEqual(len(results[0]["evidence"]), 1) + self.assertEqual(results[0]["evidence"][0]["exit_code"], 1) + self.assertEqual(results[1]["evidence"], []) + malformed = {"criteria": {"items": 3}, "x": {"items": ["a", "b"]}} + projected = plan_projection(SimpleNamespace(to_dict=lambda: malformed))["plan"] + self.assertEqual((projected["criteria"]["items"], projected["x"]["items"]), + ([], ["AC-1", "AC-2"])) + + def test_projection_drops_raw_normalized_attempts(self): + raw = {"type": "array", "value": [{"command": "pytest", "evidence": "raw output"}]} + payload = {"verification": {"results": [{"id": "x", "attempts": [{"note": "n"}], + "attempts_normalized": raw}]}} + result = plan_projection(SimpleNamespace(to_dict=lambda: payload) + )["plan"]["verification"]["results"][0] + self.assertEqual((result["attempts"], result["attempts_normalized"]), ([], {})) + def test_terminal_status_is_non_successful_and_serializable(self): self.assertEqual(telemetry.SCHEMA_VERSION, 29) self.assertEqual(cycle_status._result_icon("VERIFICATION_INCOMPLETE"), "⚠️") From 3e371cecd49f5def3492bf5ec53d74d44fb34305 Mon Sep 17 00:00:00 2001 From: datacas <4690232+datacas@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:55:43 +0100 Subject: [PATCH 2/3] test(fast-path): scan a tracked-file snapshot so untracked local files cannot fail it (#469) --- tests/test_fast_path.py | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/tests/test_fast_path.py b/tests/test_fast_path.py index 4ecccc3..35c2b8e 100644 --- a/tests/test_fast_path.py +++ b/tests/test_fast_path.py @@ -1,7 +1,9 @@ from __future__ import annotations +import contextlib import json import os +import shutil import subprocess import sys import tempfile @@ -15,6 +17,29 @@ import fast_path # noqa: E402 +@contextlib.contextmanager +def tracked_snapshot(): + """A copy of the files Git tracks, so a scan sees what CI sees. + + The privacy scan walks every file under its root. A developer's working tree + also holds untracked notes and ignored worktrees that CI never has, and they + must not decide whether the scan itself works. Without Git (an unpacked + archive) the root itself is the snapshot. + """ + listed = subprocess.run(["git", "ls-files", "-z"], cwd=ROOT, capture_output=True, check=False) + if listed.returncode != 0: + yield ROOT + return + with tempfile.TemporaryDirectory() as directory: + for name in listed.stdout.decode("utf-8").split("\0"): + source = ROOT / name + if name and source.is_file(): + target = Path(directory) / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + yield Path(directory) + + def git(directory: Path, *arguments: str) -> str: # Identity is set per command: a CI runner has no global one. result = subprocess.run( @@ -430,13 +455,14 @@ def test_every_job_has_a_label_and_validation_lists(self) -> None: class CommandTests(unittest.TestCase): - def run_script(self, *arguments: str, **environment: str) -> subprocess.CompletedProcess: + def run_script(self, *arguments: str, root: Path = ROOT, + **environment: str) -> subprocess.CompletedProcess: env = {key: value for key, value in os.environ.items() if not key.startswith(("GITHUB_", "FAST_PATH_"))} env.update(environment) return subprocess.run( - [sys.executable, str(ROOT / "scripts" / "fast_path.py"), *arguments], - capture_output=True, text=True, env=env, cwd=ROOT, + [sys.executable, str(root / "scripts" / "fast_path.py"), *arguments], + capture_output=True, text=True, env=env, cwd=root, ) def test_classify_fails_closed_and_still_exits_successfully(self) -> None: @@ -459,11 +485,11 @@ def test_run_fast_rejects_a_test_name_that_is_not_a_module(self) -> None: self.assertNotEqual(0, result.returncode) def test_run_fast_runs_the_scan_and_the_mapped_tests(self) -> None: - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, tracked_snapshot() as snapshot: summary = Path(directory) / "summary.md" result = self.run_script( - "run-fast", "--job", "validate", "--scan", + "run-fast", "--job", "validate", "--scan", root=snapshot, FAST_PATH_TESTS="test_spike_fixture_runner", GITHUB_STEP_SUMMARY=str(summary), ) @@ -524,7 +550,8 @@ def test_the_scan_is_reusable_and_reports_what_the_validator_reports(self) -> No any("possible private data in docs/guide.md" in error for error in normalized_errors) ) self.assertTrue(any("metadata sidecar" in error for error in normalized_errors)) - self.assertEqual([], module.scan_for_private_data(ROOT)) + with tracked_snapshot() as snapshot: + self.assertEqual([], module.scan_for_private_data(snapshot)) if __name__ == "__main__": From e56257a737a2160a9576459e91c09aed66f95fe6 Mon Sep 17 00:00:00 2001 From: datacas <4690232+datacas@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:04:03 +0100 Subject: [PATCH 3/3] test(validate-package): validate a tracked-file copy of the package (#469) --- tests/test_validate_package.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_validate_package.py b/tests/test_validate_package.py index 8026d6c..4930c55 100644 --- a/tests/test_validate_package.py +++ b/tests/test_validate_package.py @@ -57,7 +57,8 @@ def assert_error_contains(self, errors: list[str], expected: str) -> None: ) def test_current_package_is_valid(self) -> None: - self.assertEqual([], VALIDATOR.validate_package(ROOT)) + # A tracked-file copy: untracked local files are not part of the package. + self.assertEqual([], VALIDATOR.validate_package(self.copy_package())) def test_authorization_contract_rejects_missing_or_expanded_actions(self) -> None: package = self.copy_package()