From 73dbb3f25e292a026a39c654e1888fbbf0e4b62b Mon Sep 17 00:00:00 2001 From: ProfRandom92 <159939812+ProfRandom92@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:11:38 +0200 Subject: [PATCH 1/8] feat(evidence): add event serialization baseline --- examples/workspace/evidence-event.sample.json | 10 +++ modules/evidence/evidence.py | 15 +++- modules/validation/workspace_validation.py | 4 + schemas/evidence-event.v0.schema.json | 32 +++++++ tests/cli/test_cli_entrypoint.py | 2 +- tests/doctor/test_doctor.py | 2 +- tests/evidence/test_evidence_serialization.py | 89 +++++++++++++++++++ 7 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 examples/workspace/evidence-event.sample.json create mode 100644 schemas/evidence-event.v0.schema.json create mode 100644 tests/evidence/test_evidence_serialization.py diff --git a/examples/workspace/evidence-event.sample.json b/examples/workspace/evidence-event.sample.json new file mode 100644 index 0000000..bcaa6ab --- /dev/null +++ b/examples/workspace/evidence-event.sample.json @@ -0,0 +1,10 @@ +{ + "index": 0, + "type": "run.started", + "payload": { + "command": "comptext evidence verify --sample", + "mode": "sample" + }, + "previous_hash": "0000000000000000000000000000000000000000000000000000000000000000", + "hash": "75e38380712627db05eb0d187f9e2a7ec809c7ab3ea4255bed0c9a96f55a5aa7" +} diff --git a/modules/evidence/evidence.py b/modules/evidence/evidence.py index e9de4b7..2e7f7f2 100644 --- a/modules/evidence/evidence.py +++ b/modules/evidence/evidence.py @@ -9,12 +9,23 @@ GENESIS_HASH = "0" * 64 -def _canonical_json(value: Any) -> str: +def canonical_serialize(value: Any) -> str: + """Return deterministic canonical JSON string with sorted keys and no optional whitespace.""" return json.dumps(value, separators=(",", ":"), sort_keys=True) +def hash_event_content(event_without_hash: dict[str, Any]) -> str: + """Compute the SHA-256 hash of the canonical serialized event content without the hash field.""" + content = {k: v for k, v in event_without_hash.items() if k != "hash"} + return hashlib.sha256(canonical_serialize(content).encode("utf-8")).hexdigest() + + +def _canonical_json(value: Any) -> str: + return canonical_serialize(value) + + def _hash_event_content(event_without_hash: dict[str, Any]) -> str: - return hashlib.sha256(_canonical_json(event_without_hash).encode("utf-8")).hexdigest() + return hash_event_content(event_without_hash) def _validate_payload(payload: Any) -> None: diff --git a/modules/validation/workspace_validation.py b/modules/validation/workspace_validation.py index e693e82..319d63a 100644 --- a/modules/validation/workspace_validation.py +++ b/modules/validation/workspace_validation.py @@ -21,6 +21,10 @@ Path("schemas/reflection-gate.v0.schema.json"), Path("examples/workspace/reflection-gate.sample.json"), ), + ( + Path("schemas/evidence-event.v0.schema.json"), + Path("examples/workspace/evidence-event.sample.json"), + ), ) diff --git a/schemas/evidence-event.v0.schema.json b/schemas/evidence-event.v0.schema.json new file mode 100644 index 0000000..3f981b8 --- /dev/null +++ b/schemas/evidence-event.v0.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://comptext.local/schemas/evidence-event.v0.schema.json", + "title": "CompText Evidence Event v0", + "description": "Local dry-run contract for explicit evidence event log entry.", + "type": "object", + "required": [ + "index", + "type", + "payload", + "previous_hash", + "hash" + ], + "additionalProperties": false, + "properties": { + "index": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "payload": { + "type": "object" + }, + "previous_hash": { + "type": "string" + }, + "hash": { + "type": "string" + } + } +} diff --git a/tests/cli/test_cli_entrypoint.py b/tests/cli/test_cli_entrypoint.py index 9577cce..d86058b 100644 --- a/tests/cli/test_cli_entrypoint.py +++ b/tests/cli/test_cli_entrypoint.py @@ -26,7 +26,7 @@ def test_cli_validate_workspace_dry_run(capsys) -> None: assert run(["validate", "workspace", "--dry-run"], repo_root=ROOT) == 0 output = json.loads(capsys.readouterr().out) assert output["mode"] == "dry-run" - assert len(output["results"]) == 3 + assert len(output["results"]) == 4 for result in output["results"]: assert result["status"] == "valid" assert "schema" in result diff --git a/tests/doctor/test_doctor.py b/tests/doctor/test_doctor.py index 3ff3000..82d3b1a 100644 --- a/tests/doctor/test_doctor.py +++ b/tests/doctor/test_doctor.py @@ -15,7 +15,7 @@ def test_doctor_reports_required_project_files() -> None: assert all(result["project_files"].values()) assert "workspace_validation" in result assert result["workspace_validation"]["ok"] is True - assert len(result["workspace_validation"]["results"]) == 3 + assert len(result["workspace_validation"]["results"]) == 4 assert result["ok"] is True diff --git a/tests/evidence/test_evidence_serialization.py b/tests/evidence/test_evidence_serialization.py new file mode 100644 index 0000000..882eb4a --- /dev/null +++ b/tests/evidence/test_evidence_serialization.py @@ -0,0 +1,89 @@ +from pathlib import Path +import json +import pytest + +from modules.evidence.evidence import ( + canonical_serialize, + hash_event_content, + build_sample_evidence, + verify_evidence_chain, +) +from modules.validation.workspace_validation import validate_with_additional_properties + +ROOT = Path(__file__).resolve().parents[2] +SCHEMA_PATH = ROOT / "schemas" / "evidence-event.v0.schema.json" +SAMPLE_PATH = ROOT / "examples" / "workspace" / "evidence-event.sample.json" + + +def test_canonical_serialize_sorts_keys_and_omits_whitespace() -> None: + # Verify that different key orders produce identical output + dict_a = {"z": 1, "a": 2, "m": 3} + dict_b = {"a": 2, "m": 3, "z": 1} + + serialized_a = canonical_serialize(dict_a) + serialized_b = canonical_serialize(dict_b) + + assert serialized_a == serialized_b + assert serialized_a == '{"a":2,"m":3,"z":1}' # No spaces + + +def test_schema_required_fields() -> None: + assert SCHEMA_PATH.exists() + schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + + required = schema.get("required", []) + expected_required = {"index", "type", "payload", "previous_hash", "hash"} + assert set(required) == expected_required + + +def test_schema_rejects_unknown_fields() -> None: + schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + sample = json.loads(SAMPLE_PATH.read_text(encoding="utf-8")) + + # Valid first + validate_with_additional_properties(schema, sample) + + # Invalid with extra key + sample["extra_unsupported_key"] = "value" + with pytest.raises(ValueError, match="has unknown extra fields"): + validate_with_additional_properties(schema, sample) + + +def test_hash_event_content_stability() -> None: + sample_event = { + "index": 0, + "type": "run.started", + "payload": { + "command": "comptext evidence verify --sample", + "mode": "sample" + }, + "previous_hash": "0000000000000000000000000000000000000000000000000000000000000000", + } + # Expected value pre-calculated for the first event in build_sample_evidence + expected_hash = "75e38380712627db05eb0d187f9e2a7ec809c7ab3ea4255bed0c9a96f55a5aa7" + + computed_hash = hash_event_content(sample_event) + assert computed_hash == expected_hash + + # If the hash field is present, hash_event_content should ignore it + sample_event_with_hash = sample_event.copy() + sample_event_with_hash["hash"] = "some_incorrect_hash_to_ignore" + computed_hash_with_ignored = hash_event_content(sample_event_with_hash) + assert computed_hash_with_ignored == expected_hash + + +def test_sample_payload_secret_guardrail() -> None: + # A simple guardrail check for sample fixtures to verify they don't contain common secret patterns. + # Note: This is a sample-fixture guardrail only and does not claim to be a general secret scanner. + sample = json.loads(SAMPLE_PATH.read_text(encoding="utf-8")) + payload_str = json.dumps(sample.get("payload", {})) + + secret_indicators = ["api_key", "password", "token", "secret", "credential", "auth_token"] + for indicator in secret_indicators: + assert indicator not in payload_str.lower() + + +def test_existing_sample_chain_verification_passes() -> None: + events = build_sample_evidence() + result = verify_evidence_chain(events) + assert result["ok"] is True From e60cc6262ce1a770906b30dcb0b24efffe92b005 Mon Sep 17 00:00:00 2001 From: ProfRandom92 <159939812+ProfRandom92@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:34:03 +0200 Subject: [PATCH 2/8] feat(evidence): add offline chain file verification --- examples/workspace/evidence-chain.sample.json | 33 ++++++++++++ modules/cli/cli_entrypoint.py | 11 ++-- modules/evidence/evidence.py | 23 ++++++++ tests/cli/test_cli_entrypoint.py | 49 +++++++++++++++++ tests/evidence/test_evidence.py | 52 ++++++++++++++++++- 5 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 examples/workspace/evidence-chain.sample.json diff --git a/examples/workspace/evidence-chain.sample.json b/examples/workspace/evidence-chain.sample.json new file mode 100644 index 0000000..b36214e --- /dev/null +++ b/examples/workspace/evidence-chain.sample.json @@ -0,0 +1,33 @@ +[ + { + "index": 0, + "type": "run.started", + "payload": { + "command": "comptext evidence verify --sample", + "mode": "sample" + }, + "previous_hash": "0000000000000000000000000000000000000000000000000000000000000000", + "hash": "75e38380712627db05eb0d187f9e2a7ec809c7ab3ea4255bed0c9a96f55a5aa7" + }, + { + "index": 1, + "type": "tool.completed", + "payload": { + "tool": "sample-check", + "status": "ok" + }, + "previous_hash": "75e38380712627db05eb0d187f9e2a7ec809c7ab3ea4255bed0c9a96f55a5aa7", + "hash": "f2ff96cbd5d3deb8fcc1b480235ae43648cbab1055369934cfcd48d9454125c3" + }, + { + "index": 2, + "type": "run.completed", + "payload": { + "status": "verified", + "network": "not_called", + "providers": "not_called" + }, + "previous_hash": "f2ff96cbd5d3deb8fcc1b480235ae43648cbab1055369934cfcd48d9454125c3", + "hash": "4b686649a35a7b9d69828db72a679e868f2ddf2301052e80c10a1e58c8a65d82" + } +] diff --git a/modules/cli/cli_entrypoint.py b/modules/cli/cli_entrypoint.py index 95fffb0..a8a4541 100644 --- a/modules/cli/cli_entrypoint.py +++ b/modules/cli/cli_entrypoint.py @@ -8,7 +8,7 @@ from typing import Any from modules.doctor.doctor import run_doctor -from modules.evidence.evidence import verify_sample_evidence +from modules.evidence.evidence import verify_file_evidence, verify_sample_evidence from modules.gateway.gateway import ( dry_run_gateway_response, get_gateway_health, @@ -53,7 +53,9 @@ def build_parser() -> argparse.ArgumentParser: evidence = subparsers.add_parser("evidence") evidence_subparsers = evidence.add_subparsers(dest="evidence_command", required=True) evidence_verify = evidence_subparsers.add_parser("verify") - evidence_verify.add_argument("--sample", action="store_true", required=True) + group = evidence_verify.add_mutually_exclusive_group(required=True) + group.add_argument("--sample", action="store_true") + group.add_argument("--file", type=str) run_parser = subparsers.add_parser("run") run_subparsers = run_parser.add_subparsers(dest="run_command", required=True) @@ -140,7 +142,10 @@ def run(argv: list[str] | None = None, *, repo_root: Path | None = None) -> int: _print_json(result) return 0 if result.get("ok") is True else 1 if args.command == "evidence" and args.evidence_command == "verify": - result = verify_sample_evidence(sample=args.sample) + if args.sample: + result = verify_sample_evidence(sample=args.sample) + else: + result = verify_file_evidence(filepath=args.file) _print_json(result) return 0 if result.get("ok") is True else 1 if args.command == "run" and args.run_command == "sample": diff --git a/modules/evidence/evidence.py b/modules/evidence/evidence.py index 2e7f7f2..f995465 100644 --- a/modules/evidence/evidence.py +++ b/modules/evidence/evidence.py @@ -4,6 +4,7 @@ import hashlib import json +from pathlib import Path from typing import Any GENESIS_HASH = "0" * 64 @@ -112,3 +113,25 @@ def verify_sample_evidence(*, sample: bool = True) -> dict[str, Any]: "providers": "not_called", **result, } + + +def verify_file_evidence(*, filepath: str | Path) -> dict[str, Any]: + """Load and verify a local JSON file containing an array of evidence events.""" + path = Path(filepath) + if not path.exists(): + raise FileNotFoundError(f"Evidence file not found: {filepath}") + + with open(path, "r", encoding="utf-8") as f: + events = json.load(f) + + if not isinstance(events, list): + raise ValueError("Evidence file content must be a JSON array") + + result = verify_evidence_chain(events) + return { + "command": f"comptext evidence verify --file {filepath}", + "mode": "file", + "network": "not_called", + "providers": "not_called", + **result, + } diff --git a/tests/cli/test_cli_entrypoint.py b/tests/cli/test_cli_entrypoint.py index d86058b..51bc1fd 100644 --- a/tests/cli/test_cli_entrypoint.py +++ b/tests/cli/test_cli_entrypoint.py @@ -85,6 +85,55 @@ def test_cli_evidence_verify_sample(capsys) -> None: assert output["providers"] == "not_called" +def test_cli_evidence_verify_file(capsys) -> None: + sample_file = "examples/workspace/evidence-chain.sample.json" + assert run(["evidence", "verify", "--file", sample_file]) == 0 + output = json.loads(capsys.readouterr().out) + assert output["command"] == f"comptext evidence verify --file {sample_file}" + assert output["mode"] == "file" + assert output["ok"] is True + assert output["events"] == 3 + assert output["network"] == "not_called" + assert output["providers"] == "not_called" + + +def test_cli_evidence_verify_file_missing(capsys) -> None: + assert run(["evidence", "verify", "--file", "does_not_exist.json"]) == 1 + output = json.loads(capsys.readouterr().out) + assert output["ok"] is False + assert output["error"]["type"] == "FileNotFoundError" + + +def test_cli_evidence_verify_conflict() -> None: + with pytest.raises(SystemExit): + run(["evidence", "verify", "--sample", "--file", "examples/workspace/evidence-chain.sample.json"]) + + +def test_cli_evidence_verify_neither() -> None: + with pytest.raises(SystemExit): + run(["evidence", "verify"]) + + +def test_cli_evidence_verify_invalid_json(tmp_path: Path, capsys) -> None: + file_path = tmp_path / "bad.json" + file_path.write_text("{bad json", encoding="utf-8") + assert run(["evidence", "verify", "--file", str(file_path)]) == 1 + output = json.loads(capsys.readouterr().out) + assert output["ok"] is False + assert output["error"]["type"] == "JSONDecodeError" + + +def test_cli_evidence_verify_non_array_json(tmp_path: Path, capsys) -> None: + file_path = tmp_path / "non_array.json" + file_path.write_text('{"a": 1}', encoding="utf-8") + assert run(["evidence", "verify", "--file", str(file_path)]) == 1 + output = json.loads(capsys.readouterr().out) + assert output["ok"] is False + assert output["error"]["type"] == "ValueError" + assert "must be a JSON array" in output["error"]["message"] + + + def test_cli_run_sample_dry_run(capsys) -> None: assert run(["run", "sample", "--dry-run"]) == 0 output = json.loads(capsys.readouterr().out) diff --git a/tests/evidence/test_evidence.py b/tests/evidence/test_evidence.py index 3089531..a5505e1 100644 --- a/tests/evidence/test_evidence.py +++ b/tests/evidence/test_evidence.py @@ -1,6 +1,12 @@ +from pathlib import Path import pytest -from modules.evidence.evidence import build_sample_evidence, verify_evidence_chain, verify_sample_evidence +from modules.evidence.evidence import ( + build_sample_evidence, + verify_evidence_chain, + verify_sample_evidence, + verify_file_evidence, +) def test_build_sample_evidence_is_deterministic() -> None: @@ -115,3 +121,47 @@ def test_evidence_rejects_embedded_workspace_payload_objects(ref_field: str) -> result = verify_evidence_chain(events) assert result["ok"] is False assert f"payload field {ref_field} must be a string ref" in result["error"] + + +def test_verify_file_evidence_valid() -> None: + sample_file = Path(__file__).resolve().parents[2] / "examples" / "workspace" / "evidence-chain.sample.json" + result = verify_file_evidence(filepath=sample_file) + assert result["ok"] is True + assert result["mode"] == "file" + assert result["events"] == 3 + assert len(result["root_hash"]) == 64 + + +def test_verify_file_evidence_invalid_chain(tmp_path: Path) -> None: + events = build_sample_evidence() + events[1]["index"] = 99 + + file_path = tmp_path / "invalid_chain.json" + import json + file_path.write_text(json.dumps(events), encoding="utf-8") + + result = verify_file_evidence(filepath=file_path) + assert result["ok"] is False + assert "event index mismatch" in result["error"] + + +def test_verify_file_evidence_missing() -> None: + with pytest.raises(FileNotFoundError, match="Evidence file not found"): + verify_file_evidence(filepath="this_file_does_not_exist.json") + + +def test_verify_file_evidence_invalid_json(tmp_path: Path) -> None: + file_path = tmp_path / "invalid_json.json" + file_path.write_text("{bad json", encoding="utf-8") + + import json + with pytest.raises(json.JSONDecodeError): + verify_file_evidence(filepath=file_path) + + +def test_verify_file_evidence_non_array_json(tmp_path: Path) -> None: + file_path = tmp_path / "non_array.json" + file_path.write_text('{"a": 1}', encoding="utf-8") + + with pytest.raises(ValueError, match="must be a JSON array"): + verify_file_evidence(filepath=file_path) From ef7d79ce128227bc66e24474e89939c4b03ca46b Mon Sep 17 00:00:00 2001 From: ProfRandom92 <159939812+ProfRandom92@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:40:15 +0200 Subject: [PATCH 3/8] feat(evidence): validate git commit refs in event payloads --- modules/evidence/evidence.py | 7 ++++ tests/evidence/test_evidence.py | 72 +++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/modules/evidence/evidence.py b/modules/evidence/evidence.py index f995465..cbf91c1 100644 --- a/modules/evidence/evidence.py +++ b/modules/evidence/evidence.py @@ -36,6 +36,13 @@ def _validate_payload(payload: Any) -> None: val = payload[ref_field] if not isinstance(val, str): raise ValueError(f"payload field {ref_field} must be a string ref, not {type(val).__name__}") + if "git_commit_ref" in payload: + val = payload["git_commit_ref"] + if not isinstance(val, str): + raise ValueError(f"payload field git_commit_ref must be a string ref, not {type(val).__name__}") + if len(val) != 40 or not all(c in "0123456789abcdefABCDEF" for c in val): + raise ValueError("payload field git_commit_ref must be a 40-character hex string") + def _make_event(*, index: int, event_type: str, payload: dict[str, Any], previous_hash: str) -> dict[str, Any]: diff --git a/tests/evidence/test_evidence.py b/tests/evidence/test_evidence.py index a5505e1..34ace72 100644 --- a/tests/evidence/test_evidence.py +++ b/tests/evidence/test_evidence.py @@ -165,3 +165,75 @@ def test_verify_file_evidence_non_array_json(tmp_path: Path) -> None: with pytest.raises(ValueError, match="must be a JSON array"): verify_file_evidence(filepath=file_path) + + +def test_evidence_payload_valid_git_commit_ref_lowercase() -> None: + events = build_sample_evidence() + events[0]["payload"]["git_commit_ref"] = "a" * 40 + from modules.evidence.evidence import _hash_event_content + events[0].pop("hash") + events[0]["hash"] = _hash_event_content(events[0]) + + events[1]["previous_hash"] = events[0]["hash"] + events[1].pop("hash") + events[1]["hash"] = _hash_event_content(events[1]) + events[2]["previous_hash"] = events[1]["hash"] + events[2].pop("hash") + events[2]["hash"] = _hash_event_content(events[2]) + + result = verify_evidence_chain(events) + assert result["ok"] is True + + +def test_evidence_payload_valid_git_commit_ref_uppercase() -> None: + events = build_sample_evidence() + events[0]["payload"]["git_commit_ref"] = "A" * 40 + from modules.evidence.evidence import _hash_event_content + events[0].pop("hash") + events[0]["hash"] = _hash_event_content(events[0]) + + events[1]["previous_hash"] = events[0]["hash"] + events[1].pop("hash") + events[1]["hash"] = _hash_event_content(events[1]) + events[2]["previous_hash"] = events[1]["hash"] + events[2].pop("hash") + events[2]["hash"] = _hash_event_content(events[2]) + + result = verify_evidence_chain(events) + assert result["ok"] is True + + +def test_evidence_payload_git_commit_ref_non_string() -> None: + events = build_sample_evidence() + events[0]["payload"]["git_commit_ref"] = 12345 + from modules.evidence.evidence import _hash_event_content + events[0].pop("hash") + events[0]["hash"] = _hash_event_content(events[0]) + + result = verify_evidence_chain(events) + assert result["ok"] is False + assert "must be a string ref" in result["error"] + + +def test_evidence_payload_git_commit_ref_wrong_length() -> None: + events = build_sample_evidence() + events[0]["payload"]["git_commit_ref"] = "a" * 39 + from modules.evidence.evidence import _hash_event_content + events[0].pop("hash") + events[0]["hash"] = _hash_event_content(events[0]) + + result = verify_evidence_chain(events) + assert result["ok"] is False + assert "must be a 40-character hex string" in result["error"] + + +def test_evidence_payload_git_commit_ref_non_hex() -> None: + events = build_sample_evidence() + events[0]["payload"]["git_commit_ref"] = "g" * 40 + from modules.evidence.evidence import _hash_event_content + events[0].pop("hash") + events[0]["hash"] = _hash_event_content(events[0]) + + result = verify_evidence_chain(events) + assert result["ok"] is False + assert "must be a 40-character hex string" in result["error"] From 9d8773bcf77907f4ffc0e51b8ea0a17c9ac557f7 Mon Sep 17 00:00:00 2001 From: ProfRandom92 <159939812+ProfRandom92@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:43:37 +0200 Subject: [PATCH 4/8] feat(evidence): add state log entry schema --- .../evidence-state-log-entry.sample.json | 7 ++ modules/validation/schema_validator.py | 5 ++ modules/validation/workspace_validation.py | 4 + .../evidence-state-log-entry.v0.schema.json | 36 +++++++++ tests/cli/test_cli_entrypoint.py | 2 +- tests/doctor/test_doctor.py | 2 +- .../test_evidence_state_log_entry_v0.py | 76 +++++++++++++++++++ 7 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 examples/workspace/evidence-state-log-entry.sample.json create mode 100644 schemas/evidence-state-log-entry.v0.schema.json create mode 100644 tests/validation/test_evidence_state_log_entry_v0.py diff --git a/examples/workspace/evidence-state-log-entry.sample.json b/examples/workspace/evidence-state-log-entry.sample.json new file mode 100644 index 0000000..d942aa4 --- /dev/null +++ b/examples/workspace/evidence-state-log-entry.sample.json @@ -0,0 +1,7 @@ +{ + "sequence": 0, + "evidence_event_hash": "75e38380712627db05eb0d187f9e2a7ec809c7ab3ea4255bed0c9a96f55a5aa7", + "git_commit_ref": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + "previous_state_hash": "0000000000000000000000000000000000000000000000000000000000000000", + "state_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" +} diff --git a/modules/validation/schema_validator.py b/modules/validation/schema_validator.py index 7ace877..7457408 100644 --- a/modules/validation/schema_validator.py +++ b/modules/validation/schema_validator.py @@ -43,6 +43,11 @@ def validate_json_schema_instance(schema: dict[str, Any], instance: Any, locatio if "enum" in schema and instance not in schema["enum"]: raise ValueError(f"{location} must be one of {schema['enum']}") + if "pattern" in schema and isinstance(instance, str): + import re + if not re.match(schema["pattern"], instance): + raise ValueError(f"{location} does not match pattern {schema['pattern']}") + if isinstance(instance, dict): for key in schema.get("required", []): if key not in instance: diff --git a/modules/validation/workspace_validation.py b/modules/validation/workspace_validation.py index 319d63a..90b3297 100644 --- a/modules/validation/workspace_validation.py +++ b/modules/validation/workspace_validation.py @@ -25,6 +25,10 @@ Path("schemas/evidence-event.v0.schema.json"), Path("examples/workspace/evidence-event.sample.json"), ), + ( + Path("schemas/evidence-state-log-entry.v0.schema.json"), + Path("examples/workspace/evidence-state-log-entry.sample.json"), + ), ) diff --git a/schemas/evidence-state-log-entry.v0.schema.json b/schemas/evidence-state-log-entry.v0.schema.json new file mode 100644 index 0000000..101d7bf --- /dev/null +++ b/schemas/evidence-state-log-entry.v0.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://comptext.local/schemas/evidence-state-log-entry.v0.schema.json", + "title": "CompText Evidence State Log Entry v0", + "description": "Local dry-run contract for Merkle-like state log entries linking git commits and evidence event hashes.", + "type": "object", + "required": [ + "sequence", + "evidence_event_hash", + "git_commit_ref", + "previous_state_hash", + "state_hash" + ], + "additionalProperties": false, + "properties": { + "sequence": { + "type": "integer" + }, + "evidence_event_hash": { + "type": "string", + "pattern": "^[0-9a-fA-F]{64}$" + }, + "git_commit_ref": { + "type": "string", + "pattern": "^[0-9a-fA-F]{40}$" + }, + "previous_state_hash": { + "type": "string", + "pattern": "^[0-9a-fA-F]{64}$" + }, + "state_hash": { + "type": "string", + "pattern": "^[0-9a-fA-F]{64}$" + } + } +} diff --git a/tests/cli/test_cli_entrypoint.py b/tests/cli/test_cli_entrypoint.py index 51bc1fd..ea517fe 100644 --- a/tests/cli/test_cli_entrypoint.py +++ b/tests/cli/test_cli_entrypoint.py @@ -26,7 +26,7 @@ def test_cli_validate_workspace_dry_run(capsys) -> None: assert run(["validate", "workspace", "--dry-run"], repo_root=ROOT) == 0 output = json.loads(capsys.readouterr().out) assert output["mode"] == "dry-run" - assert len(output["results"]) == 4 + assert len(output["results"]) == 5 for result in output["results"]: assert result["status"] == "valid" assert "schema" in result diff --git a/tests/doctor/test_doctor.py b/tests/doctor/test_doctor.py index 82d3b1a..edf558e 100644 --- a/tests/doctor/test_doctor.py +++ b/tests/doctor/test_doctor.py @@ -15,7 +15,7 @@ def test_doctor_reports_required_project_files() -> None: assert all(result["project_files"].values()) assert "workspace_validation" in result assert result["workspace_validation"]["ok"] is True - assert len(result["workspace_validation"]["results"]) == 4 + assert len(result["workspace_validation"]["results"]) == 5 assert result["ok"] is True diff --git a/tests/validation/test_evidence_state_log_entry_v0.py b/tests/validation/test_evidence_state_log_entry_v0.py new file mode 100644 index 0000000..9f86099 --- /dev/null +++ b/tests/validation/test_evidence_state_log_entry_v0.py @@ -0,0 +1,76 @@ +import json +from pathlib import Path + +import pytest + +from modules.validation.schema_validator import validate_json_schema_instance +from modules.validation.workspace_validation import validate_with_additional_properties + +ROOT = Path(__file__).resolve().parents[2] +SCHEMA_PATH = ROOT / "schemas" / "evidence-state-log-entry.v0.schema.json" +SAMPLE_PATH = ROOT / "examples" / "workspace" / "evidence-state-log-entry.sample.json" + + +def _load_schema() -> dict: + return json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + + +def _load_sample() -> dict: + return json.loads(SAMPLE_PATH.read_text(encoding="utf-8")) + + +def test_schema_and_sample_exist() -> None: + assert SCHEMA_PATH.exists() + assert SAMPLE_PATH.exists() + + +def test_sample_validates_successfully() -> None: + schema = _load_schema() + sample = _load_sample() + validate_with_additional_properties(schema, sample) + + +@pytest.mark.parametrize( + "field", + [ + "sequence", + "evidence_event_hash", + "git_commit_ref", + "previous_state_hash", + "state_hash", + ], +) +def test_missing_required_field_fails_validation(field: str) -> None: + schema = _load_schema() + sample = _load_sample() + sample.pop(field) + + with pytest.raises(ValueError): + validate_with_additional_properties(schema, sample) + + +def test_schema_rejects_unknown_fields() -> None: + schema = _load_schema() + sample = _load_sample() + sample["extra_unsupported_key"] = "some_value" + + with pytest.raises(ValueError, match="has unknown extra fields"): + validate_with_additional_properties(schema, sample) + + +@pytest.mark.parametrize( + "invalid_git_ref", + [ + 12345, # Non-string + "a" * 39, # Wrong length (short) + "a" * 41, # Wrong length (long) + "g" * 40, # Non-hex characters + ] +) +def test_schema_rejects_invalid_git_commit_ref(invalid_git_ref) -> None: + schema = _load_schema() + sample = _load_sample() + sample["git_commit_ref"] = invalid_git_ref + + with pytest.raises(ValueError): + validate_with_additional_properties(schema, sample) From 31212e63dd09295b7788e02c3550e34d6b718aec Mon Sep 17 00:00:00 2001 From: ProfRandom92 <159939812+ProfRandom92@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:55:46 +0200 Subject: [PATCH 5/8] feat(evidence): add state log chain verification --- .../workspace/evidence-state-log.sample.json | 16 ++++ modules/cli/cli_entrypoint.py | 9 +- modules/evidence/evidence.py | 64 +++++++++++++ tests/evidence/test_state_log.py | 93 +++++++++++++++++++ 4 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 examples/workspace/evidence-state-log.sample.json create mode 100644 tests/evidence/test_state_log.py diff --git a/examples/workspace/evidence-state-log.sample.json b/examples/workspace/evidence-state-log.sample.json new file mode 100644 index 0000000..b13a530 --- /dev/null +++ b/examples/workspace/evidence-state-log.sample.json @@ -0,0 +1,16 @@ +[ + { + "sequence": 0, + "evidence_event_hash": "75e38380712627db05eb0d187f9e2a7ec809c7ab3ea4255bed0c9a96f55a5aa7", + "git_commit_ref": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + "previous_state_hash": "0000000000000000000000000000000000000000000000000000000000000000", + "state_hash": "33c34dd11e9d7ea0b6bbd2443f0a2c838ce0b05e6fa7feddc0c770d55349b53f" + }, + { + "sequence": 1, + "evidence_event_hash": "f2ff96cbd5d3deb8fcc1b480235ae43648cbab1055369934cfcd48d9454125c3", + "git_commit_ref": "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0a1", + "previous_state_hash": "33c34dd11e9d7ea0b6bbd2443f0a2c838ce0b05e6fa7feddc0c770d55349b53f", + "state_hash": "685473c529dc5910f21a05bcd0026fe70ca26410c634fb254ceceea55e94317e" + } +] diff --git a/modules/cli/cli_entrypoint.py b/modules/cli/cli_entrypoint.py index a8a4541..73e5558 100644 --- a/modules/cli/cli_entrypoint.py +++ b/modules/cli/cli_entrypoint.py @@ -8,7 +8,7 @@ from typing import Any from modules.doctor.doctor import run_doctor -from modules.evidence.evidence import verify_file_evidence, verify_sample_evidence +from modules.evidence.evidence import verify_file_evidence, verify_file_state_log, verify_sample_evidence from modules.gateway.gateway import ( dry_run_gateway_response, get_gateway_health, @@ -57,6 +57,9 @@ def build_parser() -> argparse.ArgumentParser: group.add_argument("--sample", action="store_true") group.add_argument("--file", type=str) + evidence_verify_state = evidence_subparsers.add_parser("verify-state-log") + evidence_verify_state.add_argument("--file", type=str, required=True) + run_parser = subparsers.add_parser("run") run_subparsers = run_parser.add_subparsers(dest="run_command", required=True) sample_run = run_subparsers.add_parser("sample") @@ -148,6 +151,10 @@ def run(argv: list[str] | None = None, *, repo_root: Path | None = None) -> int: result = verify_file_evidence(filepath=args.file) _print_json(result) return 0 if result.get("ok") is True else 1 + if args.command == "evidence" and args.evidence_command == "verify-state-log": + result = verify_file_state_log(filepath=args.file) + _print_json(result) + return 0 if result.get("ok") is True else 1 if args.command == "run" and args.run_command == "sample": result = run_sample(dry_run=args.dry_run) _print_json(result) diff --git a/modules/evidence/evidence.py b/modules/evidence/evidence.py index cbf91c1..3743aeb 100644 --- a/modules/evidence/evidence.py +++ b/modules/evidence/evidence.py @@ -142,3 +142,67 @@ def verify_file_evidence(*, filepath: str | Path) -> dict[str, Any]: "providers": "not_called", **result, } + + +def verify_state_log_chain(entries: list[dict[str, Any]]) -> dict[str, Any]: + """Verify sequence, previous state hashes, git commit refs, and content hashes in a state log chain.""" + previous_hash = GENESIS_HASH + for expected_sequence, entry in enumerate(entries): + if not isinstance(entry, dict): + return {"ok": False, "error": f"entry at sequence {expected_sequence} must be an object"} + if entry.get("sequence") != expected_sequence: + return {"ok": False, "error": f"entry sequence mismatch at {expected_sequence}"} + if entry.get("previous_state_hash") != previous_hash: + return {"ok": False, "error": f"previous state hash mismatch at sequence {expected_sequence}"} + expected_hash = entry.get("state_hash") + if not isinstance(expected_hash, str): + return {"ok": False, "error": f"entry state hash missing at sequence {expected_sequence}"} + + # Validate git_commit_ref format + git_ref = entry.get("git_commit_ref") + if not isinstance(git_ref, str): + return {"ok": False, "error": f"git_commit_ref at sequence {expected_sequence} must be a string"} + if len(git_ref) != 40 or not all(c in "0123456789abcdefABCDEF" for c in git_ref): + return {"ok": False, "error": f"git_commit_ref at sequence {expected_sequence} must be a 40-character hex string"} + + # Validate evidence_event_hash format + event_hash = entry.get("evidence_event_hash") + if not isinstance(event_hash, str): + return {"ok": False, "error": f"evidence_event_hash at sequence {expected_sequence} must be a string"} + if len(event_hash) != 64 or not all(c in "0123456789abcdefABCDEF" for c in event_hash): + return {"ok": False, "error": f"evidence_event_hash at sequence {expected_sequence} must be a 64-character hex string"} + + # Compute hash of state log entry content (excluding state_hash itself) + entry_without_hash = {k: v for k, v in entry.items() if k != "state_hash"} + actual_hash = hashlib.sha256(canonical_serialize(entry_without_hash).encode("utf-8")).hexdigest() + if actual_hash != expected_hash: + return {"ok": False, "error": f"entry state hash mismatch at sequence {expected_sequence}"} + previous_hash = expected_hash + + return { + "ok": True, + "entries": len(entries), + "root_hash": previous_hash, + } + + +def verify_file_state_log(*, filepath: str | Path) -> dict[str, Any]: + """Load and verify a local JSON file containing an array of state log entries.""" + path = Path(filepath) + if not path.exists(): + raise FileNotFoundError(f"State log file not found: {filepath}") + + with open(path, "r", encoding="utf-8") as f: + entries = json.load(f) + + if not isinstance(entries, list): + raise ValueError("State log file content must be a JSON array") + + result = verify_state_log_chain(entries) + return { + "command": f"comptext evidence verify-state-log --file {filepath}", + "mode": "state-log", + "network": "not_called", + "providers": "not_called", + **result, + } diff --git a/tests/evidence/test_state_log.py b/tests/evidence/test_state_log.py new file mode 100644 index 0000000..18c85db --- /dev/null +++ b/tests/evidence/test_state_log.py @@ -0,0 +1,93 @@ +import json +from pathlib import Path +import pytest + +from modules.evidence.evidence import ( + verify_state_log_chain, + verify_file_state_log, +) +from modules.cli.cli_entrypoint import run + +ROOT = Path(__file__).resolve().parents[2] + + +def _get_valid_entries() -> list[dict]: + sample_file = ROOT / "examples" / "workspace" / "evidence-state-log.sample.json" + return json.loads(sample_file.read_text(encoding="utf-8")) + + +def test_verify_state_log_chain_valid() -> None: + entries = _get_valid_entries() + result = verify_state_log_chain(entries) + assert result["ok"] is True + assert result["entries"] == 2 + assert len(result["root_hash"]) == 64 + + +def test_verify_file_state_log_valid() -> None: + sample_file = ROOT / "examples" / "workspace" / "evidence-state-log.sample.json" + result = verify_file_state_log(filepath=sample_file) + assert result["ok"] is True + assert result["mode"] == "state-log" + assert result["entries"] == 2 + assert len(result["root_hash"]) == 64 + + +def test_verify_state_log_chain_invalid_sequence() -> None: + entries = _get_valid_entries() + entries[1]["sequence"] = 99 # Should be 1 + result = verify_state_log_chain(entries) + assert result["ok"] is False + assert "entry sequence mismatch" in result["error"] + + +def test_verify_state_log_chain_invalid_previous_state_hash() -> None: + entries = _get_valid_entries() + entries[1]["previous_state_hash"] = "bad_hash" + result = verify_state_log_chain(entries) + assert result["ok"] is False + assert "previous state hash mismatch" in result["error"] + + +def test_verify_state_log_chain_tampered_state_hash() -> None: + entries = _get_valid_entries() + entries[0]["evidence_event_hash"] = "0" * 64 # Tamper content + result = verify_state_log_chain(entries) + assert result["ok"] is False + assert "entry state hash mismatch" in result["error"] + + +def test_verify_file_state_log_missing() -> None: + with pytest.raises(FileNotFoundError, match="State log file not found"): + verify_file_state_log(filepath="does_not_exist.json") + + +def test_verify_file_state_log_invalid_json(tmp_path: Path) -> None: + bad_file = tmp_path / "bad.json" + bad_file.write_text("{bad json", encoding="utf-8") + with pytest.raises(json.JSONDecodeError): + verify_file_state_log(filepath=bad_file) + + +def test_verify_file_state_log_non_array(tmp_path: Path) -> None: + bad_file = tmp_path / "non_array.json" + bad_file.write_text('{"sequence": 0}', encoding="utf-8") + with pytest.raises(ValueError, match="must be a JSON array"): + verify_file_state_log(filepath=bad_file) + + +# CLI Tests +def test_cli_verify_state_log_valid(capsys) -> None: + sample_file = "examples/workspace/evidence-state-log.sample.json" + assert run(["evidence", "verify-state-log", "--file", sample_file]) == 0 + output = json.loads(capsys.readouterr().out) + assert output["ok"] is True + assert output["mode"] == "state-log" + assert output["entries"] == 2 + + +def test_cli_verify_state_log_missing(capsys) -> None: + assert run(["evidence", "verify-state-log", "--file", "does_not_exist.json"]) == 1 + output = json.loads(capsys.readouterr().out) + assert output["ok"] is False + assert output["error"]["type"] == "FileNotFoundError" From 754bb0b2a2b6c1773d6ec080719df13b56de1798 Mon Sep 17 00:00:00 2001 From: ProfRandom92 <159939812+ProfRandom92@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:02:18 +0200 Subject: [PATCH 6/8] feat(evidence): add state log schema validation --- modules/validation/schema_validator.py | 4 ++ modules/validation/workspace_validation.py | 4 ++ schemas/evidence-state-log.v0.schema.json | 40 +++++++++++++++++++ tests/cli/test_cli_entrypoint.py | 2 +- tests/doctor/test_doctor.py | 2 +- .../test_evidence_state_log_entry_v0.py | 22 ++++++++++ 6 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 schemas/evidence-state-log.v0.schema.json diff --git a/modules/validation/schema_validator.py b/modules/validation/schema_validator.py index 7457408..a4b7894 100644 --- a/modules/validation/schema_validator.py +++ b/modules/validation/schema_validator.py @@ -48,6 +48,10 @@ def validate_json_schema_instance(schema: dict[str, Any], instance: Any, locatio if not re.match(schema["pattern"], instance): raise ValueError(f"{location} does not match pattern {schema['pattern']}") + if "minItems" in schema and isinstance(instance, list): + if len(instance) < schema["minItems"]: + raise ValueError(f"{location} must have at least {schema['minItems']} items") + if isinstance(instance, dict): for key in schema.get("required", []): if key not in instance: diff --git a/modules/validation/workspace_validation.py b/modules/validation/workspace_validation.py index 90b3297..76e5fa6 100644 --- a/modules/validation/workspace_validation.py +++ b/modules/validation/workspace_validation.py @@ -29,6 +29,10 @@ Path("schemas/evidence-state-log-entry.v0.schema.json"), Path("examples/workspace/evidence-state-log-entry.sample.json"), ), + ( + Path("schemas/evidence-state-log.v0.schema.json"), + Path("examples/workspace/evidence-state-log.sample.json"), + ), ) diff --git a/schemas/evidence-state-log.v0.schema.json b/schemas/evidence-state-log.v0.schema.json new file mode 100644 index 0000000..cc751bd --- /dev/null +++ b/schemas/evidence-state-log.v0.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://comptext.local/schemas/evidence-state-log.v0.schema.json", + "title": "CompText Evidence State Log v0", + "description": "Local dry-run contract for a chain of state log entries.", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "sequence", + "evidence_event_hash", + "git_commit_ref", + "previous_state_hash", + "state_hash" + ], + "additionalProperties": false, + "properties": { + "sequence": { + "type": "integer" + }, + "evidence_event_hash": { + "type": "string", + "pattern": "^[0-9a-fA-F]{64}$" + }, + "git_commit_ref": { + "type": "string", + "pattern": "^[0-9a-fA-F]{40}$" + }, + "previous_state_hash": { + "type": "string", + "pattern": "^[0-9a-fA-F]{64}$" + }, + "state_hash": { + "type": "string", + "pattern": "^[0-9a-fA-F]{64}$" + } + } + } +} diff --git a/tests/cli/test_cli_entrypoint.py b/tests/cli/test_cli_entrypoint.py index ea517fe..a2e3cab 100644 --- a/tests/cli/test_cli_entrypoint.py +++ b/tests/cli/test_cli_entrypoint.py @@ -26,7 +26,7 @@ def test_cli_validate_workspace_dry_run(capsys) -> None: assert run(["validate", "workspace", "--dry-run"], repo_root=ROOT) == 0 output = json.loads(capsys.readouterr().out) assert output["mode"] == "dry-run" - assert len(output["results"]) == 5 + assert len(output["results"]) == 6 for result in output["results"]: assert result["status"] == "valid" assert "schema" in result diff --git a/tests/doctor/test_doctor.py b/tests/doctor/test_doctor.py index edf558e..1cf49f9 100644 --- a/tests/doctor/test_doctor.py +++ b/tests/doctor/test_doctor.py @@ -15,7 +15,7 @@ def test_doctor_reports_required_project_files() -> None: assert all(result["project_files"].values()) assert "workspace_validation" in result assert result["workspace_validation"]["ok"] is True - assert len(result["workspace_validation"]["results"]) == 5 + assert len(result["workspace_validation"]["results"]) == 6 assert result["ok"] is True diff --git a/tests/validation/test_evidence_state_log_entry_v0.py b/tests/validation/test_evidence_state_log_entry_v0.py index 9f86099..c90d1c9 100644 --- a/tests/validation/test_evidence_state_log_entry_v0.py +++ b/tests/validation/test_evidence_state_log_entry_v0.py @@ -74,3 +74,25 @@ def test_schema_rejects_invalid_git_commit_ref(invalid_git_ref) -> None: with pytest.raises(ValueError): validate_with_additional_properties(schema, sample) + + +def test_state_log_schema_valid_sample() -> None: + schema = json.loads((ROOT / "schemas" / "evidence-state-log.v0.schema.json").read_text(encoding="utf-8")) + sample = json.loads((ROOT / "examples" / "workspace" / "evidence-state-log.sample.json").read_text(encoding="utf-8")) + validate_with_additional_properties(schema, sample) + + +def test_state_log_schema_rejects_empty_array() -> None: + schema = json.loads((ROOT / "schemas" / "evidence-state-log.v0.schema.json").read_text(encoding="utf-8")) + sample = [] + with pytest.raises(ValueError, match="must have at least 1 items"): + validate_with_additional_properties(schema, sample) + + +def test_state_log_schema_rejects_invalid_entry() -> None: + schema = json.loads((ROOT / "schemas" / "evidence-state-log.v0.schema.json").read_text(encoding="utf-8")) + sample = json.loads((ROOT / "examples" / "workspace" / "evidence-state-log.sample.json").read_text(encoding="utf-8")) + + sample[1]["git_commit_ref"] = "a" * 39 + with pytest.raises(ValueError): + validate_with_additional_properties(schema, sample) From 699eb94dc152b726c6f212caec7676ccfe11e389 Mon Sep 17 00:00:00 2001 From: ProfRandom92 <159939812+ProfRandom92@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:13:59 +0200 Subject: [PATCH 7/8] feat(evidence): integrate state log validation in status screen and TUI --- modules/cli/status_screen.py | 5 ++++- modules/cli/tui.py | 13 +++++++------ tests/cli/test_tui.py | 10 ++++++++++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/modules/cli/status_screen.py b/modules/cli/status_screen.py index a95520e..2405848 100644 --- a/modules/cli/status_screen.py +++ b/modules/cli/status_screen.py @@ -55,7 +55,10 @@ def build_status_screen(repo_root: Path) -> tuple[int, str]: evidence_ok = False try: evidence_res = verify_sample_evidence(sample=True) - evidence_ok = bool(evidence_res.get("ok")) + state_log_path = repo_root / "examples/workspace/evidence-state-log.sample.json" + from modules.evidence.evidence import verify_file_state_log + state_log_res = verify_file_state_log(filepath=state_log_path) + evidence_ok = bool(evidence_res.get("ok")) and bool(state_log_res.get("ok")) except Exception: pass diff --git a/modules/cli/tui.py b/modules/cli/tui.py index e11afd1..196f601 100644 --- a/modules/cli/tui.py +++ b/modules/cli/tui.py @@ -9,7 +9,7 @@ from modules.doctor.doctor import run_doctor from modules.validation.workspace_validation import validate_workspace_schemas from modules.runtime.sample_run import run_sample -from modules.evidence.evidence import verify_sample_evidence +from modules.evidence.evidence import verify_file_state_log, verify_sample_evidence def build_tui_snapshot(repo_root: Path) -> dict[str, Any]: @@ -48,7 +48,9 @@ def build_tui_snapshot(repo_root: Path) -> dict[str, Any]: evidence_ok = False try: evidence_res = verify_sample_evidence(sample=True) - evidence_ok = bool(evidence_res.get("ok")) + state_log_path = repo_root / "examples/workspace/evidence-state-log.sample.json" + state_log_res = verify_file_state_log(filepath=state_log_path) + evidence_ok = bool(evidence_res.get("ok")) and bool(state_log_res.get("ok")) except Exception: pass @@ -70,9 +72,8 @@ def build_tui_snapshot(repo_root: Path) -> dict[str, Any]: workspace_validation = { "ok": workspace_ok, "results": [ - {"example": "examples/workspace/workspace-snapshot.sample.json", "status": "valid" if workspace_ok else "invalid"}, - {"example": "examples/workspace/workspace-delta.sample.json", "status": "valid" if workspace_ok else "invalid"}, - {"example": "examples/workspace/reflection-gate.sample.json", "status": "valid" if workspace_ok else "invalid"} + {"example": r["example"].replace("\\", "/"), "status": r["status"]} + for r in workspace_results ] } @@ -87,7 +88,7 @@ def build_tui_snapshot(repo_root: Path) -> dict[str, Any]: # 7. Evidence evidence = { "status": "pass" if evidence_ok else "fail", - "note": "Evidence chain: local dry-run summary available through existing CompText status/verify surfaces." + "note": "Evidence chain: local event chain and state log chain verified." } # 8. Providers diff --git a/tests/cli/test_tui.py b/tests/cli/test_tui.py index d602adf..36f2302 100644 --- a/tests/cli/test_tui.py +++ b/tests/cli/test_tui.py @@ -94,3 +94,13 @@ def mock_import(name, *args, **kwargs): assert run_tui(ROOT, dry_run=True) == 1 out = capsys.readouterr().out assert "Textual is required for comptext tui --dry-run." in out + + +def test_build_tui_snapshot_evidence_fields() -> None: + snap = build_tui_snapshot(ROOT) + assert snap["evidence"]["status"] == "pass" + assert "local event chain and state log chain verified" in snap["evidence"]["note"] + + # Check that all 6 workspace validation schemas are present in the TUI snapshot + results = snap["workspace_validation"]["results"] + assert len(results) == 6 From b0ec173c2c13cb5b0f105653e5eb419f57416d23 Mon Sep 17 00:00:00 2001 From: ProfRandom92 <159939812+ProfRandom92@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:09:16 +0200 Subject: [PATCH 8/8] chore(tui): document non-interactive validation path --- .agents/skills/evidence-chain/SKILL.md | 4 ++-- .antigravity/plugins/comptext-local/skills/evidence-chain.md | 4 ++-- docs/ANTIGRAVITY_CLI_WORKFLOW.md | 2 +- docs/LOCAL_V0_WORKSPACE_README.md | 4 ++-- schemas/evidence-state-log-entry.v0.schema.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/skills/evidence-chain/SKILL.md b/.agents/skills/evidence-chain/SKILL.md index 0faa538..ac845ff 100644 --- a/.agents/skills/evidence-chain/SKILL.md +++ b/.agents/skills/evidence-chain/SKILL.md @@ -5,13 +5,13 @@ description: Design evidence schemas, validate serialization, and plan hash chai # Evidence Chain Skill -Draft and validate evidence event models, hash-chain sequences, and Merkle tree state logs. +Draft and validate evidence event models, hash-chain sequences, and state log chains. ## Use when - Designing JSON schemas for evidence events and run records (e.g. `schemas/`). - Refining event serialization modules and formatting sample evidence logs. - Implementing or validating local hash-chain verification routines. -- Planning local Merkle tree state logs to link git commit hashes into the evidence chain. +- Planning local state log chains to link git commit hashes into the evidence chain. ## Do not use when - Implementing live provider attestation or hosted cryptographic verification. diff --git a/.antigravity/plugins/comptext-local/skills/evidence-chain.md b/.antigravity/plugins/comptext-local/skills/evidence-chain.md index 0faa538..ac845ff 100644 --- a/.antigravity/plugins/comptext-local/skills/evidence-chain.md +++ b/.antigravity/plugins/comptext-local/skills/evidence-chain.md @@ -5,13 +5,13 @@ description: Design evidence schemas, validate serialization, and plan hash chai # Evidence Chain Skill -Draft and validate evidence event models, hash-chain sequences, and Merkle tree state logs. +Draft and validate evidence event models, hash-chain sequences, and state log chains. ## Use when - Designing JSON schemas for evidence events and run records (e.g. `schemas/`). - Refining event serialization modules and formatting sample evidence logs. - Implementing or validating local hash-chain verification routines. -- Planning local Merkle tree state logs to link git commit hashes into the evidence chain. +- Planning local state log chains to link git commit hashes into the evidence chain. ## Do not use when - Implementing live provider attestation or hosted cryptographic verification. diff --git a/docs/ANTIGRAVITY_CLI_WORKFLOW.md b/docs/ANTIGRAVITY_CLI_WORKFLOW.md index fb32671..5eccb1b 100644 --- a/docs/ANTIGRAVITY_CLI_WORKFLOW.md +++ b/docs/ANTIGRAVITY_CLI_WORKFLOW.md @@ -49,4 +49,4 @@ comptext tui --dry-run ## Next Implementation Unit The next planned unit of work is: -- **Offline Merkle Tree state log validation**: Building the local validation tools to link sequential git commit hashes directly into the evidence chain. +- **Offline state log chain validation**: Building the local validation tools to link sequential git commit hashes directly into the evidence chain. diff --git a/docs/LOCAL_V0_WORKSPACE_README.md b/docs/LOCAL_V0_WORKSPACE_README.md index d230ed7..3081dfd 100644 --- a/docs/LOCAL_V0_WORKSPACE_README.md +++ b/docs/LOCAL_V0_WORKSPACE_README.md @@ -120,8 +120,8 @@ python -m pytest # Run focused validation tests python -m pytest tests/validation tests/evidence tests/cli -# Run Textual TUI smoke run -comptext tui --dry-run +# Run Textual TUI headless validation +python -m pytest tests/cli/test_tui.py # Check git repository for formatting or whitespace issues git diff --check diff --git a/schemas/evidence-state-log-entry.v0.schema.json b/schemas/evidence-state-log-entry.v0.schema.json index 101d7bf..8425506 100644 --- a/schemas/evidence-state-log-entry.v0.schema.json +++ b/schemas/evidence-state-log-entry.v0.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://comptext.local/schemas/evidence-state-log-entry.v0.schema.json", "title": "CompText Evidence State Log Entry v0", - "description": "Local dry-run contract for Merkle-like state log entries linking git commits and evidence event hashes.", + "description": "Local dry-run contract for state log entries linking git commits and evidence event hashes.", "type": "object", "required": [ "sequence",