diff --git a/.github/workflows/causal-temporal-transition-graph.yml b/.github/workflows/causal-temporal-transition-graph.yml new file mode 100644 index 0000000..c1dae6d --- /dev/null +++ b/.github/workflows/causal-temporal-transition-graph.yml @@ -0,0 +1,43 @@ +name: Causal-temporal transition graph + +on: + pull_request: + paths: + - "scripts/check_causal_temporal_graph.py" + - "conformance/guardrail-decision-v1/causal-temporal/**" + - "docs/CAUSAL_TEMPORAL_TRANSITION_GRAPH.md" + - "docs/CAUSAL_TEMPORAL_TRACE_SEAL.md" + - ".github/workflows/causal-temporal-transition-graph.yml" + push: + branches: + - main + paths: + - "scripts/check_causal_temporal_graph.py" + - "conformance/guardrail-decision-v1/causal-temporal/**" + - "docs/CAUSAL_TEMPORAL_TRANSITION_GRAPH.md" + - "docs/CAUSAL_TEMPORAL_TRACE_SEAL.md" + - ".github/workflows/causal-temporal-transition-graph.yml" + +permissions: + contents: read + +jobs: + conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Validate causal transition vectors + run: | + python3 scripts/check_causal_temporal_graph.py \ + conformance/guardrail-decision-v1/causal-temporal/graph.json \ + conformance/guardrail-decision-v1/causal-temporal/vectors.json + - name: Validate sealed trace completeness + run: | + python3 scripts/check_causal_temporal_graph.py \ + conformance/guardrail-decision-v1/causal-temporal/graph.json \ + conformance/guardrail-decision-v1/causal-temporal/sealed-vectors.json diff --git a/conformance/guardrail-decision-v1/causal-temporal/README.md b/conformance/guardrail-decision-v1/causal-temporal/README.md new file mode 100644 index 0000000..7632248 --- /dev/null +++ b/conformance/guardrail-decision-v1/causal-temporal/README.md @@ -0,0 +1,61 @@ +# Causal-temporal transition conformance + +This corpus verifies a product-state graph for one immutable subject hash across +three independent axes: + +- origin: `UNCOMMITTED → COMMITTED → REPRODUCIBLE`; +- freshness: `UNKNOWN ↔ FRESH ↔ STALE`; +- temporal: `UNANCHORED → PENDING → ANCHORED` with bounded failure states. + +The design prevents one fact from overwriting another. A source can be stale +while its historical bytes remain reproducible and temporally anchored. + +## Run + +```bash +python3 scripts/check_causal_temporal_graph.py \ + conformance/guardrail-decision-v1/causal-temporal/graph.json \ + conformance/guardrail-decision-v1/causal-temporal/vectors.json + +python3 scripts/check_causal_temporal_graph.py \ + conformance/guardrail-decision-v1/causal-temporal/graph.json \ + conformance/guardrail-decision-v1/causal-temporal/sealed-vectors.json +``` + +Expected results: + +```text +Causal-temporal graph conformance passed: 12 +Causal-temporal graph conformance passed: 6 +``` + +## What the unsealed corpus catches + +- illegal or undeclared transitions; +- sequence gaps and duplicates; +- broken parent links; +- event-byte mutation; +- subject-hash drift; +- local observation-time regression; +- undeclared mutation of a second state axis; +- missing evidence; +- temporal-anchor success without block/time bounds; +- attempted `ANCHORED → PENDING` rollback. + +## What the sealed corpus adds + +A trace seal commits to: + +```text +subject_hash + total_events + terminal_event_id + sealed_at +``` + +This catches a silently dropped suffix inside the declared boundary. Without a +seal, a complete-looking prefix cannot prove that no tail was removed. + +## Scope + +The checker validates the consistency and bounded completeness of recorded +events. It cannot prove that the chosen seal boundary corresponds to every +real-world event that should have happened; boundary governance remains a +separate concern. diff --git a/conformance/guardrail-decision-v1/causal-temporal/graph.json b/conformance/guardrail-decision-v1/causal-temporal/graph.json new file mode 100644 index 0000000..f237299 --- /dev/null +++ b/conformance/guardrail-decision-v1/causal-temporal/graph.json @@ -0,0 +1,157 @@ +{ + "schema": "proofpath.causal-temporal-graph.v0.1", + "canonicalization": "utf8-json-sort-keys-no-whitespace-v0.1", + "axes": { + "origin": { + "initial": "UNCOMMITTED", + "states": [ + "UNCOMMITTED", + "COMMITTED", + "REPRODUCIBLE", + "INVALID" + ] + }, + "freshness": { + "initial": "UNKNOWN", + "states": [ + "UNKNOWN", + "FRESH", + "STALE" + ] + }, + "temporal": { + "initial": "UNANCHORED", + "states": [ + "UNANCHORED", + "PENDING", + "ANCHORED", + "UNAVAILABLE", + "INVALID" + ] + } + }, + "transitions": [ + { + "event_type": "COMMIT_SOURCE_HASH", + "axis": "origin", + "from": [ + "UNCOMMITTED" + ], + "to": "COMMITTED", + "evidence_required": true + }, + { + "event_type": "VERIFY_SOURCE_SNAPSHOT", + "axis": "origin", + "from": [ + "COMMITTED" + ], + "to": "REPRODUCIBLE", + "evidence_required": true + }, + { + "event_type": "INVALIDATE_SOURCE_ORIGIN", + "axis": "origin", + "from": [ + "UNCOMMITTED", + "COMMITTED", + "REPRODUCIBLE" + ], + "to": "INVALID", + "evidence_required": true + }, + { + "event_type": "CHECK_SOURCE_FRESH", + "axis": "freshness", + "from": [ + "UNKNOWN", + "FRESH", + "STALE" + ], + "to": "FRESH", + "evidence_required": true + }, + { + "event_type": "CHECK_SOURCE_STALE", + "axis": "freshness", + "from": [ + "UNKNOWN", + "FRESH", + "STALE" + ], + "to": "STALE", + "evidence_required": true + }, + { + "event_type": "SUBMIT_OTS", + "axis": "temporal", + "from": [ + "UNANCHORED", + "UNAVAILABLE" + ], + "to": "PENDING", + "evidence_required": true + }, + { + "event_type": "VERIFY_OTS_PENDING", + "axis": "temporal", + "from": [ + "UNANCHORED", + "PENDING", + "UNAVAILABLE" + ], + "to": "PENDING", + "evidence_required": true + }, + { + "event_type": "VERIFY_OTS_SUCCESS", + "axis": "temporal", + "from": [ + "UNANCHORED", + "PENDING", + "UNAVAILABLE" + ], + "to": "ANCHORED", + "evidence_required": true, + "required_fields": [ + "temporal_bound.bitcoin_block_height", + "temporal_bound.attested_before" + ] + }, + { + "event_type": "REVERIFY_OTS_SUCCESS", + "axis": "temporal", + "from": [ + "ANCHORED" + ], + "to": "ANCHORED", + "evidence_required": true, + "required_fields": [ + "temporal_bound.bitcoin_block_height", + "temporal_bound.attested_before" + ] + }, + { + "event_type": "VERIFY_OTS_UNAVAILABLE", + "axis": "temporal", + "from": [ + "UNANCHORED", + "PENDING", + "UNAVAILABLE" + ], + "to": "UNAVAILABLE", + "evidence_required": true + }, + { + "event_type": "VERIFY_OTS_INVALID", + "axis": "temporal", + "from": [ + "UNANCHORED", + "PENDING", + "UNAVAILABLE" + ], + "to": "INVALID", + "evidence_required": true + } + ] +} \ No newline at end of file diff --git a/conformance/guardrail-decision-v1/causal-temporal/sealed-vectors.json b/conformance/guardrail-decision-v1/causal-temporal/sealed-vectors.json new file mode 100644 index 0000000..048974d --- /dev/null +++ b/conformance/guardrail-decision-v1/causal-temporal/sealed-vectors.json @@ -0,0 +1,109 @@ +{ + "schema": "proofpath.causal-temporal-sealed-vectors.v0.2", + "require_seal": true, + "base_trace": { + "subject_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "events": [ + { + "seq": 0, + "parent_event_id": null, + "event_type": "COMMIT_SOURCE_HASH", + "state_before": {"origin":"UNCOMMITTED","freshness":"UNKNOWN","temporal":"UNANCHORED"}, + "state_after": {"origin":"COMMITTED","freshness":"UNKNOWN","temporal":"UNANCHORED"}, + "subject_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "evidence_ref": "evidence:source-hash", + "observed_at": "2026-07-22T04:00:00Z", + "event_id": "sha256:829330b6c5b60e3774d6c55fe174092ae5ba0406198e93ff8efc17d77fc73962" + }, + { + "seq": 1, + "parent_event_id": "sha256:829330b6c5b60e3774d6c55fe174092ae5ba0406198e93ff8efc17d77fc73962", + "event_type": "VERIFY_SOURCE_SNAPSHOT", + "state_before": {"origin":"COMMITTED","freshness":"UNKNOWN","temporal":"UNANCHORED"}, + "state_after": {"origin":"REPRODUCIBLE","freshness":"UNKNOWN","temporal":"UNANCHORED"}, + "subject_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "evidence_ref": "evidence:snapshot", + "observed_at": "2026-07-22T04:01:00Z", + "event_id": "sha256:f7325b13c1b69f4482ff2f743210e33a3d2c50e6f83fa4993a3a2b764cb680e0" + }, + { + "seq": 2, + "parent_event_id": "sha256:f7325b13c1b69f4482ff2f743210e33a3d2c50e6f83fa4993a3a2b764cb680e0", + "event_type": "CHECK_SOURCE_FRESH", + "state_before": {"origin":"REPRODUCIBLE","freshness":"UNKNOWN","temporal":"UNANCHORED"}, + "state_after": {"origin":"REPRODUCIBLE","freshness":"FRESH","temporal":"UNANCHORED"}, + "subject_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "evidence_ref": "evidence:live-fetch", + "observed_at": "2026-07-22T04:02:00Z", + "event_id": "sha256:cd424af268762cc38b46a9b3bc14ebe51a8c615082847bae9100620c15525065" + }, + { + "seq": 3, + "parent_event_id": "sha256:cd424af268762cc38b46a9b3bc14ebe51a8c615082847bae9100620c15525065", + "event_type": "SUBMIT_OTS", + "state_before": {"origin":"REPRODUCIBLE","freshness":"FRESH","temporal":"UNANCHORED"}, + "state_after": {"origin":"REPRODUCIBLE","freshness":"FRESH","temporal":"PENDING"}, + "subject_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "evidence_ref": "evidence:ots-proof-pending", + "observed_at": "2026-07-22T04:03:00Z", + "event_id": "sha256:5110d0131c3fe5eea49865ea467ed229c48f75763c5e50c1c6776db6d5cb7a18" + }, + { + "seq": 4, + "parent_event_id": "sha256:5110d0131c3fe5eea49865ea467ed229c48f75763c5e50c1c6776db6d5cb7a18", + "event_type": "VERIFY_OTS_SUCCESS", + "state_before": {"origin":"REPRODUCIBLE","freshness":"FRESH","temporal":"PENDING"}, + "state_after": {"origin":"REPRODUCIBLE","freshness":"FRESH","temporal":"ANCHORED"}, + "subject_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "evidence_ref": "evidence:ots-verification", + "observed_at": "2026-07-22T05:00:00Z", + "temporal_bound": {"kind":"not_later_than","bitcoin_block_height":900001,"attested_before":"2026-07-22T04:55:00Z"}, + "event_id": "sha256:d2db1fff41daa5b2656e72d0be097963d23085347059287b7fc2a43a5d58c8e8" + } + ], + "seal": { + "subject_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "total_events": 5, + "terminal_event_id": "sha256:d2db1fff41daa5b2656e72d0be097963d23085347059287b7fc2a43a5d58c8e8", + "sealed_at": "2026-07-22T05:01:00Z", + "seal_id": "sha256:cc765068061e280cf3ae88fef87b655d48ed2a8ee9523daa18cccb446780d5d5" + } + }, + "cases": [ + { + "name": "valid-sealed-trace", + "expected": {"ok":true,"error":null,"event_count":5} + }, + { + "name": "reject-dropped-tail-against-seal", + "mutations": [{"op":"truncate_events","count":4}], + "expected": {"ok":false,"error":"TRACE_TOTAL_MISMATCH"} + }, + { + "name": "reject-missing-trace-seal", + "mutations": [{"op":"delete","path":"seal"}], + "expected": {"ok":false,"error":"TRACE_SEAL_MISSING"} + }, + { + "name": "reject-tampered-seal-id", + "mutations": [{"op":"set","path":"seal.seal_id","value":"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}], + "expected": {"ok":false,"error":"TRACE_SEAL_ID_MISMATCH"} + }, + { + "name": "reject-terminal-event-mismatch", + "mutations": [ + {"op":"set","path":"seal.terminal_event_id","value":"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}, + {"op":"recompute_seal_id"} + ], + "expected": {"ok":false,"error":"TRACE_TERMINAL_EVENT_MISMATCH"} + }, + { + "name": "reject-sealed-at-regression", + "mutations": [ + {"op":"set","path":"seal.sealed_at","value":"2026-07-22T04:59:00Z"}, + {"op":"recompute_seal_id"} + ], + "expected": {"ok":false,"error":"SEALED_AT_REGRESSION"} + } + ] +} \ No newline at end of file diff --git a/conformance/guardrail-decision-v1/causal-temporal/vectors.json b/conformance/guardrail-decision-v1/causal-temporal/vectors.json new file mode 100644 index 0000000..b501d99 --- /dev/null +++ b/conformance/guardrail-decision-v1/causal-temporal/vectors.json @@ -0,0 +1,180 @@ +{ + "schema": "proofpath.causal-temporal-vectors.v0.2", + "base_trace": { + "subject_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "events": [ + { + "seq": 0, + "parent_event_id": null, + "event_type": "COMMIT_SOURCE_HASH", + "state_before": {"origin":"UNCOMMITTED","freshness":"UNKNOWN","temporal":"UNANCHORED"}, + "state_after": {"origin":"COMMITTED","freshness":"UNKNOWN","temporal":"UNANCHORED"}, + "subject_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "evidence_ref": "evidence:source-hash", + "observed_at": "2026-07-22T04:00:00Z", + "event_id": "sha256:829330b6c5b60e3774d6c55fe174092ae5ba0406198e93ff8efc17d77fc73962" + }, + { + "seq": 1, + "parent_event_id": "sha256:829330b6c5b60e3774d6c55fe174092ae5ba0406198e93ff8efc17d77fc73962", + "event_type": "VERIFY_SOURCE_SNAPSHOT", + "state_before": {"origin":"COMMITTED","freshness":"UNKNOWN","temporal":"UNANCHORED"}, + "state_after": {"origin":"REPRODUCIBLE","freshness":"UNKNOWN","temporal":"UNANCHORED"}, + "subject_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "evidence_ref": "evidence:snapshot", + "observed_at": "2026-07-22T04:01:00Z", + "event_id": "sha256:f7325b13c1b69f4482ff2f743210e33a3d2c50e6f83fa4993a3a2b764cb680e0" + }, + { + "seq": 2, + "parent_event_id": "sha256:f7325b13c1b69f4482ff2f743210e33a3d2c50e6f83fa4993a3a2b764cb680e0", + "event_type": "CHECK_SOURCE_FRESH", + "state_before": {"origin":"REPRODUCIBLE","freshness":"UNKNOWN","temporal":"UNANCHORED"}, + "state_after": {"origin":"REPRODUCIBLE","freshness":"FRESH","temporal":"UNANCHORED"}, + "subject_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "evidence_ref": "evidence:live-fetch", + "observed_at": "2026-07-22T04:02:00Z", + "event_id": "sha256:cd424af268762cc38b46a9b3bc14ebe51a8c615082847bae9100620c15525065" + }, + { + "seq": 3, + "parent_event_id": "sha256:cd424af268762cc38b46a9b3bc14ebe51a8c615082847bae9100620c15525065", + "event_type": "SUBMIT_OTS", + "state_before": {"origin":"REPRODUCIBLE","freshness":"FRESH","temporal":"UNANCHORED"}, + "state_after": {"origin":"REPRODUCIBLE","freshness":"FRESH","temporal":"PENDING"}, + "subject_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "evidence_ref": "evidence:ots-proof-pending", + "observed_at": "2026-07-22T04:03:00Z", + "event_id": "sha256:5110d0131c3fe5eea49865ea467ed229c48f75763c5e50c1c6776db6d5cb7a18" + }, + { + "seq": 4, + "parent_event_id": "sha256:5110d0131c3fe5eea49865ea467ed229c48f75763c5e50c1c6776db6d5cb7a18", + "event_type": "VERIFY_OTS_SUCCESS", + "state_before": {"origin":"REPRODUCIBLE","freshness":"FRESH","temporal":"PENDING"}, + "state_after": {"origin":"REPRODUCIBLE","freshness":"FRESH","temporal":"ANCHORED"}, + "subject_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "evidence_ref": "evidence:ots-verification", + "observed_at": "2026-07-22T05:00:00Z", + "temporal_bound": {"kind":"not_later_than","bitcoin_block_height":900001,"attested_before":"2026-07-22T04:55:00Z"}, + "event_id": "sha256:d2db1fff41daa5b2656e72d0be097963d23085347059287b7fc2a43a5d58c8e8" + } + ] + }, + "cases": [ + { + "name": "valid-full-causal-chain", + "expected": { + "ok": true, + "error": null, + "final_state": {"origin":"REPRODUCIBLE","freshness":"FRESH","temporal":"ANCHORED"}, + "event_count": 5 + } + }, + { + "name": "valid-staleness-does-not-erase-temporal-anchor", + "mutations": [ + { + "op": "append_event", + "value": { + "seq": 5, + "parent_event_id": "sha256:d2db1fff41daa5b2656e72d0be097963d23085347059287b7fc2a43a5d58c8e8", + "event_type": "CHECK_SOURCE_STALE", + "state_before": {"origin":"REPRODUCIBLE","freshness":"FRESH","temporal":"ANCHORED"}, + "state_after": {"origin":"REPRODUCIBLE","freshness":"STALE","temporal":"ANCHORED"}, + "subject_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "evidence_ref": "evidence:changed-live-source", + "observed_at": "2026-07-22T06:00:00Z" + } + }, + {"op":"recompute_event_id","index":5} + ], + "expected": { + "ok": true, + "error": null, + "final_state": {"origin":"REPRODUCIBLE","freshness":"STALE","temporal":"ANCHORED"}, + "event_count": 6 + } + }, + { + "name": "reject-undeclared-transition", + "mutations": [ + {"op":"set","path":"events.3.event_type","value":"ROLLBACK_TIME"}, + {"op":"recompute_event_id","index":3} + ], + "expected": {"ok":false,"error":"UNDECLARED_TRANSITION"} + }, + { + "name": "reject-sequence-gap", + "mutations": [{"op":"set","path":"events.2.seq","value":3}], + "expected": {"ok":false,"error":"SEQUENCE_GAP_OR_DUPLICATE"} + }, + { + "name": "reject-parent-mismatch", + "mutations": [{"op":"set","path":"events.2.parent_event_id","value":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], + "expected": {"ok":false,"error":"PARENT_EVENT_MISMATCH"} + }, + { + "name": "reject-subject-hash-drift", + "mutations": [{"op":"set","path":"events.2.subject_hash","value":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}], + "expected": {"ok":false,"error":"SUBJECT_HASH_DRIFT"} + }, + { + "name": "reject-observed-time-regression", + "mutations": [ + {"op":"set","path":"events.2.observed_at","value":"2026-07-22T03:59:00Z"}, + {"op":"recompute_event_id","index":2} + ], + "expected": {"ok":false,"error":"OBSERVED_AT_REGRESSION"} + }, + { + "name": "reject-missing-transition-evidence", + "mutations": [ + {"op":"set","path":"events.2.evidence_ref","value":""}, + {"op":"recompute_event_id","index":2} + ], + "expected": {"ok":false,"error":"MISSING_TRANSITION_EVIDENCE"} + }, + { + "name": "reject-undeclared-axis-mutation", + "mutations": [ + {"op":"set","path":"events.2.state_after.origin","value":"INVALID"}, + {"op":"recompute_event_id","index":2} + ], + "expected": {"ok":false,"error":"UNDECLARED_AXIS_MUTATION"} + }, + { + "name": "reject-event-id-tamper", + "mutations": [{"op":"set","path":"events.1.event_id","value":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}], + "expected": {"ok":false,"error":"EVENT_ID_MISMATCH"} + }, + { + "name": "reject-temporal-regression-after-anchor", + "mutations": [ + { + "op": "append_event", + "value": { + "seq": 5, + "parent_event_id": "sha256:d2db1fff41daa5b2656e72d0be097963d23085347059287b7fc2a43a5d58c8e8", + "event_type": "SUBMIT_OTS", + "state_before": {"origin":"REPRODUCIBLE","freshness":"FRESH","temporal":"ANCHORED"}, + "state_after": {"origin":"REPRODUCIBLE","freshness":"FRESH","temporal":"PENDING"}, + "subject_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "evidence_ref": "evidence:new-submit", + "observed_at": "2026-07-22T06:00:00Z" + } + }, + {"op":"recompute_event_id","index":5} + ], + "expected": {"ok":false,"error":"ILLEGAL_AXIS_TRANSITION"} + }, + { + "name": "reject-anchor-without-temporal-bound", + "mutations": [ + {"op":"delete","path":"events.4.temporal_bound"}, + {"op":"recompute_event_id","index":4} + ], + "expected": {"ok":false,"error":"MISSING_REQUIRED_TRANSITION_FIELD"} + } + ] +} \ No newline at end of file diff --git a/docs/CAUSAL_TEMPORAL_TRACE_SEAL.md b/docs/CAUSAL_TEMPORAL_TRACE_SEAL.md new file mode 100644 index 0000000..f352753 --- /dev/null +++ b/docs/CAUSAL_TEMPORAL_TRACE_SEAL.md @@ -0,0 +1,63 @@ +# Causal-temporal trace seal + +Status: **experimental completeness sidecar** +Companion to: `proofpath.causal-temporal-graph.v0.1` + +## Why a seal is needed + +A parent-linked hash chain exposes mutation and an omitted event in the middle. +It cannot, by itself, distinguish a complete chain from a prefix whose entire +tail was removed. A trace seal closes that bounded-completeness gap by declaring +where the trace ends. + +## Seal shape + +```json +{ + "subject_hash": "sha256:...", + "total_events": 5, + "terminal_event_id": "sha256:...", + "sealed_at": "2026-07-22T05:01:00Z", + "seal_id": "sha256:..." +} +``` + +`seal_id` is SHA-256 over compact sorted-key UTF-8 JSON of all members except +`seal_id`. + +## Verification + +A sealed trace is accepted only when: + +1. the seal itself recomputes; +2. `subject_hash` equals the trace subject; +3. `total_events` equals the held event count; +4. `terminal_event_id` equals the last recomputed event ID; +5. `sealed_at` is not earlier than the final `observed_at`. + +The checker returns explicit signals: + +- `TRACE_SEAL_MISSING`; +- `TRACE_SEAL_ID_MISMATCH`; +- `TRACE_SEAL_SUBJECT_MISMATCH`; +- `TRACE_TOTAL_MISMATCH`; +- `TRACE_TERMINAL_EVENT_MISMATCH`; +- `INVALID_SEALED_AT`; +- `SEALED_AT_REGRESSION`. + +## Causal boundary + +A seal proves completeness only relative to the boundary it declares. It does +not prove that a producer chose the correct real-world boundary or recorded all +external events before sealing. Governance of who may seal, when sealing is +required, and how seals are externally witnessed remains a separate layer. + +For stronger temporal evidence, the seal bytes themselves may be submitted to +the OpenTimestamps profile. That produces two independent guarantees: + +```text +trace seal → bounded event completeness +OTS proof → seal bytes existed no later than a Bitcoin block +``` + +Neither guarantee substitutes for the other. diff --git a/docs/CAUSAL_TEMPORAL_TRANSITION_GRAPH.md b/docs/CAUSAL_TEMPORAL_TRANSITION_GRAPH.md new file mode 100644 index 0000000..02a07ef --- /dev/null +++ b/docs/CAUSAL_TEMPORAL_TRANSITION_GRAPH.md @@ -0,0 +1,201 @@ +# Causal-temporal transition graph + +Status: **experimental stacked profile** +Depends on: `proofpath.temporal-anchor.ots.v0.1` + +## Purpose + +A provenance hash, a live-source check, and a Bitcoin temporal anchor answer +different questions. Combining them into one scalar status loses causality and +can produce false regressions. This profile represents one immutable subject as +a product state: + +```text +S(t) = (origin(t), freshness(t), temporal(t)) +``` + +- `origin` answers whether the historical bytes are committed and reproducible; +- `freshness` answers whether a current source still matches those bytes; +- `temporal` answers whether the exact bytes have an independently verified time upper bound. + +A source can therefore be both `STALE` and `ANCHORED`: the live page changed, +but the historical bytes remain reproducible and were proven to exist before a +Bitcoin block. Those facts must not overwrite each other. + +## Full causal chain + +```mermaid +flowchart LR + A[Knowledge source bytes] -->|COMMIT_SOURCE_HASH| B[Origin: COMMITTED] + B -->|VERIFY_SOURCE_SNAPSHOT| C[Origin: REPRODUCIBLE] + C -->|CHECK_SOURCE_FRESH| D[Freshness: FRESH] + C -->|CHECK_SOURCE_STALE| E[Freshness: STALE] + + C -->|SUBMIT_OTS| F[Temporal: PENDING] + F -->|VERIFY_OTS_SUCCESS| G[Temporal: ANCHORED] + F -->|VERIFY_OTS_UNAVAILABLE| H[Temporal: UNAVAILABLE] + F -->|VERIFY_OTS_INVALID| I[Temporal: INVALID] + + G --> J[Bitcoin block height] + J --> K[External upper bound: bytes existed no later than block time] + + D -. live source changes .-> E + G -. preserved independently .-> E +``` + +## Independent state axes + +### Origin axis + +```mermaid +stateDiagram-v2 + [*] --> UNCOMMITTED + UNCOMMITTED --> COMMITTED: COMMIT_SOURCE_HASH + COMMITTED --> REPRODUCIBLE: VERIFY_SOURCE_SNAPSHOT + UNCOMMITTED --> INVALID: INVALIDATE_SOURCE_ORIGIN + COMMITTED --> INVALID: INVALIDATE_SOURCE_ORIGIN + REPRODUCIBLE --> INVALID: INVALIDATE_SOURCE_ORIGIN +``` + +### Freshness axis + +```mermaid +stateDiagram-v2 + [*] --> UNKNOWN + UNKNOWN --> FRESH: CHECK_SOURCE_FRESH + UNKNOWN --> STALE: CHECK_SOURCE_STALE + FRESH --> FRESH: CHECK_SOURCE_FRESH + FRESH --> STALE: CHECK_SOURCE_STALE + STALE --> STALE: CHECK_SOURCE_STALE + STALE --> FRESH: CHECK_SOURCE_FRESH +``` + +Freshness can change as the live source changes. It does not rewrite the frozen +snapshot or remove an existing temporal anchor. + +### Temporal axis + +```mermaid +stateDiagram-v2 + [*] --> UNANCHORED + UNANCHORED --> PENDING: SUBMIT_OTS + UNAVAILABLE --> PENDING: SUBMIT_OTS + UNANCHORED --> PENDING: VERIFY_OTS_PENDING + PENDING --> PENDING: VERIFY_OTS_PENDING + UNAVAILABLE --> PENDING: VERIFY_OTS_PENDING + UNANCHORED --> ANCHORED: VERIFY_OTS_SUCCESS + PENDING --> ANCHORED: VERIFY_OTS_SUCCESS + UNAVAILABLE --> ANCHORED: VERIFY_OTS_SUCCESS + ANCHORED --> ANCHORED: REVERIFY_OTS_SUCCESS + UNANCHORED --> UNAVAILABLE: VERIFY_OTS_UNAVAILABLE + PENDING --> UNAVAILABLE: VERIFY_OTS_UNAVAILABLE + UNAVAILABLE --> UNAVAILABLE: VERIFY_OTS_UNAVAILABLE + UNANCHORED --> INVALID: VERIFY_OTS_INVALID + PENDING --> INVALID: VERIFY_OTS_INVALID + UNAVAILABLE --> INVALID: VERIFY_OTS_INVALID +``` + +There is intentionally no `ANCHORED → PENDING`, `ANCHORED → UNAVAILABLE`, or +`ANCHORED → INVALID` transition for the same exact subject bytes. A later failed +verification attempt is a new observation; it cannot erase a previously +verified historical fact. A contradiction must be recorded separately and +investigated rather than silently rolling back the state. + +## Event record + +Each transition is an append-only causal event: + +```json +{ + "seq": 4, + "parent_event_id": "sha256:...", + "event_type": "VERIFY_OTS_SUCCESS", + "state_before": { + "origin": "REPRODUCIBLE", + "freshness": "FRESH", + "temporal": "PENDING" + }, + "state_after": { + "origin": "REPRODUCIBLE", + "freshness": "FRESH", + "temporal": "ANCHORED" + }, + "subject_hash": "sha256:...", + "evidence_ref": "evidence:ots-verification", + "observed_at": "2026-07-22T05:00:00Z", + "temporal_bound": { + "kind": "not_later_than", + "bitcoin_block_height": 900001, + "attested_before": "2026-07-22T04:55:00Z" + }, + "event_id": "sha256:..." +} +``` + +`event_id` is SHA-256 over compact, sorted-key UTF-8 JSON of every event member +except `event_id` itself. `parent_event_id` creates the causal chain. + +## Time semantics + +The graph intentionally carries two different time concepts: + +- `observed_at` orders local observations inside one trace. It is operational metadata and remains dependent on the observer clock. +- `temporal_bound.attested_before` is an externally verified upper bound derived from an OTS proof and Bitcoin block. It does not claim the exact creation time. + +`issued_at` is not used to prove temporal precedence. A self-declared timestamp +may be useful metadata, but it cannot replace the external bound. + +## Mechanical invariants + +The checker rejects a trace when any of these invariants fails: + +1. `seq` starts at zero and has no gaps or duplicates; +2. every `parent_event_id` equals the previous recomputed `event_id`; +3. every event recomputes to its declared `event_id`; +4. `subject_hash` never changes inside one trace; +5. `observed_at` never moves backward inside one trace; +6. `state_before` equals the prior event's `state_after`; +7. the event type is declared in the graph; +8. only the transition's declared axis may change; +9. the axis transition must be explicitly allowed; +10. required transition evidence must be present; +11. a successful temporal anchor must carry block height and external upper bound. + +## Deviation signals + +| Deviation | Checker signal | +| --- | --- | +| Missing or duplicate sequence | `SEQUENCE_GAP_OR_DUPLICATE` | +| Broken causal parent | `PARENT_EVENT_MISMATCH` | +| Event bytes modified | `EVENT_ID_MISMATCH` | +| Different subject inserted into trace | `SUBJECT_HASH_DRIFT` | +| Observer time moved backward | `OBSERVED_AT_REGRESSION` | +| State does not continue from prior event | `STATE_BEFORE_MISMATCH` | +| Unknown event type | `UNDECLARED_TRANSITION` | +| Forbidden state move | `ILLEGAL_AXIS_TRANSITION` | +| A transition changes another axis | `UNDECLARED_AXIS_MUTATION` | +| Evidence omitted | `MISSING_TRANSITION_EVIDENCE` | +| Anchor omits block/time bound | `MISSING_REQUIRED_TRANSITION_FIELD` | + +## Supersession boundary + +Re-extracting changed source bytes creates a new `subject_hash` and therefore a +new trace. It must not mutate the prior trace. A separate record may connect the +new trace with `supersedes_trace_ref`, but each trace remains independently +recomputable. + +This rule distinguishes: + +```text +same subject + new observation → append event to existing trace +new subject bytes → create new trace and link by supersession +``` + +## Scope boundary + +This profile proves transition consistency for recorded events. It does not +prove that every real-world event was recorded. Completeness requires an +independent boundary or seal that commits to the expected event count or time +window. The graph catches mutation, gaps inside the held chain, illegal moves, +and semantic drift; it cannot detect an entirely omitted unsealed suffix by +itself. diff --git a/scripts/check_causal_temporal_graph.py b/scripts/check_causal_temporal_graph.py new file mode 100644 index 0000000..6973287 --- /dev/null +++ b/scripts/check_causal_temporal_graph.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Validate causal-temporal transition traces for one immutable subject hash.""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import re +import sys +from datetime import datetime +from pathlib import Path +from typing import Any, Sequence + +HASH_RE = re.compile(r"^sha256:[0-9a-f]{64}$") + + +def canonical_bytes(value: Any) -> bytes: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + + +def recompute_event_id(event: dict[str, Any]) -> str: + preimage = {key: value for key, value in event.items() if key != "event_id"} + return "sha256:" + hashlib.sha256(canonical_bytes(preimage)).hexdigest() + + +def recompute_seal_id(seal: dict[str, Any]) -> str: + preimage = {key: value for key, value in seal.items() if key != "seal_id"} + return "sha256:" + hashlib.sha256(canonical_bytes(preimage)).hexdigest() + + +def _path_parts(path: str) -> list[str | int]: + return [int(part) if part.isdigit() else part for part in path.split(".")] + + +def _resolve_parent(root: Any, path: str) -> tuple[Any, str | int]: + parts = _path_parts(path) + if not parts: + raise ValueError("empty mutation path") + current = root + for part in parts[:-1]: + current = current[part] + return current, parts[-1] + + +def materialize_case(vectors: dict[str, Any], case: dict[str, Any]) -> dict[str, Any]: + if "events" in case: + return case + if "base_trace" not in vectors: + raise ValueError(f"case {case.get('name')} has no events and no base_trace") + + materialized = copy.deepcopy(vectors["base_trace"]) + materialized["name"] = case["name"] + materialized["expected"] = case["expected"] + + for mutation in case.get("mutations", []): + op = mutation["op"] + if op == "set": + parent, key = _resolve_parent(materialized, mutation["path"]) + parent[key] = copy.deepcopy(mutation["value"]) + elif op == "delete": + parent, key = _resolve_parent(materialized, mutation["path"]) + del parent[key] + elif op == "append_event": + materialized["events"].append(copy.deepcopy(mutation["value"])) + elif op == "truncate_events": + materialized["events"] = materialized["events"][: int(mutation["count"])] + elif op == "recompute_event_id": + index = int(mutation["index"]) + materialized["events"][index]["event_id"] = recompute_event_id( + materialized["events"][index] + ) + elif op == "recompute_seal_id": + materialized["seal"]["seal_id"] = recompute_seal_id(materialized["seal"]) + else: + raise ValueError(f"unknown mutation op: {op}") + + return materialized + + +def parse_time(value: str) -> datetime: + if value.endswith("Z"): + value = value[:-1] + "+00:00" + return datetime.fromisoformat(value) + + +def get_dotted(value: dict[str, Any], path: str) -> Any: + current: Any = value + for part in path.split("."): + if not isinstance(current, dict) or part not in current: + raise KeyError(path) + current = current[part] + return current + + +def initial_state(graph: dict[str, Any]) -> dict[str, str]: + return {name: spec["initial"] for name, spec in graph["axes"].items()} + + +def validate_graph(graph: dict[str, Any]) -> None: + axes = graph["axes"] + for axis, spec in axes.items(): + states = spec["states"] + if len(states) != len(set(states)): + raise ValueError(f"duplicate state on axis {axis}") + if spec["initial"] not in states: + raise ValueError(f"initial state missing on axis {axis}") + + event_types: set[str] = set() + for transition in graph["transitions"]: + event_type = transition["event_type"] + if event_type in event_types: + raise ValueError(f"duplicate event_type: {event_type}") + event_types.add(event_type) + axis = transition["axis"] + if axis not in axes: + raise ValueError(f"unknown axis: {axis}") + allowed = set(axes[axis]["states"]) + if not set(transition["from"]).issubset(allowed): + raise ValueError(f"unknown from-state in {event_type}") + if transition["to"] not in allowed: + raise ValueError(f"unknown to-state in {event_type}") + + +def fail(code: str, seq: int | None = None) -> dict[str, Any]: + result: dict[str, Any] = {"ok": False, "error": code} + if seq is not None: + result["seq"] = seq + return result + + +def verify_trace( + graph: dict[str, Any], + case: dict[str, Any], + *, + require_seal: bool = False, +) -> dict[str, Any]: + transitions = {item["event_type"]: item for item in graph["transitions"]} + axes = graph["axes"] + expected_axes = set(axes) + current = initial_state(graph) + previous_id: str | None = None + previous_time: datetime | None = None + subject_hash = case["subject_hash"] + + if not HASH_RE.fullmatch(subject_hash): + return fail("INVALID_SUBJECT_HASH") + + events = case["events"] + for index, event in enumerate(events): + if event.get("seq") != index: + return fail("SEQUENCE_GAP_OR_DUPLICATE", index) + if event.get("parent_event_id") != previous_id: + return fail("PARENT_EVENT_MISMATCH", index) + if event.get("subject_hash") != subject_hash: + return fail("SUBJECT_HASH_DRIFT", index) + + event_id = event.get("event_id") + if not isinstance(event_id, str) or event_id != recompute_event_id(event): + return fail("EVENT_ID_MISMATCH", index) + + try: + observed_at = parse_time(event["observed_at"]) + except (KeyError, TypeError, ValueError): + return fail("INVALID_OBSERVED_AT", index) + if previous_time is not None and observed_at < previous_time: + return fail("OBSERVED_AT_REGRESSION", index) + + before = event.get("state_before") + after = event.get("state_after") + if not isinstance(before, dict) or not isinstance(after, dict): + return fail("INVALID_STATE_VECTOR", index) + if set(before) != expected_axes or set(after) != expected_axes: + return fail("STATE_VECTOR_AXES_MISMATCH", index) + for axis, spec in axes.items(): + if before[axis] not in spec["states"] or after[axis] not in spec["states"]: + return fail("UNKNOWN_STATE", index) + if before != current: + return fail("STATE_BEFORE_MISMATCH", index) + + transition = transitions.get(event.get("event_type")) + if transition is None: + return fail("UNDECLARED_TRANSITION", index) + axis = transition["axis"] + if before[axis] not in transition["from"] or after[axis] != transition["to"]: + return fail("ILLEGAL_AXIS_TRANSITION", index) + for untouched_axis in expected_axes - {axis}: + if before[untouched_axis] != after[untouched_axis]: + return fail("UNDECLARED_AXIS_MUTATION", index) + + if transition.get("evidence_required") and not event.get("evidence_ref"): + return fail("MISSING_TRANSITION_EVIDENCE", index) + for required_path in transition.get("required_fields", []): + try: + required_value = get_dotted(event, required_path) + except KeyError: + return fail("MISSING_REQUIRED_TRANSITION_FIELD", index) + if required_value in (None, ""): + return fail("MISSING_REQUIRED_TRANSITION_FIELD", index) + + current = after + previous_id = event_id + previous_time = observed_at + + seal = case.get("seal") + if require_seal and seal is None: + return fail("TRACE_SEAL_MISSING") + if seal is not None: + if not isinstance(seal, dict): + return fail("INVALID_TRACE_SEAL") + if seal.get("seal_id") != recompute_seal_id(seal): + return fail("TRACE_SEAL_ID_MISMATCH") + if seal.get("subject_hash") != subject_hash: + return fail("TRACE_SEAL_SUBJECT_MISMATCH") + if seal.get("total_events") != len(events): + return fail("TRACE_TOTAL_MISMATCH") + if seal.get("terminal_event_id") != previous_id: + return fail("TRACE_TERMINAL_EVENT_MISMATCH") + try: + sealed_at = parse_time(seal["sealed_at"]) + except (KeyError, TypeError, ValueError): + return fail("INVALID_SEALED_AT") + if previous_time is not None and sealed_at < previous_time: + return fail("SEALED_AT_REGRESSION") + + return { + "ok": True, + "error": None, + "final_state": current, + "terminal_event_id": previous_id, + "event_count": len(events), + } + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("graph", type=Path) + parser.add_argument("vectors", type=Path) + args = parser.parse_args(sys.argv[1:] if argv is None else argv) + + graph = json.loads(args.graph.read_text(encoding="utf-8")) + vectors = json.loads(args.vectors.read_text(encoding="utf-8")) + validate_graph(graph) + + failures = 0 + for case_spec in vectors["cases"]: + case = materialize_case(vectors, case_spec) + actual = verify_trace( + graph, + case, + require_seal=bool(vectors.get("require_seal", False)), + ) + expected = case["expected"] + projection = {key: actual.get(key) for key in expected} + if projection != expected: + failures += 1 + print(f"FAIL {case['name']}") + print(" expected:", json.dumps(expected, sort_keys=True)) + print(" actual: ", json.dumps(projection, sort_keys=True)) + else: + outcome = "PASS" if actual["ok"] else actual["error"] + print(f"PASS {case['name']} -> {outcome}") + + if failures: + print(f"\nCausal-temporal graph conformance failed: {failures}") + return 1 + + print(f"\nCausal-temporal graph conformance passed: {len(vectors['cases'])}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())