causal-continuity-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. causal_continuity_engine/__init__.py +21 -0
  2. causal_continuity_engine/api.py +1264 -0
  3. causal_continuity_engine/capabilities.py +548 -0
  4. causal_continuity_engine/capsule.py +996 -0
  5. causal_continuity_engine/cli.py +1338 -0
  6. causal_continuity_engine/core.py +468 -0
  7. causal_continuity_engine/engine.py +4354 -0
  8. causal_continuity_engine/evidence.py +971 -0
  9. causal_continuity_engine/extraction.py +263 -0
  10. causal_continuity_engine/github.py +657 -0
  11. causal_continuity_engine/graph.py +809 -0
  12. causal_continuity_engine/invalidation.py +1040 -0
  13. causal_continuity_engine/lamport.py +306 -0
  14. causal_continuity_engine/learning.py +466 -0
  15. causal_continuity_engine/memory.py +610 -0
  16. causal_continuity_engine/ontology.py +108 -0
  17. causal_continuity_engine/partial.py +309 -0
  18. causal_continuity_engine/policy.py +940 -0
  19. causal_continuity_engine/proof.py +920 -0
  20. causal_continuity_engine/py.typed +1 -0
  21. causal_continuity_engine/redaction.py +94 -0
  22. causal_continuity_engine/resume.py +653 -0
  23. causal_continuity_engine/store.py +1174 -0
  24. causal_continuity_engine/verifiers.py +1006 -0
  25. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/SPEC.md +515 -0
  26. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/__init__.py +0 -0
  27. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/continuitybench/__init__.py +0 -0
  28. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/continuitybench/run.py +146 -0
  29. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/continuitybench/scenarios.py +544 -0
  30. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.anchor.v1.json +62 -0
  31. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.capsule.v1.json +112 -0
  32. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.continuity-receipt.v1.json +315 -0
  33. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.event.v1.json +73 -0
  34. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.proof-predicate.v1.json +35 -0
  35. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.proof.v1.json +242 -0
  36. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.recovery.v1.json +109 -0
  37. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.resume.v1.json +332 -0
  38. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/__init__.py +1 -0
  39. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/schema_validation.py +61 -0
  40. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_api_contract.py +1051 -0
  41. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_cli.py +664 -0
  42. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_conformance.py +758 -0
  43. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_distribution_environment.py +26 -0
  44. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_engine_e2e.py +286 -0
  45. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_github_redaction.py +193 -0
  46. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_instrument_validation.py +535 -0
  47. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_invalidation_resume.py +205 -0
  48. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_learning_partial_api.py +380 -0
  49. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_memory_extraction.py +151 -0
  50. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions.py +406 -0
  51. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round10_release.py +842 -0
  52. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round11_deep_integrity.py +831 -0
  53. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round12_release_blockers.py +206 -0
  54. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round13_jcs.py +212 -0
  55. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round14_state_integrity.py +1056 -0
  56. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round15_capsule_invalidation_github.py +251 -0
  57. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round15_public_boundaries.py +561 -0
  58. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round15_storage_boundaries.py +707 -0
  59. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round16_cli_api_core_capabilities.py +285 -0
  60. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round2.py +388 -0
  61. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round3.py +490 -0
  62. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round4.py +557 -0
  63. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round5.py +583 -0
  64. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round6.py +559 -0
  65. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round7.py +579 -0
  66. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_boundaries.py +558 -0
  67. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_engine.py +843 -0
  68. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_portability.py +2250 -0
  69. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_state.py +705 -0
  70. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round9_integrity.py +885 -0
  71. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round9_scope_freshness.py +179 -0
  72. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_release_boundary_validation.py +676 -0
  73. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_release_controls.py +840 -0
  74. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_sqlite_lifecycle.py +52 -0
  75. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_store_graph.py +172 -0
  76. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_trust.py +398 -0
  77. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/action_intent_wrong_type.json +170 -0
  78. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/actor_wrong_type.json +170 -0
  79. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/created_at_invalid_calendar_date.json +172 -0
  80. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/created_at_noncanonical_offset.json +172 -0
  81. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_authoritative_failure.json +114 -0
  82. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_authoritative_pass.json +107 -0
  83. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_no_results.json +99 -0
  84. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_self_asserted_pass.json +105 -0
  85. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/generate.py +498 -0
  86. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/incomplete_failed_check.json +168 -0
  87. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/index.json +240 -0
  88. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/input_item_wrong_type.json +163 -0
  89. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/invalid_result_value.json +170 -0
  90. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/lamport_fingerprint_spoof.json +1458 -0
  91. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/lamport_no_registry.json +1454 -0
  92. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/lamport_truncated_key.json +1454 -0
  93. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/missing_required_field.json +157 -0
  94. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/non_binary64_integer.json +174 -0
  95. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/policy_decision_wrong_type.json +146 -0
  96. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/retry_cannot_launder_a_failure.json +197 -0
  97. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/self_asserted_cannot_satisfy.json +173 -0
  98. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/self_assertion_cannot_poison_authoritative_pass.json +194 -0
  99. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/status_contradicts_contents.json +173 -0
  100. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/summary_contradicts_contents.json +170 -0
  101. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/tamper_statement.json +172 -0
  102. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/tamper_statement_resealed.json +172 -0
  103. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/task_id_under_unrelated_field.json +172 -0
  104. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unbound_task.json +165 -0
  105. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_action_intent_field.json +173 -0
  106. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_algorithm.json +172 -0
  107. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_schema_version.json +170 -0
  108. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_top_level_field.json +175 -0
  109. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unverified_forged_body_no_key.json +170 -0
  110. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unverified_hmac_no_key.json +170 -0
  111. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_hmac.json +172 -0
  112. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_hmac_jcs_edges.json +186 -0
  113. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_hmac_unicode.json +172 -0
  114. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_lamport.json +1458 -0
  115. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/wrong_project.json +173 -0
  116. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/verifiers/__init__.py +0 -0
  117. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/verifiers/verify_proof.py +1109 -0
  118. causal_continuity_engine-0.1.0.dist-info/METADATA +720 -0
  119. causal_continuity_engine-0.1.0.dist-info/RECORD +124 -0
  120. causal_continuity_engine-0.1.0.dist-info/WHEEL +5 -0
  121. causal_continuity_engine-0.1.0.dist-info/entry_points.txt +2 -0
  122. causal_continuity_engine-0.1.0.dist-info/licenses/LICENSE.txt +202 -0
  123. causal_continuity_engine-0.1.0.dist-info/licenses/NOTICE +6 -0
  124. causal_continuity_engine-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,4354 @@
1
+ """CCE engine facade: canonical event processing and project operations.
2
+
3
+ accept -> store immutable envelope (redacted per capture policy)
4
+ -> normalize -> extract -> dedup + graph write -> conflict &
5
+ invalidation detection -> active-state/packet recompute flag
6
+ -> policy/check publication -> metrics.
7
+
8
+ Extracted nodes use deterministic content-derived ids (stable_key) so a
9
+ clean-database replay of the event log rebuilds an equivalent projection
10
+ (CCG-006); runtime nodes (sessions, checkpoints) keep random ids and are
11
+ compared by type/status counts.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import os
18
+ import re
19
+ import sqlite3
20
+ import stat
21
+ from collections import Counter
22
+ from pathlib import Path
23
+
24
+ from .capsule import CapsuleManager
25
+ from .core import (
26
+ Signer,
27
+ canonical_json,
28
+ digest_obj,
29
+ is_canonical_utc_timestamp,
30
+ is_rfc3339_datetime,
31
+ new_id,
32
+ sha256_hex,
33
+ strict_json_loads,
34
+ utcnow,
35
+ validate_human_text,
36
+ validate_public_identifier,
37
+ validate_repository_name,
38
+ )
39
+ from .evidence import (
40
+ EvidenceGrade,
41
+ MutationReport,
42
+ grade_evidence,
43
+ normalize_artifact_path,
44
+ normalize_artifact_paths,
45
+ run_determinism_probe,
46
+ run_mutation_probe,
47
+ )
48
+ from .extraction import DeterministicExtractor, normalize_statement
49
+ from .github import (
50
+ TRUSTED_ASSOCIATIONS,
51
+ WEBHOOK_BODY_MAX_BYTES,
52
+ WebhookPayloadError,
53
+ continuity_conclusion,
54
+ normalize,
55
+ validate_webhook_secret,
56
+ verify_signature,
57
+ )
58
+ from .graph import Graph
59
+ from .invalidation import InvalidationEngine
60
+ from .learning import EvalGenerator, FailureComposter, ReplayManager, SkillProposer
61
+ from .memory import Memory
62
+ from .ontology import authority_rank
63
+ from .partial import PartialProgressManager
64
+ from .policy import ACTION_CLASSES, PolicyEngine, proof_policy_verifier_gaps
65
+ from .proof import (
66
+ AUTHORITATIVE_SOURCES,
67
+ SELF_ASSERTED,
68
+ ProofEnvelope,
69
+ detect_stale,
70
+ validate_envelope_shape,
71
+ verify_envelope,
72
+ )
73
+ from .redaction import CAPTURE_MODES, apply_capture_mode
74
+ from .resume import ResumeComposer
75
+ from .store import (
76
+ DuplicateEventError,
77
+ Store,
78
+ serialized_access,
79
+ )
80
+ from .verifiers import VerifierRunner, VerifierSpec, record_verification
81
+
82
+ PROCESSOR_VERSION = "cce-processor/1.0.0"
83
+
84
+ _KIND_PREFIX = {"assumption": "asm", "requirement": "req", "constraint": "cst",
85
+ "decision": "dec", "claim": "clm", "task": "tsk"}
86
+
87
+ _CONTINUITY_LINK_TYPES = {
88
+ "task_ids": "task",
89
+ "requirement_ids": "requirement",
90
+ "decision_ids": "decision",
91
+ "assumption_ids": "assumption",
92
+ "artifact_ids": "artifact",
93
+ "evidence_ids": "evidence",
94
+ "action_ids": "action",
95
+ }
96
+
97
+ _CONTINUITY_STATE_FIELDS = (
98
+ "node_id", "version", "entity_type", "tenant_id", "project_id",
99
+ "status", "criticality", "authority", "confidence", "scope", "data",
100
+ "valid_from", "valid_to", "event_id", "extractor",
101
+ "extractor_version",
102
+ )
103
+
104
+ _AUTHORIZED_ASSOCIATIONS = TRUSTED_ASSOCIATIONS
105
+
106
+ _VOLATILE_PROJECTION_KEYS = {
107
+ "captured_at", "checked_at", "completed_at", "composed_at", "created_at",
108
+ "decided_at", "fired_at", "generated_at", "packet_generation_time",
109
+ "ran_at", "started_at", "superseded_at",
110
+ }
111
+ _INTERNAL_RANDOM_ID = re.compile(
112
+ r"^(act|art|asm|cap|ckp|clm|cst|dec|edg|evd|evl|flr|grt|inv|nod|"
113
+ r"out|pln|prf|req|rpl|rsp|ses|skl|tsk|ver)_[0-9a-f]{24}$")
114
+ _SHA256_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
115
+
116
+ _CONTINUITY_PAYLOAD_TYPE = (
117
+ "https://raw.githubusercontent.com/thequantumfalcon/"
118
+ "causal-continuity-engine/v0.1.0/schemas/"
119
+ "cce.continuity-receipt.v1.json")
120
+ _CONTINUITY_PREDICATE_NAMES = frozenset({
121
+ "critical_invalidations_empty",
122
+ "human_approvals_complete",
123
+ "authority_conflicts_empty",
124
+ "active_proof_failures_empty",
125
+ "required_verifiers_current",
126
+ "resume_packet_current",
127
+ "revision_frontier_decidable",
128
+ "integrity_chains_intact",
129
+ })
130
+
131
+ def _continuity_predicates(project_id: str, state: dict) -> list[dict]:
132
+ """Derive every v1 witness predicate from its committed decision state."""
133
+ invalidations = state.get("invalidations") or []
134
+ critical_ids = [
135
+ item.get("node_id") for item in invalidations
136
+ if item.get("severity") in ("high", "critical")]
137
+ pending_ids = [
138
+ item.get("node_id") for item in invalidations
139
+ if item.get("status") == "pending_confirmation"]
140
+ conflicts = state.get("conflicts") or []
141
+ proofs = state.get("proofs") or []
142
+ gaps = sorted(state.get("verifier_gaps") or [])
143
+ trusted_passes = state.get("trusted_passes") or []
144
+ packet = state.get("packet") or {}
145
+ revision = state.get("revision") or {}
146
+ integrity = state.get("integrity") or {}
147
+ revision_decidable = not (
148
+ bool(revision.get("uncertain"))
149
+ or bool(revision.get("external_without_frontier")))
150
+ chains_intact = bool(
151
+ integrity.get("event_log_intact")
152
+ and integrity.get("audit_log_intact"))
153
+ return [
154
+ {
155
+ "predicate": "critical_invalidations_empty",
156
+ "subject": project_id,
157
+ "observed": len(critical_ids), "required": 0,
158
+ "satisfied": not critical_ids,
159
+ "evidence_digest": digest_obj(critical_ids),
160
+ "remediation": (
161
+ "resolve or narrow every high/critical invalidation"),
162
+ },
163
+ {
164
+ "predicate": "human_approvals_complete",
165
+ "subject": project_id,
166
+ "observed": len(pending_ids), "required": 0,
167
+ "satisfied": not pending_ids,
168
+ "evidence_digest": digest_obj(pending_ids),
169
+ "remediation": "record each required human confirmation",
170
+ },
171
+ {
172
+ "predicate": "authority_conflicts_empty",
173
+ "subject": project_id,
174
+ "observed": len(conflicts), "required": 0,
175
+ "satisfied": not conflicts,
176
+ "evidence_digest": digest_obj(conflicts),
177
+ "remediation": (
178
+ "resolve every contested authoritative statement"),
179
+ },
180
+ {
181
+ "predicate": "active_proof_failures_empty",
182
+ "subject": project_id,
183
+ "observed": len(proofs), "required": 0,
184
+ "satisfied": not proofs,
185
+ "evidence_digest": digest_obj(proofs),
186
+ "remediation": (
187
+ "re-attest or explicitly resolve failed proof claims"),
188
+ },
189
+ {
190
+ "predicate": "required_verifiers_current",
191
+ "subject": project_id,
192
+ "observed": gaps, "required": [],
193
+ "satisfied": not gaps,
194
+ "evidence_digest": digest_obj(trusted_passes),
195
+ "remediation": (
196
+ "configure and run every required verifier at the bound "
197
+ "frontier"),
198
+ },
199
+ {
200
+ "predicate": "resume_packet_current",
201
+ "subject": project_id,
202
+ "observed": bool(packet.get("current")), "required": True,
203
+ "satisfied": bool(packet.get("current")),
204
+ "evidence_digest": digest_obj(packet),
205
+ "remediation": (
206
+ "compose a resume packet after the latest control-state change"),
207
+ },
208
+ {
209
+ "predicate": "revision_frontier_decidable",
210
+ "subject": revision.get("tracked_ref"),
211
+ "observed": revision_decidable, "required": True,
212
+ "satisfied": revision_decidable,
213
+ "evidence_digest": digest_obj(revision),
214
+ "remediation": (
215
+ "reconcile the tracked ref and establish its current SHA"),
216
+ },
217
+ {
218
+ "predicate": "integrity_chains_intact",
219
+ "subject": project_id,
220
+ "observed": chains_intact, "required": True,
221
+ "satisfied": chains_intact,
222
+ "evidence_digest": digest_obj(integrity),
223
+ "remediation": (
224
+ "restore from a trusted checkpoint and verify external anchors"),
225
+ },
226
+ ]
227
+
228
+
229
+ def _continuity_decision(predicates: list[dict]) -> str:
230
+ """Recompute the GitHub conclusion from the typed predicate frontier."""
231
+ values = {item["predicate"]: bool(item["satisfied"])
232
+ for item in predicates}
233
+ return continuity_conclusion(
234
+ critical_invalidation=not values["critical_invalidations_empty"],
235
+ proof_ok=(values["active_proof_failures_empty"]
236
+ and values["required_verifiers_current"]),
237
+ packet_current=values["resume_packet_current"],
238
+ authority_conflict=not values["authority_conflicts_empty"],
239
+ approval_needed=not values["human_approvals_complete"],
240
+ trust_unavailable=(not values["revision_frontier_decidable"]
241
+ or not values["integrity_chains_intact"]),
242
+ )
243
+
244
+
245
+ _EVIDENCE_FLOOR_RANK = {None: 0, "D": 1, "C": 2, "B": 3, "A": 4}
246
+
247
+
248
+ class AttestationInputError(ValueError):
249
+ """Caller-controlled proof input failed before any verifier or mutation."""
250
+
251
+
252
+ class GitHubDeliveryError(PermissionError):
253
+ """A GitHub delivery failed the configured project identity boundary."""
254
+
255
+
256
+ class UnsafeArtifactError(ValueError):
257
+ """A declared artifact reaches bytes through an unstable filesystem link."""
258
+
259
+
260
+ _REPARSE_POINT = 0x400 # Windows FILE_ATTRIBUTE_REPARSE_POINT
261
+ _POSIX_ARTIFACT_FD_SNAPSHOT = bool(
262
+ os.name == "posix"
263
+ and hasattr(os, "O_NOFOLLOW")
264
+ and hasattr(os, "O_DIRECTORY")
265
+ and os.open in getattr(os, "supports_dir_fd", set())
266
+ and os.stat in getattr(os, "supports_dir_fd", set())
267
+ and os.stat in getattr(os, "supports_follow_symlinks", set())
268
+ and os.listdir in getattr(os, "supports_fd", set())
269
+ )
270
+
271
+
272
+ def _artifact_identity_key(info: os.stat_result) -> tuple:
273
+ """Physical entry identity, excluding mutable directory contents."""
274
+ return (
275
+ info.st_dev,
276
+ info.st_ino,
277
+ stat.S_IFMT(info.st_mode),
278
+ getattr(info, "st_file_attributes", 0),
279
+ getattr(info, "st_reparse_tag", 0),
280
+ )
281
+
282
+
283
+ def _artifact_stat_key(info: os.stat_result) -> tuple:
284
+ """Metadata that must bracket one regular-file content read."""
285
+ return _artifact_identity_key(info) + (
286
+ info.st_mode,
287
+ info.st_nlink,
288
+ info.st_size,
289
+ getattr(info, "st_mtime_ns", int(info.st_mtime * 1_000_000_000)),
290
+ getattr(info, "st_ctime_ns", int(info.st_ctime * 1_000_000_000)),
291
+ )
292
+
293
+
294
+ def _artifact_is_indirect(info: os.stat_result) -> bool:
295
+ return bool(
296
+ stat.S_ISLNK(info.st_mode)
297
+ or getattr(info, "st_file_attributes", 0) & _REPARSE_POINT
298
+ )
299
+
300
+
301
+ def _hash_artifact_file(path: str | Path, *, fd: int | None = None) -> str:
302
+ """Stream one file from a held descriptor when the host supports it."""
303
+ digest = hashlib.sha256()
304
+ if fd is not None:
305
+ os.lseek(fd, 0, os.SEEK_SET)
306
+ while True:
307
+ chunk = os.read(fd, 1024 * 1024)
308
+ if not chunk:
309
+ break
310
+ digest.update(chunk)
311
+ return "sha256:" + digest.hexdigest()
312
+ with Path(path).open("rb") as stream:
313
+ while True:
314
+ chunk = stream.read(1024 * 1024)
315
+ if not chunk:
316
+ break
317
+ digest.update(chunk)
318
+ return "sha256:" + digest.hexdigest()
319
+
320
+
321
+ def _artifact_changed(rel: str, detail: str = "") -> UnsafeArtifactError:
322
+ suffix = f": {detail}" if detail else ""
323
+ return UnsafeArtifactError(
324
+ f"declared artifact {rel!r} changed while hashing{suffix}")
325
+
326
+
327
+ def _artifact_link(
328
+ rel: str, location: str, *, nested: bool = False
329
+ ) -> UnsafeArtifactError:
330
+ relation = "contains" if nested else "uses"
331
+ return UnsafeArtifactError(
332
+ f"declared artifact {rel!r} {relation} a symlink or reparse "
333
+ f"{'point' if nested else 'route'} at {location}")
334
+
335
+
336
+ def _posix_stat_at(parent_fd: int, name: str) -> os.stat_result:
337
+ return os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
338
+
339
+
340
+ def _posix_open_stable(
341
+ parent_fd: int, name: str, before: os.stat_result, *,
342
+ directory: bool, rel: str) -> int:
343
+ flags = os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)
344
+ if directory:
345
+ flags |= os.O_DIRECTORY
346
+ try:
347
+ fd = os.open(name, flags, dir_fd=parent_fd)
348
+ except OSError as exc:
349
+ raise _artifact_changed(
350
+ rel, f"route changed or became unreadable at {name!r}") from exc
351
+ try:
352
+ opened = os.fstat(fd)
353
+ current = _posix_stat_at(parent_fd, name)
354
+ key = (
355
+ _artifact_stat_key
356
+ if directory or stat.S_ISREG(before.st_mode)
357
+ else _artifact_identity_key
358
+ )
359
+ if (_artifact_is_indirect(opened)
360
+ or _artifact_is_indirect(current)
361
+ or key(opened) != key(before)
362
+ or key(current) != key(before)):
363
+ raise _artifact_changed(rel, f"route changed at {name!r}")
364
+ except Exception:
365
+ os.close(fd)
366
+ raise
367
+ return fd
368
+
369
+
370
+ def _snapshot_posix_directory(
371
+ fd: int, path: Path, rel: str, prefix: str = "") -> list[dict]:
372
+ before_dir = os.fstat(fd)
373
+ try:
374
+ before_names = sorted(os.listdir(fd))
375
+ except OSError as exc:
376
+ raise _artifact_changed(
377
+ rel, "directory inventory became unreadable") from exc
378
+ before_entries: dict[str, os.stat_result] = {}
379
+ records: list[dict] = []
380
+ for name in before_names:
381
+ logical = f"{prefix}/{name}" if prefix else name
382
+ try:
383
+ before = _posix_stat_at(fd, name)
384
+ except OSError as exc:
385
+ raise _artifact_changed(
386
+ rel, f"entry vanished at {logical!r}") from exc
387
+ before_entries[name] = before
388
+ if _artifact_is_indirect(before):
389
+ raise _artifact_link(rel, logical, nested=True)
390
+ if stat.S_ISREG(before.st_mode):
391
+ child_fd = _posix_open_stable(
392
+ fd, name, before, directory=False, rel=rel)
393
+ try:
394
+ file_digest = _hash_artifact_file(
395
+ path / name, fd=child_fd)
396
+ opened_after = os.fstat(child_fd)
397
+ entry_after = _posix_stat_at(fd, name)
398
+ if (_artifact_is_indirect(entry_after)
399
+ or _artifact_stat_key(opened_after)
400
+ != _artifact_stat_key(before)
401
+ or _artifact_stat_key(entry_after)
402
+ != _artifact_stat_key(before)):
403
+ raise _artifact_changed(rel, f"file changed at {logical!r}")
404
+ except OSError as exc:
405
+ raise _artifact_changed(
406
+ rel, f"file changed at {logical!r}") from exc
407
+ finally:
408
+ os.close(child_fd)
409
+ records.append({
410
+ "kind": "file", "path": logical, "digest": file_digest})
411
+ elif stat.S_ISDIR(before.st_mode):
412
+ child_fd = _posix_open_stable(
413
+ fd, name, before, directory=True, rel=rel)
414
+ try:
415
+ records.append({"kind": "directory", "path": logical})
416
+ records.extend(_snapshot_posix_directory(
417
+ child_fd, path / name, rel, logical))
418
+ opened_after = os.fstat(child_fd)
419
+ entry_after = _posix_stat_at(fd, name)
420
+ if (_artifact_stat_key(opened_after)
421
+ != _artifact_stat_key(before)
422
+ or _artifact_stat_key(entry_after)
423
+ != _artifact_stat_key(before)):
424
+ raise _artifact_changed(
425
+ rel, f"directory changed at {logical!r}")
426
+ except OSError as exc:
427
+ raise _artifact_changed(
428
+ rel, f"directory changed at {logical!r}") from exc
429
+ finally:
430
+ os.close(child_fd)
431
+ else:
432
+ raise UnsafeArtifactError(
433
+ f"declared artifact {rel!r} contains unsupported physical "
434
+ f"entry {logical!r}")
435
+
436
+ try:
437
+ after_names = sorted(os.listdir(fd))
438
+ after_dir = os.fstat(fd)
439
+ except OSError as exc:
440
+ raise _artifact_changed(
441
+ rel, "directory inventory became unreadable") from exc
442
+ if (after_names != before_names
443
+ or _artifact_stat_key(after_dir)
444
+ != _artifact_stat_key(before_dir)):
445
+ raise _artifact_changed(rel, "directory inventory changed")
446
+ for name in after_names:
447
+ try:
448
+ after = _posix_stat_at(fd, name)
449
+ except OSError as exc:
450
+ raise _artifact_changed(rel, f"entry vanished at {name!r}") from exc
451
+ if (_artifact_is_indirect(after)
452
+ or _artifact_stat_key(after)
453
+ != _artifact_stat_key(before_entries[name])):
454
+ raise _artifact_changed(rel, f"entry changed at {name!r}")
455
+ return records
456
+
457
+
458
+ def _verify_posix_route(
459
+ root: Path, root_fd: int, root_identity: tuple,
460
+ route: list[tuple[int, str, int, tuple]], rel: str) -> None:
461
+ try:
462
+ root_now = os.lstat(root)
463
+ if (_artifact_is_indirect(root_now)
464
+ or _artifact_identity_key(root_now) != root_identity
465
+ or _artifact_identity_key(os.fstat(root_fd)) != root_identity):
466
+ raise _artifact_changed(rel, "verifier workdir route changed")
467
+ for parent_fd, name, child_fd, identity in route:
468
+ entry = _posix_stat_at(parent_fd, name)
469
+ if (_artifact_is_indirect(entry)
470
+ or _artifact_identity_key(entry) != identity
471
+ or _artifact_identity_key(os.fstat(child_fd)) != identity):
472
+ raise _artifact_changed(rel, f"route changed at {name!r}")
473
+ except UnsafeArtifactError:
474
+ raise
475
+ except OSError as exc:
476
+ raise _artifact_changed(rel, "artifact route changed") from exc
477
+
478
+
479
+ def _snapshot_artifact_posix(root: Path, rel: str) -> str:
480
+ root_before = os.lstat(root)
481
+ if _artifact_is_indirect(root_before):
482
+ raise _artifact_link(rel, "<verifier-workdir>")
483
+ if not stat.S_ISDIR(root_before.st_mode):
484
+ raise UnsafeArtifactError("verifier workdir is not a physical directory")
485
+ flags = (
486
+ os.O_RDONLY
487
+ | os.O_DIRECTORY
488
+ | os.O_NOFOLLOW
489
+ | getattr(os, "O_CLOEXEC", 0)
490
+ )
491
+ try:
492
+ root_fd = os.open(root, flags)
493
+ except OSError as exc:
494
+ raise _artifact_changed(rel, "verifier workdir route changed") from exc
495
+ route: list[tuple[int, str, int, tuple]] = []
496
+ opened_route_fds: list[int] = []
497
+ try:
498
+ root_identity = _artifact_identity_key(root_before)
499
+ if _artifact_identity_key(os.fstat(root_fd)) != root_identity:
500
+ raise _artifact_changed(rel, "verifier workdir route changed")
501
+ parent_fd = root_fd
502
+ cursor = root
503
+ parts = rel.split("/")
504
+ for part in parts[:-1]:
505
+ try:
506
+ before = _posix_stat_at(parent_fd, part)
507
+ except FileNotFoundError:
508
+ _verify_posix_route(
509
+ root, root_fd, root_identity, route, rel)
510
+ try:
511
+ _posix_stat_at(parent_fd, part)
512
+ except FileNotFoundError:
513
+ return sha256_hex("<absent>")
514
+ raise _artifact_changed(rel, f"route appeared at {part!r}")
515
+ if _artifact_is_indirect(before):
516
+ raise _artifact_link(rel, str((cursor / part).relative_to(root)))
517
+ if not stat.S_ISDIR(before.st_mode):
518
+ _verify_posix_route(
519
+ root, root_fd, root_identity, route, rel)
520
+ try:
521
+ after = _posix_stat_at(parent_fd, part)
522
+ except OSError as exc:
523
+ raise _artifact_changed(
524
+ rel, f"route changed at {part!r}") from exc
525
+ if _artifact_stat_key(after) != _artifact_stat_key(before):
526
+ raise _artifact_changed(rel, f"route changed at {part!r}")
527
+ return sha256_hex("<absent>")
528
+ child_fd = _posix_open_stable(
529
+ parent_fd, part, before, directory=True, rel=rel)
530
+ identity = _artifact_identity_key(before)
531
+ route.append((parent_fd, part, child_fd, identity))
532
+ opened_route_fds.append(child_fd)
533
+ parent_fd = child_fd
534
+ cursor /= part
535
+
536
+ name = parts[-1]
537
+ try:
538
+ before = _posix_stat_at(parent_fd, name)
539
+ except FileNotFoundError:
540
+ _verify_posix_route(root, root_fd, root_identity, route, rel)
541
+ try:
542
+ _posix_stat_at(parent_fd, name)
543
+ except FileNotFoundError:
544
+ return sha256_hex("<absent>")
545
+ raise _artifact_changed(rel, "artifact appeared during snapshot")
546
+ if _artifact_is_indirect(before):
547
+ raise _artifact_link(rel, rel)
548
+ target = root.joinpath(*parts)
549
+ if stat.S_ISREG(before.st_mode):
550
+ target_fd = _posix_open_stable(
551
+ parent_fd, name, before, directory=False, rel=rel)
552
+ try:
553
+ digest = _hash_artifact_file(target, fd=target_fd)
554
+ opened_after = os.fstat(target_fd)
555
+ entry_after = _posix_stat_at(parent_fd, name)
556
+ if (_artifact_is_indirect(entry_after)
557
+ or _artifact_stat_key(opened_after)
558
+ != _artifact_stat_key(before)
559
+ or _artifact_stat_key(entry_after)
560
+ != _artifact_stat_key(before)):
561
+ raise _artifact_changed(rel)
562
+ except OSError as exc:
563
+ raise _artifact_changed(rel) from exc
564
+ finally:
565
+ os.close(target_fd)
566
+ elif stat.S_ISDIR(before.st_mode):
567
+ target_fd = _posix_open_stable(
568
+ parent_fd, name, before, directory=True, rel=rel)
569
+ try:
570
+ records = _snapshot_posix_directory(
571
+ target_fd, target, rel)
572
+ opened_after = os.fstat(target_fd)
573
+ entry_after = _posix_stat_at(parent_fd, name)
574
+ if (_artifact_stat_key(opened_after)
575
+ != _artifact_stat_key(before)
576
+ or _artifact_stat_key(entry_after)
577
+ != _artifact_stat_key(before)):
578
+ raise _artifact_changed(rel)
579
+ digest = sha256_hex(canonical_json(records))
580
+ except OSError as exc:
581
+ raise _artifact_changed(rel) from exc
582
+ finally:
583
+ os.close(target_fd)
584
+ else:
585
+ raise UnsafeArtifactError(
586
+ f"declared artifact {rel!r} is not a regular file or directory")
587
+ _verify_posix_route(root, root_fd, root_identity, route, rel)
588
+ return digest
589
+ finally:
590
+ for fd in reversed(opened_route_fds):
591
+ os.close(fd)
592
+ os.close(root_fd)
593
+
594
+
595
+ def _verify_fallback_route(
596
+ route: list[tuple[Path, tuple]], rel: str) -> None:
597
+ for path, identity in route:
598
+ try:
599
+ current = os.lstat(path)
600
+ except OSError as exc:
601
+ raise _artifact_changed(
602
+ rel, f"route vanished at {path.name!r}") from exc
603
+ if (_artifact_is_indirect(current)
604
+ or _artifact_identity_key(current) != identity):
605
+ raise _artifact_changed(rel, f"route changed at {path.name!r}")
606
+
607
+
608
+ def _snapshot_fallback_directory(
609
+ path: Path, rel: str, prefix: str = "") -> list[dict]:
610
+ before_dir = os.lstat(path)
611
+ if _artifact_is_indirect(before_dir):
612
+ raise _artifact_link(rel, prefix or rel, nested=bool(prefix))
613
+ try:
614
+ before_names = sorted(os.listdir(path))
615
+ except OSError as exc:
616
+ raise _artifact_changed(
617
+ rel, "directory inventory became unreadable") from exc
618
+ before_entries: dict[str, os.stat_result] = {}
619
+ records: list[dict] = []
620
+ for name in before_names:
621
+ child = path / name
622
+ logical = f"{prefix}/{name}" if prefix else name
623
+ try:
624
+ before = os.lstat(child)
625
+ except OSError as exc:
626
+ raise _artifact_changed(
627
+ rel, f"entry vanished at {logical!r}") from exc
628
+ before_entries[name] = before
629
+ if _artifact_is_indirect(before):
630
+ raise _artifact_link(rel, logical, nested=True)
631
+ if stat.S_ISREG(before.st_mode):
632
+ try:
633
+ file_digest = _hash_artifact_file(child)
634
+ after = os.lstat(child)
635
+ except OSError as exc:
636
+ raise _artifact_changed(
637
+ rel, f"file changed at {logical!r}") from exc
638
+ if (_artifact_is_indirect(after)
639
+ or _artifact_stat_key(after)
640
+ != _artifact_stat_key(before)):
641
+ raise _artifact_changed(rel, f"file changed at {logical!r}")
642
+ records.append({
643
+ "kind": "file", "path": logical, "digest": file_digest})
644
+ elif stat.S_ISDIR(before.st_mode):
645
+ records.append({"kind": "directory", "path": logical})
646
+ records.extend(_snapshot_fallback_directory(
647
+ child, rel, logical))
648
+ try:
649
+ after = os.lstat(child)
650
+ except OSError as exc:
651
+ raise _artifact_changed(
652
+ rel, f"directory changed at {logical!r}") from exc
653
+ if (_artifact_stat_key(after) != _artifact_stat_key(before)
654
+ or _artifact_is_indirect(after)):
655
+ raise _artifact_changed(
656
+ rel, f"directory changed at {logical!r}")
657
+ else:
658
+ raise UnsafeArtifactError(
659
+ f"declared artifact {rel!r} contains unsupported physical "
660
+ f"entry {logical!r}")
661
+
662
+ try:
663
+ after_names = sorted(os.listdir(path))
664
+ after_dir = os.lstat(path)
665
+ except OSError as exc:
666
+ raise _artifact_changed(
667
+ rel, "directory inventory became unreadable") from exc
668
+ if (after_names != before_names
669
+ or _artifact_stat_key(after_dir) != _artifact_stat_key(before_dir)):
670
+ raise _artifact_changed(rel, "directory inventory changed")
671
+ for name in after_names:
672
+ try:
673
+ after = os.lstat(path / name)
674
+ except OSError as exc:
675
+ raise _artifact_changed(rel, f"entry vanished at {name!r}") from exc
676
+ if _artifact_is_indirect(after):
677
+ raise _artifact_link(rel, name, nested=True)
678
+ if (_artifact_stat_key(after)
679
+ != _artifact_stat_key(before_entries[name])):
680
+ raise _artifact_changed(rel, f"entry changed at {name!r}")
681
+ return records
682
+
683
+
684
+ def _snapshot_artifact_fallback(root: Path, rel: str) -> str:
685
+ try:
686
+ root_before = os.lstat(root)
687
+ except OSError:
688
+ raise
689
+ if _artifact_is_indirect(root_before):
690
+ raise _artifact_link(rel, "<verifier-workdir>")
691
+ if not stat.S_ISDIR(root_before.st_mode):
692
+ raise UnsafeArtifactError("verifier workdir is not a physical directory")
693
+ route: list[tuple[Path, tuple]] = [
694
+ (root, _artifact_identity_key(root_before))]
695
+ cursor = root
696
+ parts = rel.split("/")
697
+ for part in parts[:-1]:
698
+ cursor /= part
699
+ try:
700
+ before = os.lstat(cursor)
701
+ except FileNotFoundError:
702
+ _verify_fallback_route(route, rel)
703
+ try:
704
+ os.lstat(cursor)
705
+ except FileNotFoundError:
706
+ return sha256_hex("<absent>")
707
+ raise _artifact_changed(rel, f"route appeared at {part!r}")
708
+ if _artifact_is_indirect(before):
709
+ raise _artifact_link(rel, str(cursor.relative_to(root)))
710
+ if not stat.S_ISDIR(before.st_mode):
711
+ _verify_fallback_route(route, rel)
712
+ try:
713
+ after = os.lstat(cursor)
714
+ except OSError as exc:
715
+ raise _artifact_changed(
716
+ rel, f"route changed at {part!r}") from exc
717
+ if _artifact_stat_key(after) != _artifact_stat_key(before):
718
+ raise _artifact_changed(rel, f"route changed at {part!r}")
719
+ return sha256_hex("<absent>")
720
+ route.append((cursor, _artifact_identity_key(before)))
721
+
722
+ target = root.joinpath(*parts)
723
+ try:
724
+ before = os.lstat(target)
725
+ except FileNotFoundError:
726
+ _verify_fallback_route(route, rel)
727
+ try:
728
+ os.lstat(target)
729
+ except FileNotFoundError:
730
+ return sha256_hex("<absent>")
731
+ raise _artifact_changed(rel, "artifact appeared during snapshot")
732
+ if _artifact_is_indirect(before):
733
+ raise _artifact_link(rel, rel)
734
+ if stat.S_ISREG(before.st_mode):
735
+ try:
736
+ digest = _hash_artifact_file(target)
737
+ after = os.lstat(target)
738
+ except OSError as exc:
739
+ raise _artifact_changed(rel) from exc
740
+ if (_artifact_is_indirect(after)
741
+ or _artifact_stat_key(after) != _artifact_stat_key(before)):
742
+ raise _artifact_changed(rel)
743
+ elif stat.S_ISDIR(before.st_mode):
744
+ records = _snapshot_fallback_directory(target, rel)
745
+ try:
746
+ after = os.lstat(target)
747
+ except OSError as exc:
748
+ raise _artifact_changed(rel) from exc
749
+ if (_artifact_is_indirect(after)
750
+ or _artifact_stat_key(after) != _artifact_stat_key(before)):
751
+ raise _artifact_changed(rel)
752
+ digest = sha256_hex(canonical_json(records))
753
+ else:
754
+ raise UnsafeArtifactError(
755
+ f"declared artifact {rel!r} is not a regular file or directory")
756
+ _verify_fallback_route(route, rel)
757
+ return digest
758
+
759
+
760
+ def _snapshot_artifact(root: Path, rel: str) -> str:
761
+ if _POSIX_ARTIFACT_FD_SNAPSHOT:
762
+ return _snapshot_artifact_posix(root, rel)
763
+ return _snapshot_artifact_fallback(root, rel)
764
+
765
+
766
+ def _policy_strength_covers(current: dict, previous: dict) -> bool:
767
+ """Whether a successful retry is comparable and at least as demanding.
768
+
769
+ Required verifiers are comparable only when their complete normalized
770
+ definitions are identical. A different timeout, oracle expectation,
771
+ negative control or artifact surface changes what a pass establishes just
772
+ as surely as changing the command does.
773
+ """
774
+ if not isinstance(current, dict) or not isinstance(previous, dict):
775
+ return False
776
+ current_defs = {
777
+ item.get("name"): item for item in current.get("required", [])
778
+ if isinstance(item, dict) and item.get("name")}
779
+ previous_defs = {
780
+ item.get("name"): item for item in previous.get("required", [])
781
+ if isinstance(item, dict) and item.get("name")}
782
+ for name, old in previous_defs.items():
783
+ new = current_defs.get(name)
784
+ if (new is None
785
+ or not old.get("definition_digest")
786
+ or new.get("definition_digest") != old["definition_digest"]):
787
+ return False
788
+ return _EVIDENCE_FLOOR_RANK.get(current.get("min_evidence_grade"), -1) >= \
789
+ _EVIDENCE_FLOOR_RANK.get(previous.get("min_evidence_grade"), -1)
790
+
791
+
792
+ def _preflight_proof_body(body: dict, verifications: list[dict]) -> None:
793
+ """Validate caller-controlled proof semantics before executing anything."""
794
+ try:
795
+ candidate = strict_json_loads(canonical_json(body))
796
+ candidate["verifications"] = strict_json_loads(
797
+ canonical_json(verifications))
798
+ except (TypeError, ValueError, OverflowError, RecursionError):
799
+ raise AttestationInputError(
800
+ "attestation inputs must be finite canonical JSON") from None
801
+ candidate["verification_summary"] = {
802
+ "unbacked_self_assertions": [],
803
+ "required": [],
804
+ "missing": [],
805
+ "failed": [],
806
+ "inconclusive": [],
807
+ "skipped": [],
808
+ "passed": [],
809
+ }
810
+ candidate["proof_digest"] = digest_obj({})
811
+ candidate["signature"] = {
812
+ "key_id": "preflight",
813
+ "algorithm": "hmac-sha256",
814
+ "value": "preflight",
815
+ }
816
+ errors = validate_envelope_shape(candidate)
817
+ if errors:
818
+ raise AttestationInputError(
819
+ "invalid attestation input: " + "; ".join(errors))
820
+
821
+ # Packet freshness watermark (CI-006). Persisted so a fresh process — every
822
+ # CLI invocation is one — sees the same staleness state as a long-lived
823
+ # service; an in-memory flag would make `cce-engine check` report success on state
824
+ # that no packet has ever reflected.
825
+ _ENGINE_SCHEMA = """
826
+ CREATE TABLE IF NOT EXISTS packet_watermark (
827
+ project_id TEXT PRIMARY KEY,
828
+ last_event_seq INTEGER NOT NULL,
829
+ composed_at TEXT NOT NULL,
830
+ packet_id TEXT,
831
+ packet_digest TEXT,
832
+ control_basis_digest TEXT,
833
+ audit_entry_hash TEXT
834
+ );
835
+
836
+ -- Single-use proofs (ADR-018) enforced by the database rather than by a
837
+ -- read-then-write. Scanning for a prior spend and then writing is two
838
+ -- statements, so two processes both find nothing and both proceed; the
839
+ -- PRIMARY KEY makes the second one an error (ADR-059).
840
+ CREATE TABLE IF NOT EXISTS spent_proofs (
841
+ tenant_id TEXT NOT NULL,
842
+ project_id TEXT NOT NULL,
843
+ proof_id TEXT NOT NULL,
844
+ task_id TEXT NOT NULL,
845
+ spent_at TEXT NOT NULL,
846
+ PRIMARY KEY (tenant_id, project_id, proof_id)
847
+ );
848
+ """
849
+
850
+
851
+ def _restates(existing: dict, item, ref: str, authority: str) -> bool:
852
+ """Whether this extraction asserts exactly what the node already records."""
853
+ data = existing.get("data") or {}
854
+ return (
855
+ data.get("statement") == item.statement
856
+ and ref in _source_refs(existing)
857
+ and existing.get("authority") == authority
858
+ and existing.get("criticality") == item.criticality
859
+ and existing.get("confidence") == item.confidence
860
+ and data.get("done", item.meta.get("done")) == item.meta.get("done")
861
+ )
862
+
863
+
864
+ def _proof_covers(proof: dict, task_id: str) -> bool:
865
+ """Whether a signed envelope names this task as its subject.
866
+
867
+ Task completion requires the typed task_ids continuity relation. Merely
868
+ mentioning a task under an unrelated link, requirement id, or subject
869
+ label does not make that task the action's completion target.
870
+ """
871
+ task_ids = (proof.get("continuity_links") or {}).get("task_ids")
872
+ return (
873
+ isinstance(task_ids, list)
874
+ and all(isinstance(value, str) for value in task_ids)
875
+ and task_id in task_ids
876
+ )
877
+
878
+
879
+ def _source_refs(node: dict | None) -> set[str]:
880
+ """Every source ref that currently states this node's content.
881
+
882
+ `source_refs` is authoritative once written; `source_ref` (the most
883
+ recent single writer, kept for readability) is only a fallback for nodes
884
+ that predate the list, and must never re-add a retracted ref.
885
+ """
886
+ if not node:
887
+ return set()
888
+ data = node.get("data") or {}
889
+ refs = data.get("source_refs")
890
+ if refs is not None:
891
+ return set(refs)
892
+ return {data["source_ref"]} if data.get("source_ref") else set()
893
+
894
+
895
+ def stable_node_id(project_id: str, kind: str, statement: str) -> str:
896
+ key = f"{project_id}|{kind}|{normalize_statement(statement)}"
897
+ return f"{_KIND_PREFIX.get(kind, 'nod')}_{hashlib.sha256(key.encode()).hexdigest()[:24]}"
898
+
899
+
900
+ class Engine:
901
+ def __init__(self, path=":memory:", *, tenant_id: str = "ten_local",
902
+ signer: Signer | None = None, tenant_max_level: int = 3,
903
+ workdir: str = "."):
904
+ self.tenant_id = validate_public_identifier(
905
+ tenant_id, field="tenant_id")
906
+ if signer is None:
907
+ signer = Signer.generate(f"{tenant_id}:default")
908
+ elif (not callable(getattr(signer, "sign", None))
909
+ or not callable(getattr(signer, "verify", None))):
910
+ raise ValueError(
911
+ "signer must provide callable sign and verify methods")
912
+ # Validate and preserve an explicitly supplied implementation before
913
+ # opening storage. Pluggable signer objects may intentionally be
914
+ # falsey; truthiness is not an interface or an authorization signal.
915
+ self.signer = signer
916
+ self.store = Store(path)
917
+ try:
918
+ self._initialize_components_and_schema(
919
+ tenant_max_level=tenant_max_level, workdir=workdir)
920
+ except BaseException as initialization_error:
921
+ try:
922
+ self.store.close()
923
+ except Exception as cleanup_error:
924
+ initialization_error.add_note(
925
+ f"additionally failed to close engine storage: "
926
+ f"{cleanup_error!r}")
927
+ raise
928
+
929
+ def _initialize_components_and_schema(
930
+ self, *, tenant_max_level: int, workdir: str) -> None:
931
+ self.graph = Graph(self.store)
932
+ self.memory = Memory(
933
+ self.store, self.graph, tenant_id=self.tenant_id)
934
+ self.policy = PolicyEngine(
935
+ self.store, tenant_max_level=tenant_max_level,
936
+ graph=self.graph, tenant_id=self.tenant_id)
937
+ self.invalidation = InvalidationEngine(self.store, self.graph,
938
+ policy=self.policy,
939
+ tenant_id=self.tenant_id)
940
+ self.composer = ResumeComposer(
941
+ self.store, self.graph, self.memory, self.policy,
942
+ tenant_id=self.tenant_id)
943
+ self.capsules = CapsuleManager(self.store, self.graph, self.composer,
944
+ policy=self.policy, tenant_id=self.tenant_id,
945
+ state_basis_provider=self._packet_state_basis)
946
+ self.partial = PartialProgressManager(
947
+ self.store, self.graph, self.memory, tenant_id=self.tenant_id)
948
+ self.replay = ReplayManager(
949
+ self.store, self.graph, tenant_id=self.tenant_id)
950
+ self.composter = FailureComposter(
951
+ self.store, self.graph, tenant_id=self.tenant_id)
952
+ self.evalgen = EvalGenerator(
953
+ self.store, self.graph, tenant_id=self.tenant_id)
954
+ self.skills = SkillProposer(
955
+ self.store, self.graph, tenant_id=self.tenant_id)
956
+ self.extractor = DeterministicExtractor()
957
+ self.verifier_runner = VerifierRunner(self.store, workdir)
958
+ with self.store._lock:
959
+ conn = self.store._conn
960
+ conn.executescript(_ENGINE_SCHEMA)
961
+ # Cross-process startup must serialize inspection with ALTER;
962
+ # a process-local RLock cannot stop another Engine connection.
963
+ conn.execute("BEGIN IMMEDIATE")
964
+ try:
965
+ # SQLite's CREATE TABLE IF NOT EXISTS does not add columns to
966
+ # an existing installation. These nullable additions preserve
967
+ # old stores; a legacy watermark intentionally reads as stale
968
+ # until a new, authenticated packet is composed.
969
+ watermark_columns = {
970
+ row["name"] for row in conn.execute(
971
+ "PRAGMA table_info(packet_watermark)")
972
+ }
973
+ for name in (
974
+ "packet_digest", "control_basis_digest",
975
+ "audit_entry_hash"):
976
+ if name not in watermark_columns:
977
+ conn.execute(
978
+ f"ALTER TABLE packet_watermark ADD COLUMN {name} TEXT")
979
+ except BaseException:
980
+ if conn.in_transaction:
981
+ conn.rollback()
982
+ raise
983
+ else:
984
+ conn.commit()
985
+ # This migration owns an explicit BEGIN IMMEDIATE transaction;
986
+ # run it only after the general schema transaction has closed.
987
+ self._migrate_spent_proofs_scope()
988
+ self._backfill_spent_proofs()
989
+
990
+ def _migrate_spent_proofs_scope(self) -> bool:
991
+ """Upgrade the legacy global proof-id key without unspending it.
992
+
993
+ A proof identifier is meaningful only inside its tenant and project.
994
+ The old global primary key both denied an unrelated tenant on a
995
+ collision and disclosed the other tenant's task id in the rejection.
996
+ Migration resolves every legacy row through the immutable task
997
+ identity; an orphan is a hard failure because guessing its tenant
998
+ would silently make a previously spent proof reusable.
999
+ """
1000
+ with self.store._lock:
1001
+ conn = self.store._conn
1002
+ if conn.in_transaction:
1003
+ raise RuntimeError(
1004
+ "spent_proofs migration requires transaction ownership")
1005
+ conn.execute("BEGIN IMMEDIATE")
1006
+ try:
1007
+ columns = conn.execute(
1008
+ "PRAGMA table_info(spent_proofs)").fetchall()
1009
+ expected_pk = {
1010
+ "tenant_id": 1, "project_id": 2, "proof_id": 3,
1011
+ "task_id": 0, "spent_at": 0,
1012
+ }
1013
+ if ({row["name"]: row["pk"] for row in columns}
1014
+ == expected_pk):
1015
+ conn.commit()
1016
+ return False
1017
+
1018
+ legacy_rows = conn.execute(
1019
+ "SELECT * FROM spent_proofs").fetchall()
1020
+ legacy_tables = conn.execute(
1021
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' "
1022
+ "AND name = 'spent_proofs_legacy_scope'").fetchone()
1023
+ if legacy_tables is not None:
1024
+ raise RuntimeError(
1025
+ "cannot migrate spent_proofs: a prior legacy table remains")
1026
+
1027
+ migrated = []
1028
+ column_names = {row["name"] for row in columns}
1029
+ for row in legacy_rows:
1030
+ tenant_id = (
1031
+ row["tenant_id"] if "tenant_id" in column_names else None)
1032
+ if tenant_id is None:
1033
+ tenants = conn.execute(
1034
+ "SELECT DISTINCT tenant_id FROM nodes "
1035
+ "WHERE node_id = ? AND project_id = ?",
1036
+ (row["task_id"], row["project_id"])).fetchall()
1037
+ if len(tenants) != 1:
1038
+ raise RuntimeError(
1039
+ "cannot safely scope legacy spent proof "
1040
+ f"{row['proof_id']!r}: its task identity is missing "
1041
+ "or ambiguous")
1042
+ tenant_id = tenants[0]["tenant_id"]
1043
+ migrated.append((
1044
+ tenant_id, row["project_id"], row["proof_id"],
1045
+ row["task_id"], row["spent_at"]))
1046
+
1047
+ conn.execute(
1048
+ "ALTER TABLE spent_proofs "
1049
+ "RENAME TO spent_proofs_legacy_scope")
1050
+ conn.execute(
1051
+ "CREATE TABLE spent_proofs ("
1052
+ "tenant_id TEXT NOT NULL, project_id TEXT NOT NULL, "
1053
+ "proof_id TEXT NOT NULL, task_id TEXT NOT NULL, "
1054
+ "spent_at TEXT NOT NULL, "
1055
+ "PRIMARY KEY (tenant_id, project_id, proof_id))")
1056
+ conn.executemany(
1057
+ "INSERT INTO spent_proofs "
1058
+ "(tenant_id, project_id, proof_id, task_id, spent_at) "
1059
+ "VALUES (?,?,?,?,?)", migrated)
1060
+ conn.execute("DROP TABLE spent_proofs_legacy_scope")
1061
+ except BaseException:
1062
+ if conn.in_transaction:
1063
+ conn.rollback()
1064
+ raise
1065
+ else:
1066
+ conn.commit()
1067
+ return True
1068
+
1069
+ def _backfill_spent_proofs(self) -> int:
1070
+ """Seed spent_proofs from completions recorded before it existed.
1071
+
1072
+ ADR-059 moved single-use from a scan of task.completion_evidence to a
1073
+ PRIMARY KEY, so two concurrent completions could not both win. On a
1074
+ store written before that change the new table is created EMPTY by
1075
+ `CREATE TABLE IF NOT EXISTS`, which silently un-spent every proof the
1076
+ project had ever used: each one could complete a second task after the
1077
+ upgrade. The replacement mechanism has to inherit the old one's
1078
+ history, not start from zero (ADR-064).
1079
+
1080
+ The scan is tenant-scoped and idempotent. It cannot stop merely
1081
+ because some other tenant already has a spend row.
1082
+ """
1083
+ # The inherited spend and its audit record are one migration fact.
1084
+ # If either write fails, leave both absent so the next startup retries
1085
+ # the complete operation instead of silently retaining an unaudited
1086
+ # spend row.
1087
+ with self.store.transaction():
1088
+ rows = self.store._conn.execute(
1089
+ "SELECT node_id, version, tenant_id, project_id, data, tx_from "
1090
+ "FROM nodes WHERE entity_type = 'task' AND tenant_id = ? "
1091
+ "ORDER BY project_id, tx_from, node_id, version",
1092
+ (self.tenant_id,)).fetchall()
1093
+ seeded = 0
1094
+ seen: set[tuple[str, str, str]] = set()
1095
+ for row in rows:
1096
+ try:
1097
+ proof_id = (strict_json_loads(row["data"]) or {}).get(
1098
+ "completion_evidence")
1099
+ except (ValueError, TypeError):
1100
+ continue
1101
+ if not isinstance(proof_id, str) or not proof_id:
1102
+ continue
1103
+ identity = (row["tenant_id"], row["project_id"], proof_id)
1104
+ if identity in seen:
1105
+ continue
1106
+ seen.add(identity)
1107
+ cur = self.store._conn.execute(
1108
+ "INSERT OR IGNORE INTO spent_proofs (tenant_id, project_id,"
1109
+ " proof_id, task_id, spent_at) VALUES (?,?,?,?,?)",
1110
+ (row["tenant_id"], row["project_id"], proof_id,
1111
+ row["node_id"],
1112
+ row["tx_from"]))
1113
+ seeded += cur.rowcount
1114
+ if seeded:
1115
+ self.store.audit(
1116
+ actor="cce", action="spent_proofs.backfill",
1117
+ authority="verifier_authoritative",
1118
+ detail=f"carried {seeded} pre-existing completion(s) into the"
1119
+ f" single-use table (ADR-064)")
1120
+ return seeded
1121
+
1122
+ @serialized_access
1123
+ def close(self):
1124
+ self.store.close()
1125
+
1126
+ def _require_project(self, project_id: str) -> dict:
1127
+ """Return this engine tenant's project or fail without leaking it."""
1128
+ validate_public_identifier(project_id, field="project_id")
1129
+ try:
1130
+ return self.graph.get(
1131
+ project_id, tenant_id=self.tenant_id,
1132
+ project_id=project_id, entity_type="project")
1133
+ except KeyError:
1134
+ raise PermissionError(
1135
+ f"project {project_id!r} does not exist in tenant "
1136
+ f"{self.tenant_id!r} or belongs to another scope") from None
1137
+
1138
+ @staticmethod
1139
+ def _github_numeric_id(value, field: str, *, optional: bool = False):
1140
+ if value is None and optional:
1141
+ return None
1142
+ if (isinstance(value, bool)
1143
+ or not isinstance(value, int)
1144
+ or not 1 <= value <= 2**63 - 1):
1145
+ suffix = " or null" if optional else ""
1146
+ raise ValueError(
1147
+ f"{field} must be a positive signed 64-bit integer{suffix}")
1148
+ return value
1149
+
1150
+ # ---------------------------------------------------------------- project
1151
+
1152
+ @serialized_access
1153
+ def create_project(self, name: str, *, repository: str | None = None,
1154
+ repository_id: int | None = None,
1155
+ github_installation_id: int | None = None,
1156
+ capture_mode: str = "redacted", config: dict | None = None,
1157
+ project_id: str | None = None) -> dict:
1158
+ name = validate_human_text(name, field="project name")
1159
+ repository = validate_repository_name(
1160
+ repository, field="repository", optional=True)
1161
+ if (not isinstance(capture_mode, str)
1162
+ or capture_mode not in CAPTURE_MODES):
1163
+ raise ValueError(f"unknown capture mode {capture_mode!r}")
1164
+ if repository_id is not None:
1165
+ repository_id = self._github_numeric_id(
1166
+ repository_id, "repository_id")
1167
+ if github_installation_id is not None:
1168
+ github_installation_id = self._github_numeric_id(
1169
+ github_installation_id, "github_installation_id")
1170
+ if repository_id is None:
1171
+ raise ValueError(
1172
+ "github_installation_id requires repository_id")
1173
+ # Validate before creating the graph identity, then commit identity,
1174
+ # policy and audit as one unit. An invalid policy must not leave a
1175
+ # live project running under permissive defaults.
1176
+ config = self.policy.validate_project_config(
1177
+ {} if config is None else config)
1178
+ project_id = (
1179
+ new_id("project") if project_id is None
1180
+ else validate_public_identifier(project_id, field="project_id")
1181
+ )
1182
+ with self.store.transaction():
1183
+ # Project ids are identities, not update handles. Graph.put_node
1184
+ # versions an existing id by design, so creation must reserve the
1185
+ # identity while holding the same BEGIN IMMEDIATE transaction as
1186
+ # the graph, policy and audit writes. The database lock makes
1187
+ # concurrent creators observe the winner before either can
1188
+ # mutate its state.
1189
+ if self.store._conn.execute(
1190
+ "SELECT 1 FROM nodes WHERE node_id = ? LIMIT 1",
1191
+ (project_id,)).fetchone() is not None:
1192
+ raise ValueError(
1193
+ f"project_id {project_id!r} is already in use")
1194
+ node = self.graph.put_node(
1195
+ entity_type="project", tenant_id=self.tenant_id,
1196
+ project_id=project_id, node_id=project_id, status="active",
1197
+ data={"name": name, "repository": repository,
1198
+ "repository_id": repository_id,
1199
+ "github_installation_id": github_installation_id,
1200
+ "capture_mode": capture_mode, "created_at": utcnow()},
1201
+ )
1202
+ self.policy.set_project_config(project_id, config)
1203
+ self.store.audit(
1204
+ actor="cce", action="project.create", object_id=project_id,
1205
+ detail=name)
1206
+ return node
1207
+
1208
+ @serialized_access
1209
+ def project_capture_mode(self, project_id: str) -> str:
1210
+ return self._require_project(project_id)["data"].get(
1211
+ "capture_mode", "redacted")
1212
+
1213
+ # ------------------------------------------------------------------ ingest
1214
+
1215
+ @serialized_access
1216
+ def ingest_github(
1217
+ self,
1218
+ project_id: str,
1219
+ event_name: str,
1220
+ delivery_id: str,
1221
+ payload: dict,
1222
+ *,
1223
+ raw_body: bytes | None = None,
1224
+ signature_header: str | None = None,
1225
+ webhook_secret: str | None = None,
1226
+ ) -> dict | None:
1227
+ """Verify (when a secret is configured), normalize, persist, process.
1228
+ Returns the processing report, or None for a benign duplicate."""
1229
+ # Validate before project lookup or signature checks: rejection audit
1230
+ # rows interpolate this value, so a malformed identifier must not
1231
+ # reach either persistence or log text.
1232
+ validate_public_identifier(delivery_id, field="delivery_id")
1233
+ secret = None
1234
+ if webhook_secret is not None:
1235
+ secret = validate_webhook_secret(webhook_secret)
1236
+ if not isinstance(raw_body, bytes):
1237
+ raise ValueError(
1238
+ "raw_body must be the exact signed webhook bytes")
1239
+ if not raw_body or len(raw_body) > WEBHOOK_BODY_MAX_BYTES:
1240
+ raise ValueError(
1241
+ f"raw_body must contain 1-{WEBHOOK_BODY_MAX_BYTES} bytes")
1242
+ if not isinstance(signature_header, str):
1243
+ raise ValueError("signature_header must be a string")
1244
+ try:
1245
+ project = self._require_project(project_id)
1246
+ except PermissionError:
1247
+ self.store.audit(
1248
+ actor="connector", action="webhook.rejected",
1249
+ object_id=project_id,
1250
+ detail=f"unknown project for delivery {delivery_id}")
1251
+ raise GitHubDeliveryError(
1252
+ f"GitHub delivery targets unknown project {project_id}") from None
1253
+ if secret is not None:
1254
+ if not verify_signature(secret, raw_body, signature_header):
1255
+ self.store.audit(actor="connector", action="webhook.rejected",
1256
+ detail=f"bad signature for {delivery_id}")
1257
+ raise GitHubDeliveryError("webhook signature verification failed")
1258
+ try:
1259
+ signed_payload = strict_json_loads(raw_body)
1260
+ supplied_payload = strict_json_loads(canonical_json(payload))
1261
+ if not isinstance(signed_payload, dict):
1262
+ raise ValueError("signed webhook body must be a JSON object")
1263
+ if canonical_json(signed_payload) != canonical_json(supplied_payload):
1264
+ raise ValueError(
1265
+ "payload does not match the signed webhook body")
1266
+ except (TypeError, ValueError, OverflowError, RecursionError) as exc:
1267
+ self.store.audit(
1268
+ actor="connector", action="webhook.rejected",
1269
+ object_id=project_id,
1270
+ detail=f"invalid signed body for delivery {delivery_id}")
1271
+ raise WebhookPayloadError(str(exc)) from None
1272
+ payload = signed_payload
1273
+ envelope = normalize(event_name, delivery_id, payload)
1274
+ configured_id = project["data"].get("repository_id")
1275
+ delivered_id = envelope.get("repository_id")
1276
+ if (isinstance(configured_id, bool)
1277
+ or not isinstance(configured_id, int)
1278
+ or configured_id <= 0):
1279
+ self.store.audit(
1280
+ actor="connector", action="webhook.rejected",
1281
+ object_id=project_id,
1282
+ detail=(f"unbound immutable repository id for delivery "
1283
+ f"{delivery_id}"))
1284
+ raise GitHubDeliveryError(
1285
+ "GitHub ingestion is disabled until the project is bound to "
1286
+ "a positive numeric repository_id")
1287
+ if event_name == "installation":
1288
+ configured_installation = project["data"].get(
1289
+ "github_installation_id")
1290
+ if (isinstance(configured_installation, bool)
1291
+ or not isinstance(configured_installation, int)
1292
+ or configured_installation <= 0):
1293
+ raise GitHubDeliveryError(
1294
+ "installation events require a project-bound positive "
1295
+ "github_installation_id")
1296
+ elif event_name == "installation_repositories":
1297
+ delivered_ids = envelope.get("repository_ids", [])
1298
+ if configured_id not in delivered_ids:
1299
+ raise GitHubDeliveryError(
1300
+ "installation_repositories delivery does not include the "
1301
+ "project repository_id")
1302
+ envelope["repository_id"] = configured_id
1303
+ elif (isinstance(delivered_id, bool)
1304
+ or not isinstance(delivered_id, int)
1305
+ or delivered_id <= 0
1306
+ or delivered_id != configured_id):
1307
+ self.store.audit(
1308
+ actor="connector", action="webhook.rejected",
1309
+ object_id=project_id,
1310
+ detail=(f"repository id mismatch for {delivery_id}: expected "
1311
+ f"{configured_id!r}, received {delivered_id!r}"))
1312
+ raise GitHubDeliveryError(
1313
+ "GitHub delivery repository_id does not match the project")
1314
+ configured_installation = project["data"].get(
1315
+ "github_installation_id")
1316
+ delivered_installation = envelope.get("installation_id")
1317
+ if configured_installation is not None and (
1318
+ isinstance(configured_installation, bool)
1319
+ or not isinstance(configured_installation, int)
1320
+ or configured_installation <= 0
1321
+ or isinstance(delivered_installation, bool)
1322
+ or not isinstance(delivered_installation, int)
1323
+ or delivered_installation != configured_installation):
1324
+ self.store.audit(
1325
+ actor="connector", action="webhook.rejected",
1326
+ object_id=project_id,
1327
+ detail=(f"installation id mismatch for {delivery_id}: expected "
1328
+ f"{configured_installation!r}, received "
1329
+ f"{delivered_installation!r}"))
1330
+ raise GitHubDeliveryError(
1331
+ "GitHub delivery installation_id does not match the project")
1332
+ return self._ingest(project_id, envelope)
1333
+
1334
+ @serialized_access
1335
+ def ingest_agent_trace(self, project_id: str, *, session_id: str | None,
1336
+ span_id: str, payload: dict,
1337
+ observed_at: str | None = None) -> dict | None:
1338
+ validate_public_identifier(project_id, field="project_id")
1339
+ span_id = validate_public_identifier(span_id, field="span_id")
1340
+ if not isinstance(payload, dict):
1341
+ raise ValueError("trace payload must be a JSON object")
1342
+ try:
1343
+ payload = strict_json_loads(canonical_json(payload))
1344
+ except (TypeError, ValueError, OverflowError, RecursionError) as exc:
1345
+ raise ValueError(
1346
+ f"trace payload must be finite canonical JSON: {exc}") from None
1347
+ message = payload.get("message")
1348
+ if message is not None and not isinstance(message, str):
1349
+ raise ValueError("trace payload message must be a string or null")
1350
+ if isinstance(message, str) and len(message) > 65_536:
1351
+ raise ValueError(
1352
+ "trace payload message must be at most 65536 characters")
1353
+ if observed_at is not None and (
1354
+ not isinstance(observed_at, str)
1355
+ or not is_rfc3339_datetime(observed_at)):
1356
+ raise ValueError("trace observed_at must be an RFC 3339 timestamp or null")
1357
+ self._require_project(project_id)
1358
+ if session_id is not None:
1359
+ session_id = validate_public_identifier(
1360
+ session_id, field="session_id")
1361
+ try:
1362
+ self.graph.get(
1363
+ session_id, tenant_id=self.tenant_id,
1364
+ project_id=project_id, entity_type="session")
1365
+ except KeyError:
1366
+ raise ValueError(
1367
+ "session_id is not a session in the requested project"
1368
+ ) from None
1369
+ envelope = {
1370
+ "source_type": "agent_trace",
1371
+ "source_id": span_id,
1372
+ "idempotency_key": f"trace:{span_id}",
1373
+ "authority": "agent_observed",
1374
+ "observed_at": observed_at,
1375
+ "payload": payload | (
1376
+ {"session_id": session_id} if session_id is not None else {}),
1377
+ "text_blocks": [
1378
+ {"text": payload.get("message", ""), "authority": "agent_inference",
1379
+ "ref": f"trace:{span_id}"}
1380
+ ] if payload.get("message") else [],
1381
+ "flags": {},
1382
+ }
1383
+ return self._ingest(project_id, envelope)
1384
+
1385
+ @serialized_access
1386
+ def ingest_human_decision(self, project_id: str, *, actor: str, decision: str,
1387
+ scope: dict | None = None,
1388
+ request_id: str | None = None) -> dict | None:
1389
+ validate_public_identifier(project_id, field="project_id")
1390
+ if not isinstance(actor, str) or not actor.strip():
1391
+ raise ValueError("human decision actor must be a non-empty string")
1392
+ actor = validate_human_text(actor, field="human decision actor")
1393
+ if not isinstance(decision, str) or not decision.strip():
1394
+ raise ValueError("human decision must be a non-empty string")
1395
+ decision = validate_human_text(
1396
+ decision, field="human decision", max_length=4096)
1397
+ if scope is not None and not isinstance(scope, dict):
1398
+ raise ValueError("human decision scope must be an object or null")
1399
+ try:
1400
+ scope = (
1401
+ None if scope is None
1402
+ else strict_json_loads(canonical_json(scope)))
1403
+ except (TypeError, ValueError, OverflowError, RecursionError) as exc:
1404
+ raise ValueError(
1405
+ f"human decision scope must be finite canonical JSON: {exc}"
1406
+ ) from None
1407
+ request_id = (
1408
+ new_id("event") if request_id is None
1409
+ else validate_public_identifier(request_id, field="request_id")
1410
+ )
1411
+ self._require_project(project_id)
1412
+ envelope = {
1413
+ "source_type": "human_decision",
1414
+ "source_id": actor,
1415
+ "idempotency_key": f"cce:{request_id}",
1416
+ "authority": "human_decision",
1417
+ "observed_at": None,
1418
+ "payload": {"decision": decision, "actor": actor, "scope": scope},
1419
+ "text_blocks": [{"text": decision, "authority": "human_decision",
1420
+ "ref": f"decision:{request_id}"}],
1421
+ "flags": {},
1422
+ }
1423
+ return self._ingest(project_id, envelope)
1424
+
1425
+ def _ingest(self, project_id: str, envelope: dict) -> dict | None:
1426
+ try:
1427
+ project = self.graph.get(
1428
+ project_id, tenant_id=self.tenant_id, project_id=project_id)
1429
+ except KeyError:
1430
+ self.store.audit(
1431
+ actor="connector", action="ingest.rejected",
1432
+ object_id=project_id, detail="unknown project")
1433
+ raise PermissionError(
1434
+ f"ingestion targets unknown project {project_id}") from None
1435
+ if project.get("entity_type") != "project":
1436
+ raise PermissionError(f"{project_id} is not a project")
1437
+ mode = self.project_capture_mode(project_id)
1438
+ raw = envelope.get("payload")
1439
+ if raw is not None and not isinstance(raw, dict):
1440
+ raise ValueError("ingestion payload must be a JSON object or null")
1441
+ raw_canonical = canonical_json(raw) if raw is not None else ""
1442
+ payload, capture_report = apply_capture_mode(raw, mode)
1443
+ # Idempotency compares the digest of what the SOURCE sent, not of what
1444
+ # survived redaction. Under metadata_only two genuinely different
1445
+ # bodies reduce to the same stored form, so digesting the redacted
1446
+ # payload would silently accept a changed redelivery as a duplicate
1447
+ # instead of flagging it (CCG-001).
1448
+ raw_digest = sha256_hex(raw_canonical)
1449
+ try:
1450
+ event = self.store.append_event(
1451
+ tenant_id=self.tenant_id,
1452
+ project_id=project_id,
1453
+ source_type=envelope["source_type"],
1454
+ source_id=envelope.get("source_id"),
1455
+ idempotency_key=envelope["idempotency_key"],
1456
+ payload=payload,
1457
+ payload_digest=raw_digest,
1458
+ authority=envelope.get("authority", "untrusted_content"),
1459
+ observed_at=envelope.get("observed_at"),
1460
+ capture_mode=mode,
1461
+ sensitivity="internal",
1462
+ )
1463
+ except DuplicateEventError:
1464
+ return None
1465
+ try:
1466
+ # Process the STORED event, not the incoming envelope: extraction
1467
+ # must see exactly what durably persisted. Handing it the raw
1468
+ # envelope would write unredacted text into graph nodes (defeating
1469
+ # the capture mode) and make replay diverge from live processing.
1470
+ # This is the same path rebuild_projection takes (CCG-006).
1471
+ # The event log is canonical and intentionally commits first.
1472
+ # Its rebuildable projection is one atomic unit: a processor
1473
+ # failure may quarantine the event, but must not leave half of
1474
+ # its nodes, edges, invalidations, or audits visible.
1475
+ with self.store.transaction():
1476
+ report = self.process_event(event)
1477
+ # The success marker is part of the projection commit. If it
1478
+ # cannot be written, every node/edge/audit mutation rolls
1479
+ # back and the outer failure path records quarantine alone.
1480
+ self.store.mark_processed(
1481
+ event["event_id"], PROCESSOR_VERSION, "ok")
1482
+ except Exception as exc: # quarantine and preserve replayability (ADR-036)
1483
+ self.store.mark_processed(event["event_id"], PROCESSOR_VERSION,
1484
+ "quarantined", repr(exc))
1485
+ raise
1486
+ report["capture"] = capture_report
1487
+ return report
1488
+
1489
+ # ----------------------------------------------------------------- process
1490
+
1491
+ @serialized_access
1492
+ def process_event(self, event: dict, envelope: dict | None = None) -> dict:
1493
+ """Process one stored event through the canonical projection path."""
1494
+ # Public direct callers get the same all-or-nothing projection
1495
+ # guarantee as ingest(). When ingest already owns a transaction this
1496
+ # becomes a nested savepoint, so both entry paths remain atomic.
1497
+ with self.store.transaction():
1498
+ return self._process_event(event, envelope)
1499
+
1500
+ def _process_event(self, event: dict, envelope: dict | None = None) -> dict:
1501
+ """Project one canonical stored event inside an owned transaction."""
1502
+ if not isinstance(event, dict):
1503
+ raise TypeError("processed event must be a canonical stored event object")
1504
+ supplied_event = event
1505
+ event_id = event.get("event_id")
1506
+ project_id = event.get("project_id")
1507
+ if not isinstance(event_id, str) or not event_id:
1508
+ raise ValueError("processed event must name a non-empty event_id")
1509
+ if event.get("tenant_id") != self.tenant_id:
1510
+ raise PermissionError("processed event is outside this Engine tenant")
1511
+ if not isinstance(project_id, str) or not project_id:
1512
+ raise ValueError("processed event must name a non-empty project_id")
1513
+ self._require_project(project_id)
1514
+ try:
1515
+ event = self.store.get_event(
1516
+ event_id, tenant_id=self.tenant_id, project_id=project_id)
1517
+ except (KeyError, TypeError):
1518
+ raise PermissionError(
1519
+ "processed event must resolve in this Engine tenant/project") from None
1520
+ if set(supplied_event) != set(event):
1521
+ raise ValueError(
1522
+ "processed event fields differ from the canonical stored event")
1523
+ try:
1524
+ supplied_semantics = canonical_json(supplied_event)
1525
+ stored_semantics = canonical_json(event)
1526
+ except (TypeError, ValueError, OverflowError, RecursionError):
1527
+ raise ValueError("processed event is not canonical JSON") from None
1528
+ if supplied_semantics != stored_semantics:
1529
+ raise ValueError(
1530
+ "processed event differs from the canonical stored event")
1531
+ canonical_envelope = self._re_envelope(event)
1532
+ if envelope is not None:
1533
+ try:
1534
+ supplied = canonical_json(envelope)
1535
+ expected = canonical_json(canonical_envelope)
1536
+ except (TypeError, ValueError, OverflowError, RecursionError):
1537
+ raise ValueError("supplied processing envelope is not canonical JSON") from None
1538
+ if supplied != expected:
1539
+ raise ValueError(
1540
+ "supplied processing envelope differs from the stored event")
1541
+ envelope = canonical_envelope
1542
+ report = {"event_id": event_id, "created": [], "invalidations": [],
1543
+ "conflicts": [], "verifications": [], "commands": []}
1544
+
1545
+ # Events are first-class causal endpoints. Besides making provenance
1546
+ # traversable, this prevents event-to-node support edges from being
1547
+ # dangling references.
1548
+ self.graph.put_node(
1549
+ entity_type="event", tenant_id=event["tenant_id"],
1550
+ project_id=event["project_id"], node_id=event_id,
1551
+ status="recorded", authority=event["authority"],
1552
+ data={
1553
+ "source_type": event["source_type"],
1554
+ "source_id": event.get("source_id"),
1555
+ "idempotency_key": event["idempotency_key"],
1556
+ "payload_digest": event["payload_digest"],
1557
+ "stored_payload_digest": event["stored_payload_digest"],
1558
+ "observed_at": event["observed_at"],
1559
+ },
1560
+ valid_from=event.get("valid_from"),
1561
+ valid_to=event.get("valid_to"),
1562
+ event_id=event_id,
1563
+ )
1564
+
1565
+ flags = envelope.get("flags", {})
1566
+ source_type = envelope.get("source_type", event["source_type"])
1567
+
1568
+ # Verifier-authoritative events become verification nodes (EV-003).
1569
+ if source_type in ("github:check_run", "github:workflow_run",
1570
+ "github:check_suite"):
1571
+ self._process_check(event, flags, report)
1572
+
1573
+ # Push events: dependency/critical-path drift (CI-001, GHI-008).
1574
+ if source_type == "github:push":
1575
+ self._process_push(event, envelope, report)
1576
+
1577
+ # /cce commands (GHI-005).
1578
+ if flags.get("command"):
1579
+ self._process_command(event, envelope, report)
1580
+
1581
+ # Text extraction (AD-001..AD-008).
1582
+ for block in envelope.get("text_blocks", []):
1583
+ self._process_text(event, block, report)
1584
+
1585
+ return report
1586
+
1587
+ def _re_envelope(self, event: dict) -> dict:
1588
+ """Rebuild the normalization envelope from a stored event (replay)."""
1589
+ source_type = event["source_type"]
1590
+ payload = event["payload"] or {}
1591
+ if source_type.startswith("github:"):
1592
+ name = source_type.split(":", 1)[1]
1593
+ try:
1594
+ return normalize(name, event["idempotency_key"].split(":", 1)[1],
1595
+ payload)
1596
+ except Exception:
1597
+ return {"source_type": source_type, "text_blocks": [], "flags": {}}
1598
+ if source_type == "human_decision":
1599
+ return {
1600
+ "source_type": source_type, "flags": {},
1601
+ "text_blocks": [{"text": payload.get("decision", ""),
1602
+ "authority": "human_decision",
1603
+ "ref": f"decision:{event['event_id']}"}],
1604
+ }
1605
+ if source_type == "agent_trace":
1606
+ return {
1607
+ "source_type": source_type, "flags": {},
1608
+ "text_blocks": [{"text": payload.get("message", ""),
1609
+ "authority": "agent_inference",
1610
+ "ref": f"trace:{event['source_id']}"}]
1611
+ if payload.get("message") else [],
1612
+ }
1613
+ return {"source_type": source_type, "text_blocks": [], "flags": {}}
1614
+
1615
+ # ------------------------------------------------------- text processing
1616
+
1617
+ def _process_text(self, event: dict, block: dict, report: dict):
1618
+ project_id = event["project_id"]
1619
+ event_id = event["event_id"]
1620
+ text = block.get("text") or ""
1621
+ authority = block.get("authority", "untrusted_content")
1622
+ ref = block.get("ref", "")
1623
+ result = self.extractor.extract(text, source_authority=authority,
1624
+ scope={"source_ref": ref})
1625
+ # Identical statements from different sources converge on one node
1626
+ # (AD-004), so a node tracks EVERY source that states it. An edit to
1627
+ # one source may only retract that source's claim on the node.
1628
+ prior_from_ref = {
1629
+ n["node_id"]: n
1630
+ for kind in ("requirement", "constraint")
1631
+ for n in self.graph.current(
1632
+ project_id, kind, tenant_id=self.tenant_id)
1633
+ if (ref in _source_refs(n)
1634
+ and n["status"] not in ("superseded", "invalidated"))
1635
+ }
1636
+ seen_ids: set[str] = set()
1637
+
1638
+ for item in result.items:
1639
+ if item.suspected_injection:
1640
+ node = self.graph.put_node(
1641
+ entity_type="claim", tenant_id=self.tenant_id,
1642
+ project_id=project_id, status="quarantined",
1643
+ criticality="high", authority="untrusted_content",
1644
+ confidence=item.confidence,
1645
+ data={"statement": item.statement, "span": item.span,
1646
+ "source_ref": ref, "suspected_injection": True},
1647
+ event_id=event_id,
1648
+ extractor=result.extractor,
1649
+ extractor_version=result.extractor_version,
1650
+ )
1651
+ report["created"].append({"node_id": node.id, "kind": "claim",
1652
+ "quarantined": True})
1653
+ self.store.audit(actor="extractor", action="injection.quarantined",
1654
+ object_id=node.id, detail=item.statement[:200])
1655
+ continue
1656
+
1657
+ kind = item.kind
1658
+ node_id = stable_node_id(project_id, kind, item.statement)
1659
+ seen_ids.add(node_id)
1660
+ existing = None
1661
+ try:
1662
+ existing = self.graph.get(
1663
+ node_id, tenant_id=self.tenant_id,
1664
+ project_id=project_id)
1665
+ except KeyError:
1666
+ pass
1667
+ entity_type = kind if kind in ("assumption", "requirement", "constraint",
1668
+ "decision", "claim", "task") else "claim"
1669
+ status = self._initial_status(entity_type, item, existing)
1670
+ # A re-delivery that asserts nothing new is a no-op. Re-writing an
1671
+ # unchanged node would re-stamp its transaction time, inflate its
1672
+ # version, and re-enter it into conflict ranking, so an idempotent
1673
+ # webhook retry could change project state (CCG-001).
1674
+ if existing is not None and _restates(existing, item, ref, authority):
1675
+ continue
1676
+ if existing is not None and authority_rank(authority) < authority_rank(
1677
+ existing.get("authority") or "untrusted_content"):
1678
+ # A weaker source cannot downgrade or overwrite; record occurrence.
1679
+ self.graph.put_edge(
1680
+ edge_type="supports", src_id=event_id, dst_id=node_id,
1681
+ tenant_id=self.tenant_id, project_id=project_id,
1682
+ strength=0.3, event_id=event_id)
1683
+ continue
1684
+ node = self.graph.put_node(
1685
+ entity_type=entity_type, tenant_id=self.tenant_id,
1686
+ project_id=project_id, node_id=node_id, status=status,
1687
+ criticality=item.criticality, authority=authority,
1688
+ confidence=item.confidence,
1689
+ scope=item.scope,
1690
+ data={"statement": item.statement, "span": item.span,
1691
+ "source_ref": ref, "stable_key": node_id,
1692
+ "source_refs": sorted(_source_refs(existing) | {ref}),
1693
+ **item.meta},
1694
+ event_id=event_id,
1695
+ extractor=result.extractor,
1696
+ extractor_version=result.extractor_version,
1697
+ )
1698
+ report["created"].append({"node_id": node.id, "kind": entity_type,
1699
+ "status": status, "new": existing is None})
1700
+ self._detect_near_duplicate_conflict(node, report, event_id)
1701
+
1702
+ # Changed requirement detection (CI-001): a requirement this source
1703
+ # used to state and no longer does was edited away. It is invalidated
1704
+ # only when NO other source still states it — otherwise the edit just
1705
+ # drops this source's claim and the requirement stands on the others.
1706
+ for old_id, old in prior_from_ref.items():
1707
+ if old_id in seen_ids:
1708
+ continue
1709
+ remaining = _source_refs(old) - {ref}
1710
+ self.graph.put_node(
1711
+ entity_type=old["entity_type"], tenant_id=self.tenant_id,
1712
+ project_id=project_id, node_id=old_id,
1713
+ data={"source_refs": sorted(remaining)}, event_id=event_id,
1714
+ )
1715
+ if remaining:
1716
+ report["conflicts"].append({
1717
+ "a": old_id, "b": None, "winner": old_id,
1718
+ "explanation": f"{ref} no longer states this requirement, but"
1719
+ f" {sorted(remaining)} still do; not invalidated",
1720
+ })
1721
+ continue
1722
+ inv = self.invalidation.fire(
1723
+ tenant_id=self.tenant_id, project_id=project_id,
1724
+ target_node_id=old_id, trigger_type="changed_requirement",
1725
+ trigger_confidence=0.9,
1726
+ reason=f"source {ref} was edited and no longer states this"
1727
+ f" requirement; no other source states it",
1728
+ event_id=event_id,
1729
+ )
1730
+ report["invalidations"].append(inv["node_id"])
1731
+
1732
+ @staticmethod
1733
+ def _initial_status(entity_type: str, item, existing) -> str | None:
1734
+ if existing is not None:
1735
+ return existing["status"]
1736
+ if entity_type == "assumption":
1737
+ return "proposed" if item.confidence < 0.6 else "active"
1738
+ if entity_type in ("requirement", "constraint"):
1739
+ return "active"
1740
+ if entity_type == "decision":
1741
+ return "accepted"
1742
+ if entity_type == "task":
1743
+ return "done" if item.meta.get("done") else "open"
1744
+ return "recorded"
1745
+
1746
+ def _detect_near_duplicate_conflict(self, node, report: dict, event_id: str):
1747
+ """Two near-identical statements with a small token difference are a
1748
+ potential contradiction (stale doc vs newer decision). Resolve by
1749
+ authority + freshness (CCG-005); always preserve and expose the conflict.
1750
+
1751
+ A node that has already lost a conflict takes no further part in one.
1752
+ Re-ingesting an unchanged delivery re-stamps every extracted node's
1753
+ transaction time, so without this guard the freshness tie-break would
1754
+ hand the win back to the superseded loser and retire the survivor —
1755
+ a no-op redelivery would silently empty the active requirement set.
1756
+ """
1757
+ if node["entity_type"] not in ("claim", "decision", "requirement"):
1758
+ return
1759
+ if node.get("status") in ("superseded", "invalidated", "quarantined"):
1760
+ return
1761
+ project_id = node["project_id"]
1762
+ my_tokens = set(normalize_statement(node["data"]["statement"]).split())
1763
+ if len(my_tokens) < 3:
1764
+ return
1765
+ for other in self.graph.current(
1766
+ project_id, tenant_id=self.tenant_id):
1767
+ if other["node_id"] == node["node_id"]:
1768
+ continue
1769
+ if other["entity_type"] not in ("claim", "decision", "requirement"):
1770
+ continue
1771
+ if other["status"] in ("superseded", "invalidated", "quarantined"):
1772
+ continue
1773
+ other_tokens = set(
1774
+ normalize_statement(other["data"].get("statement") or "").split())
1775
+ if not other_tokens or other_tokens == my_tokens:
1776
+ continue
1777
+ sym_diff = my_tokens ^ other_tokens
1778
+ if 0 < len(sym_diff) <= max(2, len(my_tokens) // 4) and \
1779
+ len(my_tokens & other_tokens) >= 2:
1780
+ winner, loser, explanation = self._rank_conflict(node, other)
1781
+ # A loser that another live source still states is a genuine
1782
+ # open disagreement, not a supersession: one issue being
1783
+ # edited does not repeal what a different issue still says.
1784
+ # Preserve it for human resolution (CCG-005/ADR-017) instead of picking
1785
+ # a winner silently (ADR-008).
1786
+ contested = bool(
1787
+ loser is not None
1788
+ and _source_refs(loser) - _source_refs(node)
1789
+ and authority_rank(loser.get("authority") or "")
1790
+ == authority_rank(winner.get("authority") or ""))
1791
+ if contested:
1792
+ explanation += ("; both statements are still asserted by"
1793
+ " different sources — human resolution required")
1794
+ self.graph.put_edge(
1795
+ edge_type="contradicts", src_id=node["node_id"],
1796
+ dst_id=other["node_id"], tenant_id=self.tenant_id,
1797
+ project_id=project_id, event_id=event_id,
1798
+ data={"explanation": explanation, "contested": contested})
1799
+ if loser is not None:
1800
+ self.graph.put_node(
1801
+ entity_type=loser["entity_type"], tenant_id=self.tenant_id,
1802
+ project_id=project_id, node_id=loser["node_id"],
1803
+ data={"conflict_requires_resolution": True} if contested
1804
+ else {},
1805
+ status="uncertain" if (
1806
+ contested or loser["entity_type"] == "assumption")
1807
+ else "superseded",
1808
+ event_id=event_id)
1809
+ if not contested:
1810
+ self.graph.put_edge(
1811
+ edge_type="supersedes", src_id=winner["node_id"],
1812
+ dst_id=loser["node_id"], tenant_id=self.tenant_id,
1813
+ project_id=project_id, event_id=event_id)
1814
+ report["conflicts"].append({
1815
+ "a": node["node_id"], "b": other["node_id"],
1816
+ "winner": None if contested else (
1817
+ winner["node_id"] if winner else None),
1818
+ "requires_resolution": contested,
1819
+ "explanation": explanation,
1820
+ })
1821
+
1822
+ @staticmethod
1823
+ def _rank_conflict(a: dict, b: dict):
1824
+ """CCG-005 conflict ranking: authority, then transaction
1825
+ freshness, then confidence. Unresolvable -> both preserved."""
1826
+ ra, rb = authority_rank(a.get("authority") or ""), authority_rank(
1827
+ b.get("authority") or "")
1828
+ if ra != rb:
1829
+ winner, loser = (a, b) if ra > rb else (b, a)
1830
+ return winner, loser, (
1831
+ f"authority {winner.get('authority')} outranks"
1832
+ f" {loser.get('authority')}")
1833
+ if a["tx_from"] != b["tx_from"]:
1834
+ winner, loser = (a, b) if a["tx_from"] > b["tx_from"] else (b, a)
1835
+ return winner, loser, "same authority; newer statement wins"
1836
+ ca, cb = a.get("confidence") or 0, b.get("confidence") or 0
1837
+ if ca != cb:
1838
+ winner, loser = (a, b) if ca > cb else (b, a)
1839
+ return winner, loser, "higher extraction confidence wins"
1840
+ return None, None, "unresolved conflict: equal authority, time, confidence"
1841
+
1842
+ # ------------------------------------------------------- checks & pushes
1843
+
1844
+ def _process_check(self, event: dict, flags: dict, report: dict):
1845
+ project_id = event["project_id"]
1846
+ name = flags.get("name") or event["source_type"]
1847
+ conclusion = flags.get("conclusion")
1848
+ head_sha = flags.get("head_sha")
1849
+ app = flags.get("app")
1850
+ trusted = self.policy.external_verifier_trusted(
1851
+ project_id, event["source_type"], flags)
1852
+ if flags.get("status") != "completed" and conclusion is None:
1853
+ return
1854
+ result = {"success": "passed", "failure": "failed", "neutral": "skipped",
1855
+ "cancelled": "inconclusive", "timed_out": "inconclusive",
1856
+ "skipped": "skipped", "action_required": "failed",
1857
+ "stale": "stale"}.get(conclusion, "inconclusive")
1858
+ node = self.graph.put_node(
1859
+ entity_type="verification", tenant_id=self.tenant_id,
1860
+ project_id=project_id, status=result,
1861
+ authority=(
1862
+ "verifier_authoritative" if trusted
1863
+ else "repository_authoritative"),
1864
+ data={"verifier": name, "source": event["source_type"],
1865
+ "conclusion": conclusion, "head_sha": head_sha,
1866
+ "source_id": event["source_id"], "app": app,
1867
+ "app_id": flags.get("app_id"),
1868
+ "installation_id": flags.get("installation_id"),
1869
+ "workflow_id": flags.get("workflow_id"),
1870
+ "workflow_path": flags.get("workflow_path"),
1871
+ "trusted_app": trusted,
1872
+ "trusted_producer_at_ingest": trusted},
1873
+ event_id=event["event_id"],
1874
+ )
1875
+ report["verifications"].append(node.id)
1876
+ if result == "failed" and trusted:
1877
+ # Failed check invalidates prior passing verification of the same
1878
+ # verifier and anything that relied on it (CI-001 failed_check).
1879
+ for prior in self.graph.current(
1880
+ project_id, "verification", status=["passed"],
1881
+ tenant_id=self.tenant_id):
1882
+ prior_source = prior["data"].get("source") or ""
1883
+ if (prior.get("authority") != "verifier_authoritative"
1884
+ or not prior_source.startswith("github:")):
1885
+ continue
1886
+ if prior["data"].get("verifier") == name and \
1887
+ prior["node_id"] != node.id:
1888
+ inv = self.invalidation.fire(
1889
+ tenant_id=self.tenant_id, project_id=project_id,
1890
+ target_node_id=prior["node_id"],
1891
+ trigger_type="failed_check",
1892
+ trigger_confidence=0.95,
1893
+ reason=f"check {name!r} now fails at {head_sha}",
1894
+ event_id=event["event_id"],
1895
+ )
1896
+ report["invalidations"].append(inv["node_id"])
1897
+
1898
+ _MANIFESTS = re.compile(
1899
+ r"(^|/)(requirements[^/]*\.txt|pyproject\.toml|package(-lock)?\.json|"
1900
+ r"poetry\.lock|Pipfile(\.lock)?|go\.(mod|sum)|Cargo\.(toml|lock)|"
1901
+ r"Gemfile(\.lock)?|pom\.xml|build\.gradle[^/]*)$")
1902
+
1903
+ def _process_push(self, event: dict, envelope: dict, report: dict):
1904
+ project_id = event["project_id"]
1905
+ payload = event["payload"] or {}
1906
+ flags = envelope.get("flags", {})
1907
+ after = flags.get("after")
1908
+ before = flags.get("before")
1909
+ ref = flags.get("ref")
1910
+ project = self.graph.get(
1911
+ project_id, tenant_id=self.tenant_id,
1912
+ project_id=project_id, entity_type="project")
1913
+ tracked_basis = self.policy.tracked_ref_basis(project_id)
1914
+ tracked_ref = tracked_basis["tracked_ref"]
1915
+ is_tracked = bool(ref and ref == tracked_ref)
1916
+ frontier_matches_policy = (
1917
+ project["data"].get("tracked_ref") == tracked_ref
1918
+ and project["data"].get("tracked_ref_revision")
1919
+ == tracked_basis["revision"]
1920
+ )
1921
+ current_head = project["data"].get("current_head_sha") \
1922
+ if frontier_matches_policy else None
1923
+ out_of_order = bool(
1924
+ is_tracked and current_head and before != current_head
1925
+ and after != current_head and not flags.get("forced"))
1926
+ tracked_push = is_tracked and not out_of_order
1927
+
1928
+ if ref and not is_tracked:
1929
+ self.store.audit(
1930
+ actor="connector", action="push.untracked_ref",
1931
+ object_id=ref,
1932
+ detail=f"tracked ref is {tracked_ref}; revision frontier unchanged")
1933
+ elif out_of_order:
1934
+ self.graph.put_node(
1935
+ entity_type="project", tenant_id=self.tenant_id,
1936
+ project_id=project_id, node_id=project_id,
1937
+ status=project.get("status"),
1938
+ data={
1939
+ "revision_frontier_uncertain": True,
1940
+ "rejected_head_sha": after,
1941
+ "rejected_before_sha": before,
1942
+ },
1943
+ event_id=event["event_id"],
1944
+ )
1945
+ self.store.audit(
1946
+ actor="connector", action="push.out_of_order",
1947
+ object_id=ref,
1948
+ detail=(
1949
+ f"current {current_head}; delivery claimed "
1950
+ f"{before} -> {after}; frontier left unchanged"))
1951
+ elif is_tracked and after:
1952
+ deleted = bool(flags.get("deleted"))
1953
+ effective_head = None if deleted else after
1954
+ self.graph.put_node(
1955
+ entity_type="project", tenant_id=self.tenant_id,
1956
+ project_id=project_id, node_id=project_id,
1957
+ status=project.get("status"),
1958
+ data={
1959
+ "current_head_sha": effective_head,
1960
+ "tracked_ref": tracked_ref,
1961
+ "tracked_ref_revision": tracked_basis["revision"],
1962
+ "previous_head_sha": before,
1963
+ "revision_frontier_uncertain": deleted,
1964
+ },
1965
+ event_id=event["event_id"],
1966
+ )
1967
+ # A passing external result describes exactly one revision.
1968
+ # Moving the tracked ref stales every result for another SHA even
1969
+ # when GitHub sends an empty commit list.
1970
+ for prior in self.graph.current(
1971
+ project_id, "verification", status=["passed"],
1972
+ tenant_id=self.tenant_id):
1973
+ source = prior["data"].get("source") or ""
1974
+ if (source.startswith("github:")
1975
+ and prior["data"].get("head_sha") != effective_head):
1976
+ self.graph.put_node(
1977
+ entity_type="verification",
1978
+ tenant_id=self.tenant_id, project_id=project_id,
1979
+ node_id=prior["node_id"], status="stale",
1980
+ data={
1981
+ "stale_because": {
1982
+ "recorded_head": prior["data"].get("head_sha"),
1983
+ "current_head": effective_head,
1984
+ },
1985
+ },
1986
+ event_id=event["event_id"],
1987
+ )
1988
+ changed: set[str] = set()
1989
+ for commit in payload.get("commits", []) or []:
1990
+ for key in ("added", "modified", "removed"):
1991
+ changed.update(commit.get(key, []) or [])
1992
+ if flags.get("forced"):
1993
+ self.store.audit(actor="connector", action="push.forced",
1994
+ object_id=flags.get("ref"),
1995
+ detail=f"{flags.get('before')} -> {flags.get('after')}")
1996
+ manifest_changed = sorted(
1997
+ p for p in changed
1998
+ if tracked_push and self._MANIFESTS.search(p))
1999
+ if manifest_changed:
2000
+ # Tokens the push is "about": changed-path stems + commit messages.
2001
+ push_terms: set[str] = set()
2002
+ for p in changed:
2003
+ stem = p.split("/")[-1].rsplit(".", 1)[0]
2004
+ push_terms.update(t for t in re.split(r"[_\-.]", stem.lower())
2005
+ if len(t) > 3)
2006
+ for commit in payload.get("commits", []) or []:
2007
+ push_terms.update(
2008
+ t for t in re.findall(r"[a-z]{4,}", (commit.get("message")
2009
+ or "").lower()))
2010
+ push_terms -= {"bump", "update", "upgrade", "chore", "merge"}
2011
+ for node in self.graph.current(
2012
+ project_id, "assumption", status=["active", "supported"],
2013
+ tenant_id=self.tenant_id):
2014
+ scope_paths = (node.get("scope") or {}).get("paths", [])
2015
+ statement = (node["data"].get("statement") or "").lower()
2016
+ statement_terms = set(re.findall(r"[a-z]{4,}", statement))
2017
+ mentions_dep = any(
2018
+ tok in statement for tok in
2019
+ ("dependency", "version", "pinned", "requires", "library",
2020
+ "package")) or bool(statement_terms & push_terms)
2021
+ if mentions_dep or self._scope_hit(scope_paths, manifest_changed):
2022
+ inv = self.invalidation.fire(
2023
+ tenant_id=self.tenant_id, project_id=project_id,
2024
+ target_node_id=node["node_id"],
2025
+ trigger_type="dependency_drift",
2026
+ trigger_confidence=0.8,
2027
+ reason=f"dependency manifest changed: {manifest_changed}",
2028
+ event_id=event["event_id"],
2029
+ )
2030
+ report["invalidations"].append(inv["node_id"])
2031
+ # Critical-path changes make prior passing verifications stale (EV-005).
2032
+ if changed and tracked_push:
2033
+ for prior in self.graph.current(
2034
+ project_id, "verification", status=["passed"],
2035
+ tenant_id=self.tenant_id):
2036
+ subject_paths = (prior.get("scope") or {}).get("paths", [])
2037
+ if self._scope_hit(subject_paths, changed):
2038
+ self.graph.put_node(
2039
+ entity_type="verification", tenant_id=self.tenant_id,
2040
+ project_id=project_id, node_id=prior["node_id"],
2041
+ data={"stale_because": sorted(changed)[:10]},
2042
+ status="stale", event_id=event["event_id"])
2043
+
2044
+ @staticmethod
2045
+ def _scope_hit(scope_paths: list[str], changed) -> bool:
2046
+ for sp in scope_paths or []:
2047
+ prefix = sp.rstrip("*").rstrip("/")
2048
+ for c in changed:
2049
+ if c == sp or (prefix and c.startswith(prefix)):
2050
+ return True
2051
+ return False
2052
+
2053
+ # ---------------------------------------------------------------- commands
2054
+
2055
+ def _process_command(self, event: dict, envelope: dict, report: dict):
2056
+ flags = envelope.get("flags", {})
2057
+ command = (flags.get("command") or "").strip()
2058
+ association = flags.get("author_association") or "NONE"
2059
+ authorized = association in _AUTHORIZED_ASSOCIATIONS
2060
+ self.store.audit(actor=f"github:{association}", action="command.received",
2061
+ object_id=event["event_id"], detail=command,
2062
+ authority="human_intent")
2063
+ if not authorized:
2064
+ report["commands"].append({"command": command, "status": "rejected",
2065
+ "reason": f"association {association}"})
2066
+ return
2067
+ report["commands"].append({"command": command, "status": "accepted"})
2068
+
2069
+ # ------------------------------------------------------------------ resume
2070
+
2071
+ @serialized_access
2072
+ def resume_packet(self, project_id: str, *, target: dict | None = None,
2073
+ token_budget: int = 4000, fmt: str = "json"):
2074
+ if fmt not in ("json", "markdown"):
2075
+ raise ValueError("resume format must be 'json' or 'markdown'")
2076
+ # Compose and commit its exact state basis under one snapshot. The
2077
+ # before-basis is deliberately persisted: if an internal callback
2078
+ # mutates state while composing, the returned packet fails closed as
2079
+ # stale instead of being blessed at a later event sequence.
2080
+ with self.store.transaction():
2081
+ self._require_project(project_id)
2082
+ state_basis = self._packet_state_basis(project_id)
2083
+ event_seq = state_basis["event_seq"]
2084
+ control_basis_digest = state_basis["control_basis_digest"]
2085
+ packet = self.composer.compose(
2086
+ tenant_id=self.tenant_id, project_id=project_id, target=target,
2087
+ token_budget=token_budget, signer=self.signer,
2088
+ state_basis=state_basis)
2089
+ self._record_watermark(
2090
+ project_id, packet, last_event_seq=event_seq,
2091
+ control_basis_digest=control_basis_digest)
2092
+ if fmt == "markdown":
2093
+ return ResumeComposer.render_markdown(packet)
2094
+ return packet
2095
+
2096
+ def _packet_state_basis(self, project_id: str) -> dict:
2097
+ """The compact commitment embedded in packets and capsules."""
2098
+ self._require_project(project_id)
2099
+ control_basis = self._packet_control_basis(project_id)
2100
+ return {
2101
+ "event_seq": self._latest_event_seq(project_id),
2102
+ "control_basis_digest": digest_obj(control_basis),
2103
+ "artifact_inputs": control_basis["artifact_inputs"],
2104
+ }
2105
+
2106
+ def _latest_event_seq(self, project_id: str) -> int:
2107
+ row = self.store._conn.execute(
2108
+ "SELECT MAX(seq) AS s FROM events WHERE tenant_id = ? "
2109
+ "AND project_id = ?",
2110
+ (self.tenant_id, project_id)).fetchone()
2111
+ return row["s"] or 0
2112
+
2113
+ def _packet_control_basis(self, project_id: str) -> dict:
2114
+ """State that can change a resume packet without appending an event.
2115
+
2116
+ This is intentionally broader than the replay fingerprint: packets
2117
+ include runtime tasks, invalidations, policy/grant state, verifier
2118
+ results, and memory-tier selection. Any semantic mutation makes the
2119
+ prior packet stale; transaction clocks and audit chatter do not.
2120
+ """
2121
+ self._require_project(project_id)
2122
+ node_fields = (
2123
+ "node_id", "version", "entity_type", "tenant_id", "project_id",
2124
+ "status", "criticality", "authority", "confidence", "scope", "data",
2125
+ "valid_from", "valid_to", "event_id", "extractor",
2126
+ "extractor_version",
2127
+ )
2128
+ edge_fields = (
2129
+ "edge_id", "version", "edge_type", "src_id", "dst_id", "tenant_id",
2130
+ "project_id", "strength", "data", "valid_from", "valid_to",
2131
+ "event_id",
2132
+ )
2133
+ nodes = [
2134
+ {key: node.get(key) for key in node_fields}
2135
+ for node in self.graph.current(
2136
+ project_id, tenant_id=self.tenant_id)
2137
+ ]
2138
+ edges = [
2139
+ {key: edge.get(key) for key in edge_fields}
2140
+ for edge in self.graph.current_edges(
2141
+ project_id, tenant_id=self.tenant_id)
2142
+ ]
2143
+ nodes.sort(key=canonical_json)
2144
+ edges.sort(key=canonical_json)
2145
+
2146
+ grants = [dict(row) for row in self.store._conn.execute(
2147
+ "SELECT * FROM autonomy_grants WHERE project_id = ? "
2148
+ "ORDER BY grant_id", (project_id,))]
2149
+ active_grant_ids = sorted(
2150
+ grant["grant_id"] for grant in self.policy.active_grants(project_id))
2151
+ downgrades = [dict(row) for row in self.store._conn.execute(
2152
+ "SELECT * FROM autonomy_downgrades WHERE project_id = ? "
2153
+ "ORDER BY seq", (project_id,))]
2154
+ assignments = [dict(row) for row in self.store._conn.execute(
2155
+ "SELECT assignment.project_id, assignment.node_id, "
2156
+ "assignment.tier, assignment.op, assignment.actor, "
2157
+ "assignment.reason FROM memory_assignments AS assignment "
2158
+ "JOIN nodes AS node ON node.node_id = assignment.node_id "
2159
+ "AND node.tx_to IS NULL AND node.tenant_id = ? "
2160
+ "AND node.project_id = assignment.project_id "
2161
+ "WHERE assignment.project_id = ? ORDER BY assignment.seq",
2162
+ (self.tenant_id, project_id))]
2163
+ return {
2164
+ "schema_version": "cce.packet-control-basis.v1",
2165
+ "tenant_id": self.tenant_id,
2166
+ "project_id": project_id,
2167
+ "event_seq": self._latest_event_seq(project_id),
2168
+ "nodes": nodes,
2169
+ "edges": edges,
2170
+ "policy": self.policy.project_config(project_id),
2171
+ "grants": grants,
2172
+ "active_grant_ids": active_grant_ids,
2173
+ "downgrades": downgrades,
2174
+ "memory_assignments": assignments,
2175
+ # File bytes are external to SQLite but are still control inputs:
2176
+ # a packet that calls a verifier current must stale when the
2177
+ # verifier's declared deliverable changes under the same graph.
2178
+ "artifact_inputs": self._artifact_digests(project_id),
2179
+ }
2180
+
2181
+ def _record_watermark(
2182
+ self, project_id: str, packet: dict, *, last_event_seq: int,
2183
+ control_basis_digest: str):
2184
+ commitment = {
2185
+ "project_id": project_id,
2186
+ "packet_id": packet.get("packet_id"),
2187
+ "packet_digest": packet.get("packet_digest"),
2188
+ "last_event_seq": last_event_seq,
2189
+ "control_basis_digest": control_basis_digest,
2190
+ }
2191
+ with self.store.transaction():
2192
+ self.store._conn.execute(
2193
+ "INSERT OR REPLACE INTO packet_watermark "
2194
+ "(project_id, last_event_seq, composed_at, packet_id, "
2195
+ "packet_digest, control_basis_digest, audit_entry_hash) "
2196
+ "VALUES (?,?,?,?,?,?,NULL)",
2197
+ (project_id, last_event_seq, utcnow(), packet.get("packet_id"),
2198
+ packet.get("packet_digest"), control_basis_digest))
2199
+ self.store.audit(
2200
+ actor="resume", action="packet.watermark",
2201
+ object_id=project_id, authority="verifier_authoritative",
2202
+ detail=canonical_json(commitment))
2203
+ audit = self.store._conn.execute(
2204
+ "SELECT entry_hash FROM audit_log WHERE action = ? AND "
2205
+ "object_id = ? ORDER BY seq DESC LIMIT 1",
2206
+ ("packet.watermark", project_id)).fetchone()
2207
+ self.store._conn.execute(
2208
+ "UPDATE packet_watermark SET audit_entry_hash = ? "
2209
+ "WHERE project_id = ?",
2210
+ (audit["entry_hash"] if audit else None, project_id))
2211
+
2212
+ @serialized_access
2213
+ def packet_is_stale(self, project_id: str) -> bool:
2214
+ """True when no packet reflects the current event watermark (CI-006).
2215
+
2216
+ Never composed a packet counts as stale: the check must not claim a
2217
+ packet is current for a commit when none exists.
2218
+ """
2219
+ self._require_project(project_id)
2220
+ row = self.store._conn.execute(
2221
+ "SELECT * FROM packet_watermark WHERE project_id = ?",
2222
+ (project_id,)).fetchone()
2223
+ if row is None or any(
2224
+ not row[field] for field in (
2225
+ "packet_id", "packet_digest", "control_basis_digest",
2226
+ "audit_entry_hash")):
2227
+ return True
2228
+ if self._latest_event_seq(project_id) != row["last_event_seq"]:
2229
+ return True
2230
+ try:
2231
+ current_control_digest = digest_obj(
2232
+ self._packet_control_basis(project_id))
2233
+ except UnsafeArtifactError:
2234
+ return True
2235
+ if current_control_digest != row["control_basis_digest"]:
2236
+ return True
2237
+ audit = self.store._conn.execute(
2238
+ "SELECT * FROM audit_log WHERE entry_hash = ? AND action = ? "
2239
+ "AND object_id = ?",
2240
+ (row["audit_entry_hash"], "packet.watermark", project_id),
2241
+ ).fetchone()
2242
+ latest_audit = self.store._conn.execute(
2243
+ "SELECT entry_hash FROM audit_log WHERE action = ? AND "
2244
+ "object_id = ? ORDER BY seq DESC LIMIT 1",
2245
+ ("packet.watermark", project_id),
2246
+ ).fetchone()
2247
+ if audit is None or not self.store.verify_chain("audit_log")["intact"]:
2248
+ return True
2249
+ if (latest_audit is None
2250
+ or latest_audit["entry_hash"] != row["audit_entry_hash"]):
2251
+ return True
2252
+ expected = {
2253
+ "project_id": project_id,
2254
+ "packet_id": row["packet_id"],
2255
+ "packet_digest": row["packet_digest"],
2256
+ "last_event_seq": row["last_event_seq"],
2257
+ "control_basis_digest": row["control_basis_digest"],
2258
+ }
2259
+ try:
2260
+ committed = strict_json_loads(audit["detail"])
2261
+ except (TypeError, ValueError):
2262
+ return True
2263
+ return committed != expected
2264
+
2265
+ # ------------------------------------------------------------------- trust
2266
+
2267
+ @serialized_access
2268
+ def attest_action(
2269
+ self,
2270
+ project_id: str,
2271
+ *,
2272
+ intent_type: str,
2273
+ intent_statement: str,
2274
+ actor: dict,
2275
+ action_type: str = "run_verifier",
2276
+ subjects: list[tuple[str, str]] | None = None,
2277
+ inputs: list[tuple[str, str]] | None = None,
2278
+ environment: dict | None = None,
2279
+ verifier_specs: list[VerifierSpec] | None = None,
2280
+ verification_outcomes: list[dict] | None = None,
2281
+ continuity: dict | None = None,
2282
+ requirement_ids: list[str] | None = None,
2283
+ ) -> dict:
2284
+ """Create + finalize a proof envelope: policy decision, verifier runs,
2285
+ truthful status (F4). Never infers success from absence."""
2286
+ if not isinstance(actor, dict):
2287
+ raise AttestationInputError(
2288
+ "invalid attestation input: actor must be a typed object")
2289
+ try:
2290
+ validate_human_text(intent_type, field="intent_type")
2291
+ except ValueError as exc:
2292
+ raise AttestationInputError(str(exc)) from None
2293
+ if not isinstance(intent_statement, str):
2294
+ raise AttestationInputError("intent_statement must be a string")
2295
+ if not isinstance(action_type, str) or not action_type:
2296
+ raise AttestationInputError("action_type must be a non-empty string")
2297
+
2298
+ continuity = {} if continuity is None else continuity
2299
+ subjects = [] if subjects is None else subjects
2300
+ inputs = [] if inputs is None else inputs
2301
+ environment = {} if environment is None else environment
2302
+ verifier_specs = [] if verifier_specs is None else verifier_specs
2303
+ verification_outcomes = (
2304
+ [] if verification_outcomes is None else verification_outcomes)
2305
+ requirement_ids = [] if requirement_ids is None else requirement_ids
2306
+
2307
+ if not isinstance(continuity, dict) or any(
2308
+ not isinstance(key, str) for key in continuity):
2309
+ raise AttestationInputError("continuity links must be a typed object")
2310
+ if not isinstance(subjects, list):
2311
+ raise AttestationInputError("subjects must be an array")
2312
+ for entry in subjects:
2313
+ if (not isinstance(entry, (list, tuple)) or len(entry) != 2
2314
+ or not isinstance(entry[0], str) or not entry[0]
2315
+ or not isinstance(entry[1], str)
2316
+ or _SHA256_DIGEST.fullmatch(entry[1]) is None):
2317
+ raise AttestationInputError(
2318
+ "subjects must contain [non-empty name, sha256 digest] pairs")
2319
+ if not isinstance(inputs, list):
2320
+ raise AttestationInputError("inputs must be an array")
2321
+ for entry in inputs:
2322
+ if (not isinstance(entry, (list, tuple))
2323
+ or len(entry) not in (2, 3)
2324
+ or not isinstance(entry[0], str) or not entry[0]
2325
+ or not isinstance(entry[1], str)
2326
+ or _SHA256_DIGEST.fullmatch(entry[1]) is None
2327
+ or (len(entry) == 3
2328
+ and (not isinstance(entry[2], str) or not entry[2]))):
2329
+ raise AttestationInputError(
2330
+ "inputs must contain [non-empty name, sha256 digest, "
2331
+ "optional non-empty kind] entries")
2332
+ if (not isinstance(environment, dict)
2333
+ or any(not isinstance(key, str) for key in environment)):
2334
+ raise AttestationInputError("environment must be a typed object")
2335
+ if (not isinstance(verifier_specs, list)
2336
+ or any(not isinstance(spec, VerifierSpec)
2337
+ for spec in verifier_specs)):
2338
+ raise AttestationInputError(
2339
+ "verifier_specs must be an array of VerifierSpec objects")
2340
+ for spec in verifier_specs:
2341
+ try:
2342
+ spec.validate()
2343
+ except (TypeError, ValueError, OverflowError, RecursionError) as exc:
2344
+ raise AttestationInputError(
2345
+ f"invalid verifier specification: {exc}") from None
2346
+ if (not isinstance(verification_outcomes, list)
2347
+ or any(not isinstance(outcome, dict)
2348
+ for outcome in verification_outcomes)):
2349
+ raise AttestationInputError(
2350
+ "verification_outcomes must be an array of objects")
2351
+ if (not isinstance(requirement_ids, list)
2352
+ or len(requirement_ids) != len(set(
2353
+ value for value in requirement_ids
2354
+ if isinstance(value, str)))):
2355
+ raise AttestationInputError(
2356
+ "requirement_ids must be an array of distinct identifiers")
2357
+ try:
2358
+ requirement_ids = [
2359
+ validate_public_identifier(value, field="requirement_id")
2360
+ for value in requirement_ids
2361
+ ]
2362
+ canonical_json({
2363
+ "actor": actor,
2364
+ "intent_statement": intent_statement,
2365
+ "environment": environment,
2366
+ "verification_outcomes": verification_outcomes,
2367
+ })
2368
+ except (TypeError, ValueError, OverflowError, RecursionError) as exc:
2369
+ raise AttestationInputError(
2370
+ f"attestation inputs must be finite canonical JSON: {exc}") from None
2371
+
2372
+ self._require_project(project_id)
2373
+ initial_control_basis_digest = digest_obj(
2374
+ self._packet_control_basis(project_id))
2375
+ continuity = dict(continuity)
2376
+ if requirement_ids:
2377
+ normalized_requirements = sorted(requirement_ids)
2378
+ linked_requirements = continuity.get("requirement_ids")
2379
+ if (linked_requirements is not None
2380
+ and (not isinstance(linked_requirements, list)
2381
+ or any(not isinstance(value, str)
2382
+ for value in linked_requirements)
2383
+ or sorted(linked_requirements)
2384
+ != normalized_requirements)):
2385
+ raise AttestationInputError(
2386
+ "action-intent requirement_ids and continuity "
2387
+ "requirement_ids must name the same targets")
2388
+ if normalized_requirements:
2389
+ continuity["requirement_ids"] = normalized_requirements
2390
+ requirement_ids = normalized_requirements
2391
+ unknown_links = sorted(set(continuity) - set(_CONTINUITY_LINK_TYPES))
2392
+ if unknown_links:
2393
+ raise AttestationInputError(
2394
+ f"unknown typed continuity relation(s): {unknown_links}")
2395
+ for relation, targets in continuity.items():
2396
+ if (not isinstance(targets, list)
2397
+ or any(not isinstance(target, str) for target in targets)):
2398
+ raise AttestationInputError(
2399
+ f"{relation} must be an array of node ids")
2400
+ if len(targets) != len(set(targets)):
2401
+ raise AttestationInputError(
2402
+ f"{relation} must not contain duplicate node ids")
2403
+ for target in targets:
2404
+ try:
2405
+ self.graph.get(
2406
+ target, tenant_id=self.tenant_id,
2407
+ project_id=project_id,
2408
+ entity_type=_CONTINUITY_LINK_TYPES[relation])
2409
+ except KeyError as exc:
2410
+ raise AttestationInputError(
2411
+ f"{relation} target {target!r} is not a same-project "
2412
+ f"{_CONTINUITY_LINK_TYPES[relation]}") from exc
2413
+
2414
+ policy_defs = self.policy.required_verifier_defs(project_id)
2415
+ policy_strength = {
2416
+ "required": [
2417
+ {
2418
+ "name": definition["name"],
2419
+ "pinned": bool(definition.get("pinned")),
2420
+ "command_digest": (
2421
+ VerifierSpec.from_policy(definition).command_digest
2422
+ if definition.get("pinned") else None),
2423
+ "definition_digest": VerifierSpec.from_policy(
2424
+ definition).definition_digest,
2425
+ }
2426
+ for definition in policy_defs
2427
+ ],
2428
+ "min_evidence_grade": self.policy.min_evidence_grade(project_id),
2429
+ }
2430
+ env = ProofEnvelope(
2431
+ tenant_id=self.tenant_id, project_id=project_id,
2432
+ intent_type=intent_type, intent_statement=intent_statement,
2433
+ actor=actor, requirement_ids=requirement_ids,
2434
+ )
2435
+ for name, digest in subjects:
2436
+ env.add_subject(name, digest)
2437
+ for entry in inputs:
2438
+ # Caller inputs are 'declared' unless the caller names a kind. The
2439
+ # engine collects artifact digests itself, below; anything it did
2440
+ # not collect it cannot check for freshness, and must not later
2441
+ # report as changed (ADR-065). A 3-tuple opts a caller input into
2442
+ # artifact tracking deliberately.
2443
+ name, digest, kind = (*entry, "declared")[:3]
2444
+ if (kind == "continuity"
2445
+ or (isinstance(name, str)
2446
+ and name.startswith("artifact:"))):
2447
+ raise AttestationInputError(
2448
+ "continuity inputs and the 'artifact:' name prefix are "
2449
+ "reserved for engine-collected commitments")
2450
+ env.add_input(name, digest, kind=kind)
2451
+ # Link identifiers alone do not bind what the verifier observed. A
2452
+ # signed digest of every typed target makes a later task,
2453
+ # requirement, assumption, or evidence revision invalidate the old
2454
+ # proof even if its identifier remains stable.
2455
+ for name, digest in self._continuity_state_inputs(
2456
+ project_id, continuity).items():
2457
+ env.add_input(name, digest, kind="continuity")
2458
+ env.set_environment(**environment)
2459
+ # Outcomes supplied by the caller are claims, never observations.
2460
+ # Shape-check them with every other caller-controlled field before a
2461
+ # verifier command or graph mutation can occur.
2462
+ outcomes = [{**outcome, "source": SELF_ASSERTED}
2463
+ for outcome in verification_outcomes]
2464
+ _preflight_proof_body(env.body, outcomes)
2465
+ claim_key = digest_obj({
2466
+ "intent_type": intent_type,
2467
+ "intent_statement": intent_statement,
2468
+ "requirements": sorted(requirement_ids),
2469
+ "subjects": subjects,
2470
+ "continuity": {
2471
+ key: sorted(value) for key, value in sorted(continuity.items())},
2472
+ })
2473
+ # Reserve an unpredictable identity before signing, but persist no
2474
+ # attesting node yet. External checks run without a database write
2475
+ # transaction; their bounded output is staged, then proof, evidence,
2476
+ # verification nodes, supersession and downgrade commit together.
2477
+ proof_node_id = new_id("action")
2478
+ env.set_continuity(**continuity, proof_node_id=proof_node_id)
2479
+ # POLICY owns every pinned verifier operand: an absolute command for
2480
+ # subprocess kinds or a closed path/digest map for file-digest. A
2481
+ # caller spec that reuses a pinned name is discarded (ADR-024).
2482
+ policy_names = {d["name"] for d in policy_defs}
2483
+ pinned_names = {d["name"] for d in policy_defs if d["pinned"]}
2484
+ specs: list[VerifierSpec] = [
2485
+ VerifierSpec.from_policy(d) for d in policy_defs if d["pinned"]]
2486
+ displaced = []
2487
+ for spec in verifier_specs:
2488
+ # Revalidate mutable dataclass instances at the execution
2489
+ # boundary; __post_init__ alone cannot protect a later mutation.
2490
+ try:
2491
+ spec.validate()
2492
+ except (TypeError, ValueError, OverflowError, RecursionError) as exc:
2493
+ raise AttestationInputError(
2494
+ f"invalid verifier specification: {exc}") from None
2495
+ if spec.name in pinned_names:
2496
+ displaced.append(spec.name)
2497
+ continue
2498
+ # An UNPINNED policy name is satisfiable by a caller-supplied
2499
+ # command — but the proof records that the claimant chose it and
2500
+ # the evidence grade caps at D. Pinning is the supported form.
2501
+ specs.append(spec)
2502
+ artifact_paths = sorted({
2503
+ artifact
2504
+ for definition in policy_defs
2505
+ for artifact in (definition.get("artifacts") or [])
2506
+ } | {
2507
+ artifact
2508
+ for spec in specs
2509
+ for artifact in normalize_artifact_paths(spec.artifacts)
2510
+ })
2511
+ visibility_error = self.verifier_runner._artifact_visibility_error(
2512
+ artifact_paths)
2513
+ if visibility_error:
2514
+ raise AttestationInputError(
2515
+ "invalid verifier artifact declaration: "
2516
+ f"{visibility_error}")
2517
+ # Record what the checks were run AGAINST, as signed inputs. Caller
2518
+ # verifier artifacts are included as well as policy artifacts, so an
2519
+ # unpinned-but-permitted check cannot observe an unsigned deliverable.
2520
+ artifact_inputs = self._artifact_digests(
2521
+ project_id, artifacts=artifact_paths)
2522
+ for name, digest in artifact_inputs.items():
2523
+ env.add_input(name, digest, kind="artifact")
2524
+ # The action's class is what the engine is ABOUT TO DO, not what the
2525
+ # caller called it. Executing a verifier is reversible execution
2526
+ # (level 2) however it is labelled; honouring the label let an agent
2527
+ # under an AUT-005 downgrade declare "observe" and keep running
2528
+ # checks, which is the downgrade doing nothing at all (ADR-048).
2529
+ effective_action = action_type
2530
+ if specs and ACTION_CLASSES.get(action_type, 0) < \
2531
+ ACTION_CLASSES["run_verifier"]:
2532
+ effective_action = "run_verifier"
2533
+ # A scoped grant must authorize every artifact the action touches.
2534
+ # Omitting action_scope used to make a scoped grant behave as global.
2535
+ operation_scopes = sorted({
2536
+ path for spec in specs for path in (spec.artifacts or [])})
2537
+ scope_decisions = [
2538
+ self.policy.decide(
2539
+ project_id=project_id, action_type=effective_action,
2540
+ action_scope=scope)
2541
+ for scope in (operation_scopes or [None])
2542
+ ]
2543
+ decision = dict(scope_decisions[0])
2544
+ decision["decision"] = (
2545
+ "allow"
2546
+ if all(item["decision"] == "allow" for item in scope_decisions)
2547
+ else "deny")
2548
+ decision["action_scopes"] = operation_scopes
2549
+ decision["scope_decisions"] = [
2550
+ {
2551
+ "scope": scope,
2552
+ "decision": item["decision"],
2553
+ "effective_level": item["effective_level"],
2554
+ "required_level": item["required_level"],
2555
+ }
2556
+ for scope, item in zip(operation_scopes or [None], scope_decisions)
2557
+ ]
2558
+ decision["reasons"] = [
2559
+ reason
2560
+ for scope, item in zip(operation_scopes or [None], scope_decisions)
2561
+ for reason in [
2562
+ f"scope {scope!r}: {detail}" for detail in item["reasons"]]
2563
+ ]
2564
+ if effective_action != action_type:
2565
+ decision["reasons"].append(
2566
+ f"action reclassified {action_type!r} -> {effective_action!r}: "
2567
+ f"{len(specs)} verifier(s) would execute")
2568
+ env.set_policy_decision(decision)
2569
+ staged_runner = VerifierRunner(
2570
+ self.store, self.verifier_runner.workdir,
2571
+ persist_evidence=False)
2572
+ executed_outcomes = []
2573
+ if decision["decision"] == "allow":
2574
+ for spec in specs:
2575
+ outcome = staged_runner.run(spec)
2576
+ if outcome.subject_mutated:
2577
+ raise RuntimeError(
2578
+ "declared artifacts changed inside a verifier "
2579
+ "snapshot; discarding the refused attestation")
2580
+ executed_outcomes.append(outcome)
2581
+ outcomes.append(outcome.to_dict())
2582
+ env.add_execution(
2583
+ tool=f"verifier:{spec.name}",
2584
+ command_digest=spec.command_digest,
2585
+ exit_code=outcome.exit_code,
2586
+ started_at=outcome.started_at,
2587
+ output_digest=outcome.output_digest)
2588
+ elif specs:
2589
+ for spec in specs:
2590
+ outcomes.append({"verifier": spec.name, "kind": spec.kind,
2591
+ "result": "skipped",
2592
+ "details": "policy denied execution"})
2593
+ if decision["decision"] == "allow" and artifact_inputs:
2594
+ if self._artifact_digests(
2595
+ project_id, artifacts=artifact_paths) != artifact_inputs:
2596
+ raise RuntimeError(
2597
+ "declared artifacts changed while verifiers were running; "
2598
+ "discarding the stale attestation")
2599
+ for o in outcomes:
2600
+ env.add_verification(o)
2601
+ # Policy-mandated verifiers are always required; a caller may ADD to
2602
+ # the required set but never substitute its own list (ADR-020).
2603
+ required = sorted(policy_names
2604
+ | {s.name for s in specs if s.required})
2605
+ # A verifier is pinned only if policy owns its complete executable or
2606
+ # built-in adapter definition. A caller-added spec remains unpinned
2607
+ # however it labels itself.
2608
+ pinned_by_policy = {d["name"] for d in policy_defs if d["pinned"]}
2609
+ unpinned = sorted(set(self.policy.unpinned_required_verifiers(project_id))
2610
+ | {name for name in required
2611
+ if name not in pinned_by_policy})
2612
+ # Binding must be established AT ATTESTATION and signed with the rest
2613
+ # of the evidence. Grading a proof later with no probe attached made
2614
+ # the binding cap unreachable in practice: the gate could only ever
2615
+ # see "unproven", so unbound evidence passed every floor an operator
2616
+ # could realistically set (ADR-049).
2617
+ mutation_report = None
2618
+ determinism_report = {}
2619
+ if decision["decision"] == "allow" and any(s.artifacts for s in specs):
2620
+ excluded_probe_paths = []
2621
+ if self.store.path != ":memory:":
2622
+ store_path = Path(self.store.path).resolve()
2623
+ workdir = self.verifier_runner.workdir
2624
+ if store_path == workdir or workdir in store_path.parents:
2625
+ excluded_probe_paths = [
2626
+ store_path,
2627
+ Path(str(store_path) + "-wal"),
2628
+ Path(str(store_path) + "-shm"),
2629
+ ]
2630
+ mutation_report = run_mutation_probe(
2631
+ workdir=str(self.verifier_runner.workdir),
2632
+ artifacts=sorted({a for s in specs for a in s.artifacts}),
2633
+ specs=[s for s in specs if s.required],
2634
+ runner_factory=lambda sandbox: VerifierRunner(None, sandbox),
2635
+ excluded_paths=tuple(excluded_probe_paths),
2636
+ )
2637
+ if decision["decision"] == "allow":
2638
+ determinism_report = {
2639
+ spec.name: run_determinism_probe(spec, staged_runner)
2640
+ for spec in specs if spec.required
2641
+ }
2642
+ if decision["decision"] == "allow" and artifact_inputs:
2643
+ if self._artifact_digests(
2644
+ project_id, artifacts=artifact_paths) != artifact_inputs:
2645
+ raise RuntimeError(
2646
+ "declared artifacts changed while evidence probes were "
2647
+ "running; discarding the stale attestation")
2648
+ env.set_evidence_context(
2649
+ unpinned_required=unpinned,
2650
+ policy_pinned=sorted(pinned_by_policy),
2651
+ mutation=mutation_report.to_dict() if mutation_report else None,
2652
+ determinism=determinism_report)
2653
+ finalized = env.finalize(self.signer, required_verifiers=required)
2654
+ shape_errors = validate_envelope_shape(finalized)
2655
+ if shape_errors:
2656
+ raise ValueError(
2657
+ "attestation produced an invalid proof: "
2658
+ + "; ".join(shape_errors))
2659
+ with self.store.transaction():
2660
+ current_control_basis_digest = digest_obj(
2661
+ self._packet_control_basis(project_id))
2662
+ if current_control_basis_digest != initial_control_basis_digest:
2663
+ raise RuntimeError(
2664
+ "project control state changed while verifiers were running; "
2665
+ "discarding the stale attestation")
2666
+ if displaced:
2667
+ self.store.audit(
2668
+ actor="trust-service", action="verifier.displaced",
2669
+ object_id=project_id,
2670
+ detail=f"caller specs for policy-mandated verifier(s) "
2671
+ f"{sorted(set(displaced))} were ignored")
2672
+ proof_node = self.graph.put_node(
2673
+ entity_type="action", tenant_id=self.tenant_id,
2674
+ project_id=project_id, node_id=proof_node_id,
2675
+ status=finalized["status"],
2676
+ data={
2677
+ "kind": "proof", "intent_type": intent_type,
2678
+ "statement": intent_statement, "claim_key": claim_key,
2679
+ "continuity": continuity,
2680
+ "policy_strength": policy_strength,
2681
+ "policy_decision": decision["decision"],
2682
+ "proof_id": finalized["proof_id"],
2683
+ "proof_digest": finalized["proof_digest"],
2684
+ },
2685
+ )
2686
+ for outcome in executed_outcomes:
2687
+ if outcome.output is not None:
2688
+ stored_digest = self.store.put_evidence(
2689
+ outcome.output, media_type="text/plain")
2690
+ if stored_digest != outcome.evidence_digest:
2691
+ raise RuntimeError(
2692
+ "verifier evidence changed before proof commit")
2693
+ record_verification(
2694
+ self.graph, outcome, tenant_id=self.tenant_id,
2695
+ project_id=project_id)
2696
+ if finalized["status"] == "verified":
2697
+ for previous in self.graph.current(
2698
+ project_id, "action", tenant_id=self.tenant_id):
2699
+ if (previous["node_id"] == proof_node.id
2700
+ or previous["data"].get("claim_key") != claim_key
2701
+ or previous["status"] not in (
2702
+ "failed", "incomplete", "inconclusive", "stale")):
2703
+ continue
2704
+ if not _policy_strength_covers(
2705
+ policy_strength,
2706
+ previous["data"].get("policy_strength")):
2707
+ continue
2708
+ self.graph.put_node(
2709
+ entity_type="action", tenant_id=self.tenant_id,
2710
+ project_id=project_id, node_id=previous["node_id"],
2711
+ status="superseded",
2712
+ data={
2713
+ "superseded_by": finalized["proof_id"],
2714
+ "superseded_by_node": proof_node.id,
2715
+ },
2716
+ )
2717
+ self.graph.put_edge(
2718
+ edge_type="supersedes", src_id=proof_node.id,
2719
+ dst_id=previous["node_id"], tenant_id=self.tenant_id,
2720
+ project_id=project_id,
2721
+ )
2722
+ for targets in continuity.values():
2723
+ for target in targets:
2724
+ self.graph.put_edge(
2725
+ edge_type="derived_from", src_id=proof_node.id,
2726
+ dst_id=target, tenant_id=self.tenant_id,
2727
+ project_id=project_id)
2728
+ # AUT-005: a failed proof downgrades autonomy until a human clears it.
2729
+ # A policy-denied attempt is not a failed proof — it never ran.
2730
+ if (finalized["status"] == "failed"
2731
+ and decision["decision"] == "allow"):
2732
+ self.policy.downgrade(
2733
+ project_id, "failed_proof", ceiling=1,
2734
+ actor="trust-service")
2735
+ return finalized
2736
+
2737
+ @serialized_access
2738
+ def complete_task(self, project_id: str, task_id: str, *,
2739
+ proof: dict | None = None, actor: str = "agent") -> dict:
2740
+ """Validate and commit a completion against one coherent snapshot.
2741
+
2742
+ Proof currency, task status, current policy, single-use claiming, and
2743
+ the final graph mutation share the same BEGIN IMMEDIATE transaction.
2744
+ A concurrent quarantine or policy edit can therefore happen either
2745
+ before this decision or after it, never in the validation/mutation
2746
+ gap. Rejections are audited after rollback so the audit survives.
2747
+ """
2748
+ validate_public_identifier(project_id, field="project_id")
2749
+ validate_public_identifier(task_id, field="task_id")
2750
+ if not isinstance(actor, str) or not actor.strip():
2751
+ raise ValueError("task completion actor must be a non-empty string")
2752
+ actor = validate_human_text(actor, field="task completion actor")
2753
+ if proof is not None and not isinstance(proof, dict):
2754
+ raise ValueError("task completion proof must be an object or null")
2755
+ try:
2756
+ with self.store.transaction():
2757
+ return self._complete_task_locked(
2758
+ project_id, task_id, proof=proof, actor=actor)
2759
+ except PermissionError as exc:
2760
+ message = str(exc)
2761
+ self.store.audit(
2762
+ actor="trust-service", action="completion.rejected",
2763
+ object_id=task_id, authority="tenant_policy",
2764
+ detail=message)
2765
+ if "declares no required verifiers" in message:
2766
+ self.store.audit(
2767
+ actor="trust-service", action="policy.misconfigured",
2768
+ object_id=project_id, authority="tenant_policy",
2769
+ detail="proof required for task_complete but no required "
2770
+ "verifiers declared")
2771
+ raise
2772
+
2773
+ def _complete_task_locked(self, project_id: str, task_id: str, *,
2774
+ proof: dict | None = None,
2775
+ actor: str = "agent") -> dict:
2776
+ """False-completion gate: task_complete requires a VERIFIED proof when
2777
+ policy demands one (F4, PA-005, launch gate 'Trust').
2778
+
2779
+ An authentic 'verified' envelope is not enough on its own — it must be
2780
+ THIS task's proof. A signature only says the record is genuine, not
2781
+ what it is evidence for, so the gate also binds scope (same tenant and
2782
+ project), subject (the proof names this task), and single use (a proof
2783
+ already spent on another task cannot be replayed).
2784
+ """
2785
+ try:
2786
+ task = self.graph.get(
2787
+ task_id, tenant_id=self.tenant_id,
2788
+ project_id=project_id, entity_type="task")
2789
+ except KeyError:
2790
+ raise PermissionError(
2791
+ f"completion rejected: target {task_id} is not a task in the "
2792
+ f"requested project {self.tenant_id}/{project_id}") from None
2793
+ blocking_invalidations = self.invalidation.blocking_invalidations(
2794
+ project_id, task_id)
2795
+ if blocking_invalidations:
2796
+ details = ", ".join(
2797
+ f"{item['node_id']} ({item.get('status')}, "
2798
+ f"{(item.get('data') or {}).get('severity')})"
2799
+ for item in blocking_invalidations)
2800
+ raise PermissionError(
2801
+ "completion rejected: unresolved invalidation control state "
2802
+ f"blocks this task: {details}. Confirm and resolve it before "
2803
+ "claiming completion")
2804
+ if task.get("status") == "verified":
2805
+ existing_proof = (task.get("data") or {}).get(
2806
+ "completion_evidence")
2807
+ supplied_proof = proof.get("proof_id") \
2808
+ if isinstance(proof, dict) else None
2809
+ if ((proof is None and existing_proof is None)
2810
+ or (existing_proof is not None
2811
+ and supplied_proof == existing_proof)):
2812
+ return task
2813
+ raise PermissionError(
2814
+ f"completion rejected: task {task_id} is already verified by "
2815
+ "a different completion claim; explicitly reopen or "
2816
+ "invalidate it before replacing completion evidence")
2817
+ if task.get("status") == "quarantined":
2818
+ # Completion would overwrite the flag and silently promote
2819
+ # quarantined work to verified. Quarantine is resolved by a human
2820
+ # decision, never by claiming success on top of it (ADR-050).
2821
+ raise PermissionError(
2822
+ f"completion rejected: {task_id} is quarantined "
2823
+ f"({(task['data'] or {}).get('quarantine_reason', 'no reason recorded')}). "
2824
+ f"Resolve the quarantine before claiming completion.")
2825
+ proof_required = self.policy.proof_required(
2826
+ project_id, "task_complete")
2827
+ if proof_required and proof is None:
2828
+ raise PermissionError(
2829
+ "completion rejected: policy requires proof for task_complete"
2830
+ " and none was provided")
2831
+ if proof is not None:
2832
+ shape_errors = validate_envelope_shape(proof)
2833
+ if shape_errors:
2834
+ raise PermissionError(
2835
+ "completion rejected: malformed proof envelope — "
2836
+ + "; ".join(shape_errors))
2837
+ check = verify_envelope(proof, self.signer)
2838
+ if not check["valid"]:
2839
+ raise PermissionError("completion rejected: proof envelope invalid"
2840
+ " or tampered")
2841
+ if proof.get("status") != "verified":
2842
+ raise PermissionError(
2843
+ f"completion rejected: proof status is {proof.get('status')!r},"
2844
+ " not 'verified'")
2845
+ if proof.get("project_id") != project_id or \
2846
+ proof.get("tenant_id") != self.tenant_id:
2847
+ raise PermissionError(
2848
+ "completion rejected: proof was issued for"
2849
+ f" {proof.get('tenant_id')}/{proof.get('project_id')},"
2850
+ f" not {self.tenant_id}/{project_id}")
2851
+ if (proof.get("action_intent") or {}).get("type") != "task_complete":
2852
+ raise PermissionError(
2853
+ "completion rejected: proof intent is "
2854
+ f"{(proof.get('action_intent') or {}).get('type')!r}, "
2855
+ "not 'task_complete'")
2856
+ if not _proof_covers(proof, task_id):
2857
+ raise PermissionError(
2858
+ f"completion rejected: proof {proof.get('proof_id')} does not"
2859
+ f" name task {task_id}; attest the task you are completing")
2860
+ prior_spend = self.store._conn.execute(
2861
+ "SELECT task_id FROM spent_proofs WHERE tenant_id = ? "
2862
+ "AND project_id = ? AND proof_id = ?",
2863
+ (self.tenant_id, project_id, proof.get("proof_id")),
2864
+ ).fetchone()
2865
+ if prior_spend is not None and prior_spend["task_id"] != task_id:
2866
+ raise PermissionError(
2867
+ f"completion rejected: proof {proof.get('proof_id')} was "
2868
+ f"already used to complete {prior_spend['task_id']}")
2869
+ declared = self.policy.required_verifier_defs(project_id)
2870
+ if proof_required and not declared:
2871
+ # Demanding proof without saying what must be proven leaves
2872
+ # the claimant to define its own pass mark, which is the same
2873
+ # defect as an unpinned command wearing different clothes. It
2874
+ # is a configuration error, and refusing names the fix
2875
+ # (ADR-045).
2876
+ raise PermissionError(
2877
+ f"completion rejected: project {project_id} requires proof "
2878
+ f"for task_complete but declares no required verifiers, so "
2879
+ f"nothing states what would count as proof. Add a policy-"
2880
+ f"pinned verifier definition, or remove task_complete "
2881
+ f"from require_proof_for.")
2882
+ if (proof.get("policy_decision") or {}).get("decision") != "allow":
2883
+ raise PermissionError(
2884
+ "completion rejected: proof policy decision is not 'allow'")
2885
+ # The required set is re-read at completion time. A proof minted
2886
+ # under a laxer policy is not evidence for the current one: it
2887
+ # simply never tested what the project now requires (ADR-054).
2888
+ # Currency is judged only once the configuration is coherent: a
2889
+ # policy with nothing declared has no artifact surface, so a
2890
+ # staleness verdict derived from it would describe the config
2891
+ # error rather than the world (ADR-056).
2892
+ currency = self.proof_currency(project_id, task_id, proof)
2893
+ if not currency["current"]:
2894
+ raise PermissionError(
2895
+ "completion rejected: the proof no longer describes the "
2896
+ "current state — " + "; ".join(currency["reasons"])
2897
+ + ". Re-attest against the world as it is now.")
2898
+ # Compare complete normalized DEFINITIONS, not names or commands.
2899
+ # Kind, oracle expectations, timeout, negative control, artifacts
2900
+ # and isolation all change what a pass means. Command-only
2901
+ # comparison left those policy changes invisible (ADR-074).
2902
+ passed_digests: dict[str, set[str | None]] = {}
2903
+ for verification in proof.get("verifications", []):
2904
+ if (verification.get("result") == "passed"
2905
+ and verification.get("source")
2906
+ in AUTHORITATIVE_SOURCES):
2907
+ passed_digests.setdefault(
2908
+ verification.get("verifier"), set()).add(
2909
+ verification.get("definition_digest"))
2910
+ unmet = []
2911
+ for definition in self.policy.required_verifier_defs(project_id):
2912
+ name = definition["name"]
2913
+ if name not in passed_digests:
2914
+ unmet.append(name)
2915
+ elif definition["pinned"]:
2916
+ want = VerifierSpec.from_policy(definition).definition_digest
2917
+ recorded = passed_digests[name]
2918
+ if not any(recorded):
2919
+ unmet.append(
2920
+ f"{name} (this proof records no verifier definition "
2921
+ f"identity, so there is nothing to compare against "
2922
+ f"the pinned policy definition)")
2923
+ elif want not in recorded:
2924
+ unmet.append(
2925
+ f"{name} (the policy's verifier definition changed "
2926
+ f"since this proof was minted)")
2927
+ if unmet:
2928
+ raise PermissionError(
2929
+ f"completion rejected: the policy now requires {sorted(unmet)}, "
2930
+ f"which this proof does not cover. It was minted under an "
2931
+ f"earlier policy; re-attest against the current one.")
2932
+ minimum = self.policy.min_evidence_grade(project_id)
2933
+ if minimum:
2934
+ grade = self.grade_proof(project_id, proof)
2935
+ if not grade.at_least(minimum):
2936
+ raise PermissionError(
2937
+ f"completion rejected: evidence grade {grade.grade} is"
2938
+ f" below the required {minimum} — "
2939
+ + "; ".join(grade.caps or grade.reasons))
2940
+ if task.get("status") not in (None, "open", "in_progress"):
2941
+ raise PermissionError(
2942
+ f"completion rejected: task {task_id} is not completable while "
2943
+ f"its status is {task.get('status')!r}; resolve or explicitly "
2944
+ "reopen that state before claiming completion")
2945
+ current_task = self.graph.get(
2946
+ task_id, tenant_id=self.tenant_id, project_id=project_id,
2947
+ entity_type="task")
2948
+ if current_task.get("version") != task.get("version"):
2949
+ raise PermissionError(
2950
+ "completion rejected: task state changed while its proof was "
2951
+ "being validated; retry against the current task version")
2952
+ with self.store.transaction():
2953
+ if proof:
2954
+ spent_on = self._claim_proof(
2955
+ project_id, proof.get("proof_id"), task_id)
2956
+ if spent_on is not None:
2957
+ raise PermissionError(
2958
+ f"completion rejected: proof {proof.get('proof_id')} was "
2959
+ f"already used to complete {spent_on}")
2960
+ node = self.graph.put_node(
2961
+ entity_type="task", tenant_id=self.tenant_id,
2962
+ project_id=project_id, node_id=task_id, status="verified",
2963
+ data={
2964
+ "completion_evidence": (proof or {}).get("proof_id"),
2965
+ "completed_by": actor,
2966
+ "completed_at": utcnow(),
2967
+ },
2968
+ authority=(
2969
+ "verifier_authoritative"
2970
+ if proof else "agent_inference"),
2971
+ )
2972
+ if proof:
2973
+ for action in self.graph.current(
2974
+ project_id, "action", tenant_id=self.tenant_id):
2975
+ if action["data"].get("proof_id") == proof.get("proof_id"):
2976
+ self.graph.put_edge(
2977
+ edge_type="verifies",
2978
+ src_id=action["node_id"], dst_id=task_id,
2979
+ tenant_id=self.tenant_id,
2980
+ project_id=project_id)
2981
+ break
2982
+ return node
2983
+
2984
+ @serialized_access
2985
+ def bind_github_repository(
2986
+ self, project_id: str, *, repository_id: int,
2987
+ repository: str | None = None,
2988
+ github_installation_id: int | None = None,
2989
+ actor: str = "operator") -> dict:
2990
+ """Migrate an existing project to immutable GitHub webhook routing."""
2991
+ if not isinstance(actor, str) or not actor.strip():
2992
+ raise ValueError("repository binding actor must be a non-empty string")
2993
+ actor = validate_human_text(actor, field="repository binding actor")
2994
+ project = self._require_project(project_id)
2995
+ repository_id = self._github_numeric_id(
2996
+ repository_id, "repository_id")
2997
+ repository = validate_repository_name(
2998
+ repository, field="repository", optional=True)
2999
+ if github_installation_id is not None:
3000
+ github_installation_id = self._github_numeric_id(
3001
+ github_installation_id, "github_installation_id")
3002
+ data = dict(project["data"])
3003
+ if repository is not None:
3004
+ data["repository"] = repository
3005
+ data["repository_id"] = repository_id
3006
+ data["github_installation_id"] = github_installation_id
3007
+ with self.store.transaction():
3008
+ updated = self.graph.put_node(
3009
+ entity_type="project", tenant_id=self.tenant_id,
3010
+ project_id=project_id, node_id=project_id,
3011
+ status=project.get("status"), authority=project.get("authority"),
3012
+ data=data)
3013
+ self.store.audit(
3014
+ actor=actor, action="project.github_binding",
3015
+ object_id=project_id, authority="human_decision",
3016
+ detail=canonical_json({
3017
+ "repository_id": repository_id,
3018
+ "github_installation_id": github_installation_id,
3019
+ "repository": data.get("repository"),
3020
+ }))
3021
+ return updated
3022
+
3023
+ def _artifact_digests(
3024
+ self, project_id: str,
3025
+ artifacts: list[str] | None = None) -> dict[str, str]:
3026
+ """Stable content snapshots for canonical declared deliverables.
3027
+
3028
+ POSIX hosts traverse from a held workdir descriptor and open each
3029
+ component with O_NOFOLLOW. Other hosts reject links/reparse points at
3030
+ every observed route and bracket file reads and recursive inventory
3031
+ with physical metadata checks. No invalid declaration is omitted.
3032
+ """
3033
+ self._require_project(project_id)
3034
+ if artifacts is None:
3035
+ artifacts = [
3036
+ artifact
3037
+ for definition in self.policy.required_verifier_defs(project_id)
3038
+ for artifact in (definition.get("artifacts") or [])
3039
+ ]
3040
+ declared = sorted(normalize_artifact_paths(list(artifacts)))
3041
+ root = Path(os.path.abspath(self.verifier_runner.workdir))
3042
+ digests: dict[str, str] = {}
3043
+ for rel in declared:
3044
+ try:
3045
+ digests[f"artifact:{rel}"] = _snapshot_artifact(root, rel)
3046
+ except UnsafeArtifactError:
3047
+ raise
3048
+ except OSError as exc:
3049
+ # Unreadable is not unchanged. Dropping it would erase the
3050
+ # artifact from the comparison and silently make the proof
3051
+ # look current (ADR-047).
3052
+ digests[f"artifact:{rel}"] = sha256_hex(f"<unreadable:{exc}>")
3053
+ return digests
3054
+
3055
+ def _continuity_state_inputs(
3056
+ self, project_id: str, continuity: dict,
3057
+ *, allow_missing: bool = False) -> dict[str, str]:
3058
+ """Canonical signed commitments for typed proof targets.
3059
+
3060
+ Node ids are stable across graph versions, so signing only an id lets
3061
+ a proof for version 1 authorize version 2. These digests deliberately
3062
+ include the node revision and every semantic field but exclude
3063
+ transaction clocks. During a later currency check, an unavailable or
3064
+ wrong-scope target receives a deterministic tombstone digest rather
3065
+ than disappearing from the comparison.
3066
+ """
3067
+ if not isinstance(continuity, dict):
3068
+ raise ValueError("continuity links must be a typed object")
3069
+ unknown = sorted(set(continuity) - set(_CONTINUITY_LINK_TYPES))
3070
+ if unknown:
3071
+ raise ValueError(f"unknown typed continuity relation(s): {unknown}")
3072
+
3073
+ committed: dict[str, str] = {}
3074
+ for relation in sorted(continuity):
3075
+ targets = continuity[relation]
3076
+ if (not isinstance(targets, list)
3077
+ or any(not isinstance(target, str) or not target
3078
+ for target in targets)
3079
+ or len(targets) != len(set(targets))):
3080
+ raise ValueError(
3081
+ f"{relation} must be an array of distinct non-empty node ids")
3082
+ expected_type = _CONTINUITY_LINK_TYPES[relation]
3083
+ for target in sorted(targets):
3084
+ name = f"continuity:{relation}:{target}"
3085
+ try:
3086
+ node = self.graph.get(
3087
+ target, tenant_id=self.tenant_id,
3088
+ project_id=project_id, entity_type=expected_type)
3089
+ except KeyError:
3090
+ if not allow_missing:
3091
+ raise ValueError(
3092
+ f"{relation} target {target!r} is not a same-project "
3093
+ f"{expected_type}") from None
3094
+ basis = {
3095
+ "schema_version": "cce.continuity-state.v1",
3096
+ "relation": relation,
3097
+ "node_id": target,
3098
+ "expected_type": expected_type,
3099
+ "state": "unavailable",
3100
+ }
3101
+ else:
3102
+ basis = {
3103
+ "schema_version": "cce.continuity-state.v1",
3104
+ "relation": relation,
3105
+ "node": {
3106
+ field: node.get(field)
3107
+ for field in _CONTINUITY_STATE_FIELDS
3108
+ },
3109
+ }
3110
+ committed[name] = digest_obj(basis)
3111
+ return committed
3112
+
3113
+ @serialized_access
3114
+ def proof_currency(self, project_id: str, task_id: str, proof: dict) -> dict:
3115
+ """Does this proof still describe the world it was taken in?
3116
+
3117
+ A proof is a statement about a moment. Three things can make it stop
3118
+ being true without anything tampering with it: the deliverables it
3119
+ was checked against change, the assumptions the task rests on are
3120
+ invalidated, or the task itself is put under review. EV-005 shipped
3121
+ `detect_stale` as a library call that nothing invoked — a mechanism
3122
+ that exists but is not in the path that decides is not a control
3123
+ (ADR-043).
3124
+ """
3125
+ self._require_project(project_id)
3126
+ try:
3127
+ task = self.graph.get(
3128
+ task_id, tenant_id=self.tenant_id,
3129
+ project_id=project_id, entity_type="task")
3130
+ except KeyError:
3131
+ raise PermissionError(
3132
+ "proof currency target is outside the requested tenant/project") \
3133
+ from None
3134
+ if not isinstance(proof, dict):
3135
+ raise ValueError("proof currency requires a proof object")
3136
+ shape_errors = validate_envelope_shape(proof)
3137
+ if shape_errors:
3138
+ return {
3139
+ "current": False,
3140
+ "reasons": ["malformed proof envelope: " + "; ".join(shape_errors)],
3141
+ "changed_inputs": [], "untracked_inputs": [],
3142
+ }
3143
+
3144
+ reasons: list[str] = []
3145
+ continuity = proof.get("continuity_links") or {}
3146
+ try:
3147
+ continuity_inputs = self._continuity_state_inputs(
3148
+ project_id, {
3149
+ key: value for key, value in continuity.items()
3150
+ if key != "proof_node_id"
3151
+ }, allow_missing=True)
3152
+ except ValueError as exc:
3153
+ continuity_inputs = {}
3154
+ reasons.append(f"invalid continuity links: {exc}")
3155
+
3156
+ recorded_artifacts: list[str] = []
3157
+ try:
3158
+ for item in proof.get("inputs", []):
3159
+ name = item.get("name")
3160
+ if (item.get("kind") == "artifact"
3161
+ and isinstance(name, str)
3162
+ and name.startswith("artifact:")):
3163
+ recorded_artifacts.append(
3164
+ normalize_artifact_path(name.removeprefix("artifact:")))
3165
+ if len(recorded_artifacts) != len(set(recorded_artifacts)):
3166
+ raise ValueError(
3167
+ "proof repeats an engine-collected artifact commitment")
3168
+ current_inputs = self._artifact_digests(
3169
+ project_id, artifacts=recorded_artifacts)
3170
+ except (UnsafeArtifactError, ValueError) as exc:
3171
+ return {
3172
+ "current": False,
3173
+ "reasons": [f"unsafe artifact boundary: {exc}"],
3174
+ "changed_inputs": [], "untracked_inputs": [],
3175
+ }
3176
+ current_inputs.update(continuity_inputs)
3177
+ stale = detect_stale(proof, current_inputs)
3178
+ if stale["stale"]:
3179
+ by_name = {
3180
+ item["name"]: item.get("kind")
3181
+ for item in proof.get("inputs", [])
3182
+ }
3183
+ artifact_changes = [
3184
+ change["name"] for change in stale["changed_inputs"]
3185
+ if by_name.get(change["name"]) == "artifact"]
3186
+ continuity_changes = [
3187
+ change["name"] for change in stale["changed_inputs"]
3188
+ if by_name.get(change["name"]) == "continuity"]
3189
+ other_changes = [
3190
+ change["name"] for change in stale["changed_inputs"]
3191
+ if by_name.get(change["name"]) not in (
3192
+ "artifact", "continuity")]
3193
+ if artifact_changes:
3194
+ reasons.append(
3195
+ "deliverables changed since attestation: "
3196
+ + ", ".join(artifact_changes))
3197
+ if continuity_changes:
3198
+ reasons.append(
3199
+ "linked continuity state changed since attestation: "
3200
+ + ", ".join(continuity_changes))
3201
+ if other_changes:
3202
+ reasons.append(
3203
+ "tracked proof inputs changed since attestation: "
3204
+ + ", ".join(other_changes))
3205
+
3206
+ expected_names = Counter(continuity_inputs.keys())
3207
+ recorded_names = Counter(
3208
+ item["name"] for item in proof.get("inputs", [])
3209
+ if item.get("kind") == "continuity")
3210
+ if expected_names != recorded_names:
3211
+ reasons.append(
3212
+ "proof does not carry exactly one engine-collected state "
3213
+ "commitment for every typed continuity target")
3214
+ if not _proof_covers(proof, task_id):
3215
+ reasons.append("proof does not name the requested task")
3216
+ if task.get("status") in ("blocked", "uncertain"):
3217
+ reasons.append(
3218
+ f"the task is {task['status']}: something it depends on is "
3219
+ f"under review")
3220
+ for inv in self.invalidation.blocking_invalidations(
3221
+ project_id, task_id):
3222
+ data = inv.get("data") or {}
3223
+ reasons.append(
3224
+ f"unresolved invalidation {inv['node_id']} "
3225
+ f"({inv.get('status')}, {data.get('trigger_type')}, "
3226
+ f"{data.get('severity')}) blocks this task")
3227
+ return {"current": not reasons, "reasons": reasons,
3228
+ "changed_inputs": stale["changed_inputs"],
3229
+ # Disclosed, not silently dropped: a reader must be able to
3230
+ # see which recorded inputs this answer does NOT cover.
3231
+ "untracked_inputs": stale["untracked_inputs"]}
3232
+
3233
+ # -------------------------------------------------------- evidence quality
3234
+
3235
+ @serialized_access
3236
+ def grade_proof(self, project_id: str, proof: dict,
3237
+ mutation: "MutationReport | None" = None,
3238
+ determinism: dict | None = None) -> EvidenceGrade:
3239
+ """How hard would this proof be to fake? Lint, not an oracle (ADR-027)."""
3240
+ context = proof.get("evidence_context") or {}
3241
+ controls = {v.get("verifier"): (v.get("control") or {})
3242
+ for v in proof.get("verifications", [])}
3243
+ if mutation is None and context.get("mutation"):
3244
+ mutation = MutationReport(**{
3245
+ k: v for k, v in context["mutation"].items()
3246
+ if k in ("artifacts", "detected", "undetected", "inconclusive",
3247
+ "baseline", "ran_at", "error")})
3248
+ if determinism is None:
3249
+ recorded = context.get("determinism")
3250
+ determinism = recorded if isinstance(recorded, dict) else None
3251
+ return grade_evidence(
3252
+ outcomes=proof.get("verifications", []),
3253
+ required=(proof.get("verification_summary") or {}).get("required", []),
3254
+ controls=controls,
3255
+ mutation=mutation,
3256
+ determinism=determinism,
3257
+ unpinned_required=context.get("unpinned_required", []),
3258
+ )
3259
+
3260
+ @serialized_access
3261
+ def probe_evidence(self, project_id: str, *, artifacts: list[str] | None = None,
3262
+ workdir: str | None = None) -> MutationReport:
3263
+ """Destroy each declared deliverable in a sandbox copy and confirm a
3264
+ required check notices (EV-007). The real tree is never touched."""
3265
+ declared = (
3266
+ [] if artifacts is None else normalize_artifact_paths(artifacts))
3267
+ defs = [d for d in self.policy.required_verifier_defs(project_id)
3268
+ if d["pinned"]]
3269
+ specs = [VerifierSpec.from_policy(d) for d in defs]
3270
+ for spec in specs:
3271
+ declared.extend(spec.artifacts)
3272
+ root = workdir or str(self.verifier_runner.workdir)
3273
+ report = run_mutation_probe(
3274
+ workdir=root,
3275
+ artifacts=sorted(set(declared)),
3276
+ specs=specs,
3277
+ runner_factory=lambda sandbox: VerifierRunner(None, sandbox),
3278
+ excluded_paths=tuple(
3279
+ self.verifier_runner._snapshot_exclusions(root)),
3280
+ )
3281
+ self.store.audit(
3282
+ actor="trust-service", action="evidence.mutation_probe",
3283
+ object_id=project_id,
3284
+ detail=f"bound={report.bound} detected={len(report.detected)}"
3285
+ f" undetected={len(report.undetected)}")
3286
+ return report
3287
+
3288
+ @serialized_access
3289
+ def probe_determinism(self, project_id: str) -> dict:
3290
+ """Run each pinned required check twice; disagreement means flaky."""
3291
+ out = {}
3292
+ for d in self.policy.required_verifier_defs(project_id):
3293
+ if not d["pinned"]:
3294
+ continue
3295
+ spec = VerifierSpec.from_policy(d)
3296
+ out[spec.name] = run_determinism_probe(spec, self.verifier_runner)
3297
+ return out
3298
+
3299
+ def _claim_proof(self, project_id: str, proof_id: str | None,
3300
+ task_id: str) -> str | None:
3301
+ """Atomically claim this proof for this task.
3302
+
3303
+ Returns None when the claim succeeds (or is a benign re-claim by the
3304
+ same task), otherwise the task that already holds it. The uniqueness
3305
+ is the database's, so two concurrent completions cannot both win
3306
+ (ADR-059).
3307
+ """
3308
+ if not proof_id:
3309
+ return None
3310
+ with self.store.write_scope():
3311
+ try:
3312
+ self.store._conn.execute(
3313
+ "INSERT INTO spent_proofs (tenant_id, project_id, proof_id,"
3314
+ " task_id, spent_at) VALUES (?,?,?,?,?)",
3315
+ (self.tenant_id, project_id, proof_id, task_id, utcnow()))
3316
+ return None
3317
+ except sqlite3.IntegrityError:
3318
+ row = self.store._conn.execute(
3319
+ "SELECT task_id FROM spent_proofs WHERE tenant_id = ? "
3320
+ "AND project_id = ? AND proof_id = ?",
3321
+ (self.tenant_id, project_id, proof_id)).fetchone()
3322
+ holder = row["task_id"] if row else "another task"
3323
+ return None if holder == task_id else holder
3324
+
3325
+ # ----------------------------------------------------------------- checks
3326
+
3327
+ @serialized_access
3328
+ def continuity_check(self, project_id: str, *,
3329
+ _sign_receipt: bool = True) -> dict:
3330
+ """Evaluate and authenticate one coherent continuity frontier.
3331
+
3332
+ The returned counterfactual witness records both why the verdict holds
3333
+ and the exact conjunction that must change to reach success. Reads and
3334
+ signing occur under one Store transaction, so policy, graph, packet,
3335
+ and log anchors describe a state that actually coexisted.
3336
+ """
3337
+ with self.store.transaction():
3338
+ project = self.graph.get(
3339
+ project_id, tenant_id=self.tenant_id,
3340
+ project_id=project_id, entity_type="project")
3341
+ project_data = project["data"]
3342
+ frontier = self.policy.tracked_ref_frontier(
3343
+ project_id, project_data)
3344
+ tracked_ref = frontier["tracked_ref"]
3345
+ current_head = frontier["current_head_sha"]
3346
+ trusted_external_head = frontier["trusted_external_head_sha"]
3347
+ frontier_uncertain = frontier["uncertain"]
3348
+
3349
+ open_inv = self.invalidation.open_invalidations(project_id)
3350
+ critical = [
3351
+ item for item in open_inv
3352
+ if item["data"].get("severity") in ("high", "critical")
3353
+ ]
3354
+ pending = [
3355
+ item for item in open_inv
3356
+ if item["status"] == "pending_confirmation"
3357
+ ]
3358
+ failed_proofs = [
3359
+ node for node in self.graph.current(
3360
+ project_id, "action", tenant_id=self.tenant_id)
3361
+ if node["data"].get("kind") == "proof"
3362
+ and node["data"].get("policy_decision") != "deny"
3363
+ and node["status"] in (
3364
+ "failed", "incomplete", "inconclusive", "stale")
3365
+ ]
3366
+ authority_conflicts = [
3367
+ node for node in self.graph.current(
3368
+ project_id, tenant_id=self.tenant_id)
3369
+ if node["data"].get("conflict_requires_resolution")
3370
+ and node["status"] not in (
3371
+ "resolved", "superseded", "invalidated", "rejected")
3372
+ ]
3373
+
3374
+ policy_config = self.policy.project_config(project_id)
3375
+ definitions = self.policy.required_verifier_defs(project_id)
3376
+ passing_nodes = [
3377
+ node for node in self.graph.current(
3378
+ project_id, "verification", status=["passed"],
3379
+ tenant_id=self.tenant_id)
3380
+ ]
3381
+ trusted_passes = []
3382
+ verifier_gaps = proof_policy_verifier_gaps(
3383
+ policy_config, definitions)
3384
+ external_without_frontier = bool(
3385
+ project_data.get("repository_id")
3386
+ and not trusted_external_head)
3387
+ required_identities = []
3388
+ for definition in definitions:
3389
+ name = definition["name"]
3390
+ spec = VerifierSpec.from_policy(definition)
3391
+ wanted_digest = spec.command_digest \
3392
+ if definition.get("pinned") else None
3393
+ wanted_definition_digest = spec.definition_digest
3394
+ identity = {
3395
+ "name": name,
3396
+ "pinned": bool(definition.get("pinned")),
3397
+ "command_digest": wanted_digest,
3398
+ "definition_digest": wanted_definition_digest,
3399
+ }
3400
+ required_identities.append(identity)
3401
+ matches = []
3402
+ for node in passing_nodes:
3403
+ data = node["data"]
3404
+ if data.get("verifier") != name:
3405
+ continue
3406
+ source = data.get("source") or ""
3407
+ if source == "executed":
3408
+ if (definition.get("pinned")
3409
+ and data.get("pinned")
3410
+ and data.get("definition_digest")
3411
+ == wanted_definition_digest):
3412
+ matches.append(node)
3413
+ elif source.startswith("github:"):
3414
+ if not trusted_external_head:
3415
+ external_without_frontier = True
3416
+ if (not definition.get("pinned")
3417
+ and self.policy.external_verifier_trusted(
3418
+ project_id, source, data)
3419
+ and trusted_external_head
3420
+ and data.get("head_sha")
3421
+ == trusted_external_head):
3422
+ matches.append(node)
3423
+ if not matches:
3424
+ verifier_gaps.append(name)
3425
+ continue
3426
+ chosen = matches[-1]
3427
+ data = chosen["data"]
3428
+ trusted_passes.append({
3429
+ "node_id": chosen["node_id"],
3430
+ "verifier": name,
3431
+ "source": data.get("source"),
3432
+ "command_digest": data.get("command_digest"),
3433
+ "definition_digest": data.get("definition_digest"),
3434
+ "head_sha": data.get("head_sha"),
3435
+ "app": data.get("app"),
3436
+ "app_id": data.get("app_id"),
3437
+ "workflow_id": data.get("workflow_id"),
3438
+ "workflow_path": data.get("workflow_path"),
3439
+ "installation_id": data.get("installation_id"),
3440
+ })
3441
+
3442
+ packet_row = self.store._conn.execute(
3443
+ "SELECT * FROM packet_watermark WHERE project_id = ?",
3444
+ (project_id,)).fetchone()
3445
+ packet_current = not self.packet_is_stale(project_id)
3446
+ packet_state = {
3447
+ "packet_id": packet_row["packet_id"] if packet_row else None,
3448
+ "last_event_seq": (
3449
+ packet_row["last_event_seq"] if packet_row else None),
3450
+ "packet_digest": (
3451
+ packet_row["packet_digest"] if packet_row else None),
3452
+ "control_basis_digest": (
3453
+ packet_row["control_basis_digest"] if packet_row else None),
3454
+ "audit_entry_hash": (
3455
+ packet_row["audit_entry_hash"] if packet_row else None),
3456
+ "current": packet_current,
3457
+ }
3458
+ invalidation_state = [
3459
+ {
3460
+ "node_id": item["node_id"],
3461
+ "status": item["status"],
3462
+ "severity": item["data"].get("severity"),
3463
+ "trigger_type": item["data"].get("trigger_type"),
3464
+ "target_node_id": item["data"].get("target_node_id"),
3465
+ "affected": item["data"].get("affected", []),
3466
+ }
3467
+ for item in open_inv
3468
+ ]
3469
+ proof_state = [
3470
+ {
3471
+ "node_id": node["node_id"],
3472
+ "status": node["status"],
3473
+ "proof_id": node["data"].get("proof_id"),
3474
+ "claim_key": node["data"].get("claim_key"),
3475
+ "intent_type": node["data"].get("intent_type"),
3476
+ }
3477
+ for node in failed_proofs
3478
+ ]
3479
+ conflict_state = [
3480
+ {
3481
+ "node_id": node["node_id"],
3482
+ "entity_type": node["entity_type"],
3483
+ "status": node["status"],
3484
+ "authority": node.get("authority"),
3485
+ "statement_digest": digest_obj(
3486
+ node["data"].get("statement") or ""),
3487
+ }
3488
+ for node in authority_conflicts
3489
+ ]
3490
+ project_events = self.store.events(
3491
+ project_id, tenant_id=self.tenant_id)
3492
+ project_frontier = {
3493
+ "count": len(project_events),
3494
+ "max_seq": (
3495
+ max((event["seq"] for event in project_events), default=0)),
3496
+ "entries_digest": digest_obj([
3497
+ {
3498
+ "seq": event["seq"],
3499
+ "event_id": event["event_id"],
3500
+ "payload_digest": event["payload_digest"],
3501
+ "entry_hash": event.get("entry_hash"),
3502
+ }
3503
+ for event in project_events
3504
+ ]),
3505
+ }
3506
+ event_chain = self.store.verify_chain("events")
3507
+ audit_chain = self.store.verify_chain("audit_log")
3508
+ event_checkpoint = {
3509
+ "count": self.store._conn.execute(
3510
+ "SELECT COUNT(*) AS n FROM events").fetchone()["n"],
3511
+ "tip": self.store._chain_tip("events"),
3512
+ "intact": event_chain.get("intact"),
3513
+ }
3514
+ audit_checkpoint = {
3515
+ "count": self.store._conn.execute(
3516
+ "SELECT COUNT(*) AS n FROM audit_log").fetchone()["n"],
3517
+ "tip": self.store._chain_tip("audit_log"),
3518
+ "intact": audit_chain.get("intact"),
3519
+ }
3520
+ policy_digest = digest_obj(policy_config)
3521
+ projection_digest = self.projection_fingerprint(project_id)
3522
+ decision_state = {
3523
+ "invalidations": invalidation_state,
3524
+ "proofs": proof_state,
3525
+ "verifier_requirements": required_identities,
3526
+ "trusted_passes": trusted_passes,
3527
+ "verifier_gaps": sorted(verifier_gaps),
3528
+ "conflicts": conflict_state,
3529
+ "packet": packet_state,
3530
+ "revision": {
3531
+ "tracked_ref": tracked_ref,
3532
+ "current_head_sha": current_head,
3533
+ "uncertain": frontier_uncertain,
3534
+ "external_without_frontier": external_without_frontier,
3535
+ },
3536
+ # Counts and tips live in basis as prefix checkpoints. Only
3537
+ # integrity truth belongs in decision state, so an unrelated
3538
+ # project append does not stale this project's decision.
3539
+ "integrity": {
3540
+ "event_log_intact": bool(event_chain.get("intact")),
3541
+ "audit_log_intact": bool(audit_chain.get("intact")),
3542
+ },
3543
+ }
3544
+ predicates = _continuity_predicates(project_id, decision_state)
3545
+ decision_state["predicates"] = predicates
3546
+ satisfied = [item for item in predicates if item["satisfied"]]
3547
+ blockers = [item for item in predicates if not item["satisfied"]]
3548
+ conclusion = _continuity_decision(predicates)
3549
+ trust_unavailable = (
3550
+ not next(item for item in predicates if item["predicate"] ==
3551
+ "revision_frontier_decidable")["satisfied"]
3552
+ or not next(item for item in predicates if item["predicate"] ==
3553
+ "integrity_chains_intact")["satisfied"])
3554
+ decision_state_digest = digest_obj(decision_state)
3555
+ generated_at = utcnow()
3556
+ algorithm = getattr(self.signer, "algorithm", "unknown")
3557
+ self_authenticating = bool(
3558
+ getattr(self.signer, "self_authenticating", False))
3559
+ registry_capable = bool(
3560
+ not self_authenticating
3561
+ and callable(getattr(self.signer, "derive_fingerprint", None))
3562
+ and hasattr(self.signer, "registered_fingerprints"))
3563
+ if algorithm == "hmac-sha256" and self_authenticating:
3564
+ verification_mode = "locally_authenticated_mac"
3565
+ elif registry_capable:
3566
+ verification_mode = "registered_public_key"
3567
+ elif self_authenticating:
3568
+ verification_mode = "configured_authenticator"
3569
+ else:
3570
+ verification_mode = "unestablished_authenticity"
3571
+ receipt = {
3572
+ "schema_version": "cce.continuity-receipt.v1",
3573
+ "payload_type": _CONTINUITY_PAYLOAD_TYPE,
3574
+ "receipt_id": new_id("proof"),
3575
+ "generated_at": generated_at,
3576
+ "decision": conclusion,
3577
+ "audience": "operator",
3578
+ "privacy_notice": (
3579
+ "Detailed receipts contain stable project, proof, "
3580
+ "invalidation, verifier, and log identifiers. Publish "
3581
+ "only a separately designed selective-disclosure view."),
3582
+ "issuer": {
3583
+ "configured_key_id": getattr(
3584
+ self.signer, "key_id", None),
3585
+ "algorithm": algorithm,
3586
+ "verification_mode": verification_mode,
3587
+ "independently_verifiable": registry_capable,
3588
+ },
3589
+ "basis": {
3590
+ "tenant_id": self.tenant_id,
3591
+ "project_id": project_id,
3592
+ "tracked_ref": tracked_ref,
3593
+ "current_head_sha": current_head,
3594
+ "policy_digest": policy_digest,
3595
+ "projection_digest": projection_digest,
3596
+ "decision_state_digest": decision_state_digest,
3597
+ "project_event_frontier": project_frontier,
3598
+ "event_log": event_checkpoint,
3599
+ "audit_log": audit_checkpoint,
3600
+ },
3601
+ "satisfied": satisfied,
3602
+ "blockers": blockers,
3603
+ "flip_conditions": {
3604
+ "semantics": "ceteris_paribus_boolean_frontier",
3605
+ "to_success": {
3606
+ "operator": "all",
3607
+ "predicates": [
3608
+ item["predicate"] for item in blockers
3609
+ ],
3610
+ },
3611
+ "from_success": {
3612
+ "operator": "any",
3613
+ "predicates": [
3614
+ item["predicate"] for item in satisfied
3615
+ ],
3616
+ },
3617
+ },
3618
+ "decision_state": decision_state,
3619
+ "trust_limit": (
3620
+ "HMAC receipts are locally authenticated: a verifier "
3621
+ "holding the shared key can also forge them. Public "
3622
+ "non-repudiation requires a registered asymmetric signer "
3623
+ "and an external transparency anchor. Flip conditions "
3624
+ "are a frozen-state Boolean frontier, not a guarantee "
3625
+ "that remediation will leave every other predicate fixed."),
3626
+ }
3627
+ receipt["receipt_digest"] = digest_obj({
3628
+ key: value for key, value in receipt.items()
3629
+ if key not in ("signature", "receipt_digest")
3630
+ })
3631
+ if _sign_receipt:
3632
+ receipt["signature"] = self.signer.sign(receipt)
3633
+
3634
+ return {
3635
+ "name": "CCE Continuity",
3636
+ "conclusion": conclusion,
3637
+ "open_invalidations": [
3638
+ item["node_id"] for item in open_inv],
3639
+ "critical": [item["node_id"] for item in critical],
3640
+ "pending_confirmation": [
3641
+ item["node_id"] for item in pending],
3642
+ "failed_proofs": [
3643
+ node["node_id"] for node in failed_proofs],
3644
+ "verifier_gaps": sorted(verifier_gaps),
3645
+ "authority_conflicts": [
3646
+ node["node_id"] for node in authority_conflicts],
3647
+ "trust_unavailable": trust_unavailable,
3648
+ "generated_at": generated_at,
3649
+ "continuity_receipt": receipt,
3650
+ }
3651
+
3652
+ @staticmethod
3653
+ def _invalid_receipt(reason: str) -> dict:
3654
+ return {"verdict": "INVALID", "current": False, "reason": reason}
3655
+
3656
+ def _receipt_authenticator_valid(self, receipt: dict) -> tuple[bool, str]:
3657
+ """Verify integrity *and* out-of-band signer authenticity."""
3658
+ issuer = receipt.get("issuer")
3659
+ signature = receipt.get("signature")
3660
+ if not isinstance(issuer, dict) or not isinstance(signature, dict):
3661
+ return False, "receipt issuer and signature must be objects"
3662
+ algorithm = getattr(self.signer, "algorithm", None)
3663
+ key_id = getattr(self.signer, "key_id", None)
3664
+ if (not isinstance(algorithm, str)
3665
+ or issuer.get("algorithm") != algorithm
3666
+ or signature.get("algorithm") != algorithm):
3667
+ return False, "issuer, signature, and configured algorithms differ"
3668
+ if (issuer.get("configured_key_id") != key_id
3669
+ or signature.get("key_id") != key_id):
3670
+ return False, "issuer and signature key ids do not match the verifier"
3671
+
3672
+ self_authenticating = bool(
3673
+ getattr(self.signer, "self_authenticating", False))
3674
+ registry_capable = bool(
3675
+ not self_authenticating
3676
+ and callable(getattr(self.signer, "derive_fingerprint", None))
3677
+ and hasattr(self.signer, "registered_fingerprints"))
3678
+ if algorithm == "hmac-sha256" and self_authenticating:
3679
+ expected_mode = "locally_authenticated_mac"
3680
+ elif registry_capable:
3681
+ expected_mode = "registered_public_key"
3682
+ elif self_authenticating:
3683
+ expected_mode = "configured_authenticator"
3684
+ else:
3685
+ expected_mode = "unestablished_authenticity"
3686
+ if (issuer.get("verification_mode") != expected_mode
3687
+ or issuer.get("independently_verifiable") is not
3688
+ registry_capable):
3689
+ return False, "issuer verification claims do not match signer capabilities"
3690
+ try:
3691
+ signature_ok = bool(self.signer.verify(receipt))
3692
+ except (KeyError, TypeError, ValueError):
3693
+ signature_ok = False
3694
+ if not signature_ok:
3695
+ return False, "receipt signature does not cover this content"
3696
+ if self_authenticating:
3697
+ return True, "configured authenticator verified"
3698
+ if not registry_capable:
3699
+ return False, "signer cannot establish authenticity out of band"
3700
+
3701
+ actual = self.signer.derive_fingerprint(signature)
3702
+ claimed = signature.get("fingerprint")
3703
+ registry = set(getattr(self.signer, "registered_fingerprints", set()))
3704
+ if not actual or claimed != actual:
3705
+ return False, "declared fingerprint does not match the attached key"
3706
+ if actual not in registry:
3707
+ return False, "signature key is not present in the verifier registry"
3708
+ return True, "registered signer verified"
3709
+
3710
+ def _chain_checkpoint_is_prefix(self, table: str, checkpoint: dict) -> bool:
3711
+ """Whether a signed global-chain checkpoint is a stored prefix."""
3712
+ if (table not in {"events", "audit_log"}
3713
+ or not isinstance(checkpoint, dict)
3714
+ or set(checkpoint) != {"count", "tip", "intact"}):
3715
+ return False
3716
+ count = checkpoint.get("count")
3717
+ tip = checkpoint.get("tip")
3718
+ intact = checkpoint.get("intact")
3719
+ if (isinstance(count, bool) or not isinstance(count, int) or count < 0
3720
+ or not isinstance(tip, str) or not isinstance(intact, bool)):
3721
+ return False
3722
+ if count == 0:
3723
+ from .store import GENESIS
3724
+ return tip == GENESIS
3725
+ row = self.store._conn.execute(
3726
+ f"SELECT entry_hash FROM {table} ORDER BY seq LIMIT 1 OFFSET ?",
3727
+ (count - 1,)).fetchone()
3728
+ return bool(row and row["entry_hash"] == tip)
3729
+
3730
+ def _receipt_semantics_valid(
3731
+ self, project_id: str, receipt: dict) -> tuple[bool, str]:
3732
+ """Recompute v1 predicates, partition, flips, and conclusion."""
3733
+ basis = receipt.get("basis")
3734
+ state = receipt.get("decision_state")
3735
+ if not isinstance(basis, dict) or not isinstance(state, dict):
3736
+ return False, "receipt basis and decision_state must be objects"
3737
+ required_basis = {
3738
+ "tenant_id", "project_id", "tracked_ref", "current_head_sha",
3739
+ "policy_digest", "projection_digest", "decision_state_digest",
3740
+ "project_event_frontier", "event_log", "audit_log",
3741
+ }
3742
+ required_state = {
3743
+ "invalidations", "proofs", "verifier_requirements",
3744
+ "trusted_passes", "verifier_gaps", "conflicts", "packet",
3745
+ "revision", "integrity", "predicates",
3746
+ }
3747
+ if set(basis) != required_basis or set(state) != required_state:
3748
+ return False, "receipt decision basis has missing or unknown fields"
3749
+ if (basis.get("tenant_id") != self.tenant_id
3750
+ or basis.get("project_id") != project_id):
3751
+ return False, "receipt tenant/project scope does not match"
3752
+ if (not isinstance(state.get("packet"), dict)
3753
+ or not isinstance(state.get("revision"), dict)
3754
+ or not isinstance(state.get("integrity"), dict)
3755
+ or any(not isinstance(state.get(name), list) for name in (
3756
+ "invalidations", "proofs", "verifier_requirements",
3757
+ "trusted_passes", "verifier_gaps", "conflicts",
3758
+ "predicates"))):
3759
+ return False, "receipt decision_state field types are invalid"
3760
+ nested_shapes = {
3761
+ "packet": {
3762
+ "packet_id", "last_event_seq", "packet_digest",
3763
+ "control_basis_digest", "audit_entry_hash", "current",
3764
+ },
3765
+ "revision": {
3766
+ "tracked_ref", "current_head_sha", "uncertain",
3767
+ "external_without_frontier",
3768
+ },
3769
+ "integrity": {"event_log_intact", "audit_log_intact"},
3770
+ }
3771
+ for name, fields in nested_shapes.items():
3772
+ if set(state[name]) != fields:
3773
+ return False, (
3774
+ f"receipt decision_state.{name} has missing or unknown fields")
3775
+ record_shapes = {
3776
+ "invalidations": {
3777
+ "node_id", "status", "severity", "trigger_type",
3778
+ "target_node_id", "affected",
3779
+ },
3780
+ "proofs": {
3781
+ "node_id", "status", "proof_id", "claim_key", "intent_type",
3782
+ },
3783
+ "verifier_requirements": {
3784
+ "name", "pinned", "command_digest", "definition_digest",
3785
+ },
3786
+ "trusted_passes": {
3787
+ "node_id", "verifier", "source", "command_digest",
3788
+ "definition_digest",
3789
+ "head_sha", "app", "app_id", "workflow_id",
3790
+ "workflow_path", "installation_id",
3791
+ },
3792
+ "conflicts": {
3793
+ "node_id", "entity_type", "status", "authority",
3794
+ "statement_digest",
3795
+ },
3796
+ }
3797
+ for name, fields in record_shapes.items():
3798
+ if any(not isinstance(item, dict) or set(item) != fields
3799
+ for item in state[name]):
3800
+ return False, (
3801
+ f"receipt decision_state.{name} contains a malformed record")
3802
+ gaps = state["verifier_gaps"]
3803
+ if (any(not isinstance(name, str) for name in gaps)
3804
+ or gaps != sorted(set(gaps))):
3805
+ return False, "receipt verifier_gaps must be sorted unique names"
3806
+ project_frontier = basis.get("project_event_frontier")
3807
+ if (not isinstance(project_frontier, dict)
3808
+ or set(project_frontier) != {
3809
+ "count", "max_seq", "entries_digest"}
3810
+ or isinstance(project_frontier.get("count"), bool)
3811
+ or not isinstance(project_frontier.get("count"), int)
3812
+ or project_frontier["count"] < 0
3813
+ or isinstance(project_frontier.get("max_seq"), bool)
3814
+ or not isinstance(project_frontier.get("max_seq"), int)
3815
+ or project_frontier["max_seq"] < 0
3816
+ or not isinstance(
3817
+ project_frontier.get("entries_digest"), str)
3818
+ or _SHA256_DIGEST.fullmatch(
3819
+ project_frontier["entries_digest"]) is None):
3820
+ return False, "receipt project event frontier is malformed"
3821
+ for field in (
3822
+ "policy_digest", "projection_digest",
3823
+ "decision_state_digest"):
3824
+ value = basis.get(field)
3825
+ if (not isinstance(value, str)
3826
+ or _SHA256_DIGEST.fullmatch(value) is None):
3827
+ return False, f"receipt basis {field} is not a canonical digest"
3828
+ if basis.get("decision_state_digest") != digest_obj(state):
3829
+ return False, "decision_state digest does not match its contents"
3830
+ revision = state["revision"]
3831
+ if (basis.get("tracked_ref") != revision.get("tracked_ref")
3832
+ or basis.get("current_head_sha") !=
3833
+ revision.get("current_head_sha")):
3834
+ return False, "revision basis contradicts decision_state"
3835
+ event_checkpoint = basis.get("event_log")
3836
+ audit_checkpoint = basis.get("audit_log")
3837
+ if (not isinstance(event_checkpoint, dict)
3838
+ or not isinstance(audit_checkpoint, dict)
3839
+ or event_checkpoint.get("intact") is not
3840
+ state["integrity"].get("event_log_intact")
3841
+ or audit_checkpoint.get("intact") is not
3842
+ state["integrity"].get("audit_log_intact")):
3843
+ return False, "log basis contradicts integrity decision state"
3844
+ if (not self._chain_checkpoint_is_prefix("events", event_checkpoint)
3845
+ or not self._chain_checkpoint_is_prefix(
3846
+ "audit_log", audit_checkpoint)):
3847
+ return False, "receipt log checkpoint is not a stored chain prefix"
3848
+
3849
+ actual_predicates = state["predicates"]
3850
+ predicate_fields = {
3851
+ "predicate", "subject", "observed", "required", "satisfied",
3852
+ "evidence_digest", "remediation",
3853
+ }
3854
+ if any(not isinstance(item, dict) or set(item) != predicate_fields
3855
+ for item in actual_predicates):
3856
+ return False, "every decision predicate must be an object"
3857
+ actual_names = [item.get("predicate") for item in actual_predicates]
3858
+ if (len(actual_names) != len(set(actual_names))
3859
+ or set(actual_names) != _CONTINUITY_PREDICATE_NAMES):
3860
+ return False, "receipt predicate set is missing, unknown, or duplicated"
3861
+ expected_predicates = _continuity_predicates(project_id, state)
3862
+ actual_map = {item["predicate"]: item for item in actual_predicates}
3863
+ expected_map = {item["predicate"]: item for item in expected_predicates}
3864
+ if actual_map != expected_map:
3865
+ return False, "receipt predicate truth or evidence is contradictory"
3866
+
3867
+ def partition(name: str) -> tuple[dict[str, dict] | None, str | None]:
3868
+ items = receipt.get(name)
3869
+ if not isinstance(items, list) or any(
3870
+ not isinstance(item, dict) for item in items):
3871
+ return None, f"receipt {name} must be an array of predicates"
3872
+ names = [item.get("predicate") for item in items]
3873
+ if len(names) != len(set(names)):
3874
+ return None, f"receipt {name} contains duplicate predicates"
3875
+ return {item["predicate"]: item for item in items}, None
3876
+
3877
+ satisfied_map, error = partition("satisfied")
3878
+ if error:
3879
+ return False, error
3880
+ blocker_map, error = partition("blockers")
3881
+ if error:
3882
+ return False, error
3883
+ expected_satisfied = {
3884
+ name: item for name, item in expected_map.items()
3885
+ if item["satisfied"]}
3886
+ expected_blockers = {
3887
+ name: item for name, item in expected_map.items()
3888
+ if not item["satisfied"]}
3889
+ if (satisfied_map != expected_satisfied
3890
+ or blocker_map != expected_blockers):
3891
+ return False, "satisfied and blockers are not the exact predicate partition"
3892
+
3893
+ flips = receipt.get("flip_conditions")
3894
+ if (not isinstance(flips, dict)
3895
+ or set(flips) != {"semantics", "to_success", "from_success"}
3896
+ or flips.get("semantics") !=
3897
+ "ceteris_paribus_boolean_frontier"):
3898
+ return False, "flip conditions omit their ceteris-paribus semantics"
3899
+ to_success = flips.get("to_success")
3900
+ from_success = flips.get("from_success")
3901
+ if not isinstance(to_success, dict) or not isinstance(from_success, dict):
3902
+ return False, "flip condition branches must be objects"
3903
+
3904
+ def names_are(branch: dict, operator: str, expected_names: set[str]) -> bool:
3905
+ names = branch.get("predicates")
3906
+ return bool(
3907
+ set(branch) == {"operator", "predicates"}
3908
+ and branch.get("operator") == operator
3909
+ and isinstance(names, list)
3910
+ and all(isinstance(name, str) for name in names)
3911
+ and len(names) == len(set(names))
3912
+ and set(names) == expected_names)
3913
+
3914
+ if (not names_are(to_success, "all", set(expected_blockers))
3915
+ or not names_are(
3916
+ from_success, "any", set(expected_satisfied))):
3917
+ return False, "flip sets do not match the predicate partition"
3918
+ expected_decision = _continuity_decision(expected_predicates)
3919
+ if receipt.get("decision") != expected_decision:
3920
+ return False, "receipt decision contradicts its predicates"
3921
+ return True, "receipt semantics are internally consistent"
3922
+
3923
+ @serialized_access
3924
+ def verify_continuity_receipt(
3925
+ self, project_id: str, receipt: dict) -> dict:
3926
+ """Classify a receipt as invalid, authentic historical, or current."""
3927
+ required = {
3928
+ "schema_version", "payload_type", "receipt_id", "generated_at",
3929
+ "decision", "audience", "privacy_notice", "issuer", "basis",
3930
+ "satisfied", "blockers", "flip_conditions", "decision_state",
3931
+ "trust_limit", "receipt_digest", "signature",
3932
+ }
3933
+ if not isinstance(receipt, dict) or set(receipt) != required:
3934
+ return self._invalid_receipt(
3935
+ "continuity receipt has missing or unknown top-level fields")
3936
+ issuer = receipt.get("issuer")
3937
+ signature = receipt.get("signature")
3938
+ if (not isinstance(issuer, dict)
3939
+ or set(issuer) != {
3940
+ "configured_key_id", "algorithm", "verification_mode",
3941
+ "independently_verifiable"}
3942
+ or not isinstance(signature, dict)
3943
+ or not {"key_id", "algorithm", "value"} <= set(signature)
3944
+ or not set(signature) <= {
3945
+ "key_id", "algorithm", "value", "fingerprint",
3946
+ "public_key"}):
3947
+ return self._invalid_receipt(
3948
+ "receipt issuer or signature has missing or unknown fields")
3949
+ if (receipt.get("schema_version") != "cce.continuity-receipt.v1"
3950
+ or receipt.get("payload_type") != _CONTINUITY_PAYLOAD_TYPE
3951
+ or receipt.get("audience") != "operator"
3952
+ or not isinstance(receipt.get("receipt_id"), str)
3953
+ or not receipt["receipt_id"].startswith("prf_")
3954
+ or not is_canonical_utc_timestamp(
3955
+ receipt.get("generated_at"))
3956
+ or not isinstance(receipt.get("privacy_notice"), str)
3957
+ or not receipt["privacy_notice"]
3958
+ or not isinstance(receipt.get("trust_limit"), str)
3959
+ or not receipt["trust_limit"]):
3960
+ return self._invalid_receipt(
3961
+ "unsupported schema, payload domain, or audience")
3962
+ try:
3963
+ expected = digest_obj({
3964
+ key: value for key, value in receipt.items()
3965
+ if key not in ("signature", "receipt_digest")
3966
+ })
3967
+ except (TypeError, ValueError, OverflowError, RecursionError):
3968
+ return self._invalid_receipt(
3969
+ "receipt is not finite canonical JSON")
3970
+ if receipt.get("receipt_digest") != expected:
3971
+ return self._invalid_receipt(
3972
+ "receipt digest does not cover its body")
3973
+ authentic, reason = self._receipt_authenticator_valid(receipt)
3974
+ if not authentic:
3975
+ return self._invalid_receipt(reason)
3976
+ try:
3977
+ semantic, reason = self._receipt_semantics_valid(project_id, receipt)
3978
+ except (KeyError, TypeError, ValueError, OverflowError):
3979
+ return self._invalid_receipt(
3980
+ "receipt semantic structure cannot be evaluated")
3981
+ if not semantic:
3982
+ return self._invalid_receipt(reason)
3983
+
3984
+ basis = receipt["basis"]
3985
+ live = self.continuity_check(
3986
+ project_id, _sign_receipt=False)["continuity_receipt"]
3987
+ live_basis = live["basis"]
3988
+ frontier_fields = {
3989
+ "tracked_ref", "current_head_sha", "policy_digest",
3990
+ "projection_digest", "decision_state_digest",
3991
+ "project_event_frontier",
3992
+ }
3993
+ changed = sorted(
3994
+ field for field in frontier_fields
3995
+ if basis.get(field) != live_basis.get(field))
3996
+ if receipt.get("decision") != live.get("decision"):
3997
+ changed.append("decision")
3998
+ if changed:
3999
+ return {
4000
+ "verdict": "AUTHENTIC_HISTORICAL",
4001
+ "current": False,
4002
+ "changed": changed,
4003
+ "reason": (
4004
+ "receipt is authentic but no longer describes the live "
4005
+ "semantic continuity frontier"),
4006
+ }
4007
+ return {
4008
+ "verdict": "CURRENT",
4009
+ "current": True,
4010
+ "changed": [],
4011
+ "reason": (
4012
+ "receipt authenticity, semantics, and live frontier match"),
4013
+ }
4014
+
4015
+ # ---------------------------------------------------------------- rebuild
4016
+
4017
+ @staticmethod
4018
+ def _projection_value(value, aliases: dict[str, str], *,
4019
+ anonymize_unknown_ids: bool = True):
4020
+ """Canonicalize semantic values while removing processor wall clocks."""
4021
+ if isinstance(value, dict):
4022
+ return {
4023
+ key: Engine._projection_value(
4024
+ item, aliases,
4025
+ anonymize_unknown_ids=anonymize_unknown_ids)
4026
+ for key, item in sorted(value.items())
4027
+ if key not in _VOLATILE_PROJECTION_KEYS
4028
+ }
4029
+ if isinstance(value, (list, tuple)):
4030
+ return [
4031
+ Engine._projection_value(
4032
+ item, aliases,
4033
+ anonymize_unknown_ids=anonymize_unknown_ids)
4034
+ for item in value
4035
+ ]
4036
+ if isinstance(value, str):
4037
+ if value in aliases:
4038
+ return aliases[value]
4039
+ if anonymize_unknown_ids and _INTERNAL_RANDOM_ID.fullmatch(value):
4040
+ return f"<{value.split('_', 1)[0]}-id>"
4041
+ return value
4042
+
4043
+ def _semantic_projection(
4044
+ self, project_id: str, *, _with_origins: bool = False) -> dict:
4045
+ """Canonical event-derived graph state, including edge semantics.
4046
+
4047
+ Random row ids, transaction clocks, and processor-generated wall
4048
+ clocks cannot survive a clean replay and are excluded. Typed state,
4049
+ authority, criticality, confidence, scope, causal edges, strengths,
4050
+ provenance events, and extractor identities are committed.
4051
+ """
4052
+ self._require_project(project_id)
4053
+ selected = []
4054
+ histories: dict[str, list[dict]] = {}
4055
+ for node in self.graph.current(
4056
+ project_id, tenant_id=self.tenant_id):
4057
+ history = self.graph.history(
4058
+ node["node_id"], tenant_id=self.tenant_id,
4059
+ project_id=project_id)
4060
+ if any(version.get("event_id") for version in history):
4061
+ selected.append(node)
4062
+ histories[node["node_id"]] = history
4063
+
4064
+ aliases: dict[str, str] = {}
4065
+ for node in selected:
4066
+ stable_key = node["data"].get("stable_key")
4067
+ if stable_key:
4068
+ aliases[node["node_id"]] = (
4069
+ f"stable:{node['entity_type']}:{stable_key}")
4070
+ elif node["entity_type"] == "event":
4071
+ aliases[node["node_id"]] = f"event:{node['node_id']}"
4072
+ elif node["entity_type"] == "project":
4073
+ aliases[node["node_id"]] = f"project:{project_id}"
4074
+
4075
+ for node in selected:
4076
+ if node["node_id"] in aliases:
4077
+ continue
4078
+ event_ids = sorted({
4079
+ version["event_id"]
4080
+ for version in histories[node["node_id"]]
4081
+ if version.get("event_id")
4082
+ })
4083
+ hint = {
4084
+ "entity_type": node["entity_type"],
4085
+ "events": event_ids,
4086
+ "data": self._projection_value(node["data"], aliases),
4087
+ }
4088
+ aliases[node["node_id"]] = (
4089
+ f"derived:{node['entity_type']}:{digest_obj(hint)}")
4090
+
4091
+ nodes = []
4092
+ node_origins = {}
4093
+ for node in selected:
4094
+ history = histories[node["node_id"]]
4095
+ event_ids = sorted({
4096
+ version["event_id"] for version in history
4097
+ if version.get("event_id")
4098
+ })
4099
+ nodes.append({
4100
+ "id": aliases[node["node_id"]],
4101
+ "entity_type": node["entity_type"],
4102
+ "status": node.get("status"),
4103
+ "criticality": node.get("criticality"),
4104
+ "authority": node.get("authority"),
4105
+ "confidence": node.get("confidence"),
4106
+ "scope": self._projection_value(node.get("scope"), aliases),
4107
+ "data": self._projection_value(node["data"], aliases),
4108
+ "event_ids": event_ids,
4109
+ "processors": sorted({
4110
+ (
4111
+ str(version.get("extractor") or ""),
4112
+ str(version.get("extractor_version") or ""),
4113
+ )
4114
+ for version in history
4115
+ if version.get("extractor")
4116
+ }),
4117
+ })
4118
+ if _with_origins:
4119
+ node_origins[aliases[node["node_id"]]] = {
4120
+ "event_ids": event_ids,
4121
+ # A runtime-created identity later touched by an event is
4122
+ # a hybrid projection. Retention-aware replay cannot
4123
+ # prove how its runtime prefix should have been rebuilt.
4124
+ "event_only": bool(history) and all(
4125
+ version.get("event_id") for version in history),
4126
+ }
4127
+
4128
+ edges = []
4129
+ edge_origins: dict[str, list[dict]] = {}
4130
+ for edge in self.graph.current_edges(
4131
+ project_id, event_derived_only=True,
4132
+ tenant_id=self.tenant_id):
4133
+ if edge["src_id"] not in aliases or edge["dst_id"] not in aliases:
4134
+ continue
4135
+ rendered = {
4136
+ "edge_type": edge["edge_type"],
4137
+ "src": aliases[edge["src_id"]],
4138
+ "dst": aliases[edge["dst_id"]],
4139
+ "strength": edge["strength"],
4140
+ "data": self._projection_value(edge.get("data"), aliases),
4141
+ "event_id": edge.get("event_id"),
4142
+ }
4143
+ edges.append(rendered)
4144
+ if _with_origins:
4145
+ history = self.store._conn.execute(
4146
+ "SELECT event_id FROM edges WHERE edge_id = ? "
4147
+ "ORDER BY version", (edge["edge_id"],)).fetchall()
4148
+ edge_origins.setdefault(
4149
+ canonical_json(rendered), []).append({
4150
+ "event_ids": sorted({
4151
+ row["event_id"] for row in history
4152
+ if row["event_id"]}),
4153
+ "event_only": bool(history) and all(
4154
+ row["event_id"] for row in history),
4155
+ })
4156
+ nodes.sort(key=canonical_json)
4157
+ edges.sort(key=canonical_json)
4158
+ projection = {
4159
+ "schema_version": "cce.semantic-projection.v1",
4160
+ "project_id": project_id,
4161
+ "nodes": nodes,
4162
+ "edges": edges,
4163
+ }
4164
+ if _with_origins:
4165
+ projection["_origins"] = {
4166
+ "nodes": node_origins, "edges": edge_origins}
4167
+ return projection
4168
+
4169
+ @serialized_access
4170
+ def projection_fingerprint(self, project_id: str) -> str:
4171
+ """Digest the complete event-derived semantic projection (CCG-006)."""
4172
+ return digest_obj(self._semantic_projection(project_id))
4173
+
4174
+ @serialized_access
4175
+ def replay_completeness(self, project_id: str) -> dict:
4176
+ """Whether the log still holds what a rebuild would need (CCG-006).
4177
+
4178
+ Retention (SEC-006) nulls raw payloads past the window while keeping
4179
+ every digest, id and timestamp. That is deliberate, and it means the
4180
+ projection can no longer be rebuilt from the log alone: replay of a
4181
+ cleared event extracts nothing. Before this existed, the first
4182
+ retention sweep turned `cce-engine rebuild` into a permanent DIVERGES with
4183
+ no indication why — reporting a designed trade-off as if it were
4184
+ corruption or a processor bug, which are the opposite diagnosis
4185
+ (ADR-063).
4186
+
4187
+ An event that never carried a payload is not redacted; its digest is
4188
+ the digest of the empty string, which is how the two are told apart.
4189
+ """
4190
+ empty = sha256_hex("")
4191
+ self._require_project(project_id)
4192
+ total = redacted = 0
4193
+ for row in self.store.events(
4194
+ project_id, tenant_id=self.tenant_id):
4195
+ total += 1
4196
+ if row["payload"] is None and row["payload_digest"] != empty:
4197
+ redacted += 1
4198
+ return {
4199
+ "events": total,
4200
+ "redacted_payloads": redacted,
4201
+ "replayable": redacted == 0,
4202
+ "note": None if redacted == 0 else
4203
+ f"{redacted} of {total} event payloads were cleared by the"
4204
+ f" retention policy; the projection cannot be rebuilt from"
4205
+ f" the log alone, and this is permanent by design",
4206
+ }
4207
+
4208
+ @serialized_access
4209
+ def replay_agrees_where_replayable(self, project_id: str,
4210
+ fresh: "Engine") -> dict:
4211
+ """Compare every semantic node and edge a retained suffix produced.
4212
+
4213
+ A rebuild that lost its prefix cannot match a full fingerprint, but
4214
+ every node and causal edge it DID produce must still agree with the
4215
+ live projection, including authority, status, criticality, confidence,
4216
+ scope, extractor identity, edge type, strength, and edge data.
4217
+ That distinguishes 'retention removed the inputs' from 'retention
4218
+ removed the inputs AND something else is also wrong'.
4219
+ """
4220
+ live = self._semantic_projection(project_id, _with_origins=True)
4221
+ origins = live.pop("_origins")
4222
+ replayed = fresh._semantic_projection(project_id)
4223
+ live_nodes = {node["id"]: node for node in live["nodes"]}
4224
+ replayed_nodes = {
4225
+ node["id"]: node for node in replayed["nodes"]}
4226
+ empty = sha256_hex("")
4227
+ replayable_event_ids = {
4228
+ event["event_id"] for event in self.store.events(
4229
+ project_id, tenant_id=self.tenant_id)
4230
+ if event["payload"] is not None
4231
+ or event["payload_digest"] == empty
4232
+ }
4233
+ disagreements = []
4234
+ checked_nodes = 0
4235
+ for node in replayed["nodes"]:
4236
+ semantic_id = node["id"]
4237
+ expected = live_nodes.get(semantic_id)
4238
+ if expected is None:
4239
+ disagreements.append({
4240
+ "kind": "node", "semantic_id": semantic_id,
4241
+ "issue": "replayed but not live"})
4242
+ continue
4243
+ checked_nodes += 1
4244
+ if node != expected:
4245
+ disagreements.append({
4246
+ "kind": "node", "semantic_id": semantic_id,
4247
+ "issue": "replayed differently",
4248
+ "live": expected, "replayed": node})
4249
+
4250
+ # The reverse direction matters whenever retention has removed an
4251
+ # unrelated prefix: otherwise an extra live projection row sourced by
4252
+ # a fully retained event disappears from the comparison. Restrict
4253
+ # this claim to event-only identities whose entire source set remains
4254
+ # replayable; runtime/event hybrids are outside the rebuild contract.
4255
+ for semantic_id, node in live_nodes.items():
4256
+ if semantic_id in replayed_nodes:
4257
+ continue
4258
+ origin = origins["nodes"].get(semantic_id) or {}
4259
+ event_ids = set(origin.get("event_ids") or [])
4260
+ if (not origin.get("event_only") or not event_ids
4261
+ or not event_ids <= replayable_event_ids):
4262
+ continue
4263
+ checked_nodes += 1
4264
+ disagreements.append({
4265
+ "kind": "node", "semantic_id": semantic_id,
4266
+ "issue": "live but not replayed from retained events",
4267
+ "live": node,
4268
+ })
4269
+
4270
+ live_edges = Counter(canonical_json(edge) for edge in live["edges"])
4271
+ replayed_edges = Counter(
4272
+ canonical_json(edge) for edge in replayed["edges"])
4273
+ checked_edges = sum(replayed_edges.values())
4274
+ for encoded, count in (replayed_edges - live_edges).items():
4275
+ disagreements.append({
4276
+ "kind": "edge", "issue": "replayed but not live or differs",
4277
+ "count": count, "replayed": strict_json_loads(encoded)})
4278
+ for encoded, count in (live_edges - replayed_edges).items():
4279
+ eligible = [
4280
+ origin for origin in origins["edges"].get(encoded, [])
4281
+ if origin.get("event_only")
4282
+ and set(origin.get("event_ids") or [])
4283
+ and set(origin.get("event_ids") or []) <= replayable_event_ids
4284
+ ]
4285
+ eligible_count = min(count, len(eligible))
4286
+ if not eligible_count:
4287
+ continue
4288
+ checked_edges += eligible_count
4289
+ disagreements.append({
4290
+ "kind": "edge",
4291
+ "issue": "live but not replayed from retained events",
4292
+ "count": eligible_count, "live": strict_json_loads(encoded)})
4293
+ return {
4294
+ "compared": checked_nodes + checked_edges,
4295
+ "compared_nodes": checked_nodes,
4296
+ "compared_edges": checked_edges,
4297
+ "disagreements": disagreements,
4298
+ "agrees": not disagreements,
4299
+ }
4300
+
4301
+ @serialized_access
4302
+ def rebuild_projection(self, project_id: str) -> "Engine":
4303
+ """Replay the canonical event log into a fresh in-memory engine."""
4304
+ fresh = Engine(":memory:", tenant_id=self.tenant_id, signer=self.signer)
4305
+ project = self._require_project(project_id)
4306
+ fresh.create_project(
4307
+ project["data"].get("name", project_id),
4308
+ repository=project["data"].get("repository"),
4309
+ repository_id=project["data"].get("repository_id"),
4310
+ github_installation_id=project["data"].get(
4311
+ "github_installation_id"),
4312
+ capture_mode=project["data"].get("capture_mode", "redacted"),
4313
+ config=self.policy.project_config(project_id),
4314
+ project_id=project_id)
4315
+ for event in self.store.events(
4316
+ project_id, tenant_id=self.tenant_id):
4317
+ with fresh.store.write_scope():
4318
+ fresh.store._conn.execute(
4319
+ "INSERT OR IGNORE INTO events (event_id, tenant_id, project_id,"
4320
+ " source_type, source_id, idempotency_key, observed_at,"
4321
+ " recorded_at, valid_from, valid_to, actor_type, actor_id,"
4322
+ " authority, sensitivity, capture_mode, payload_digest,"
4323
+ " stored_payload_digest, payload, schema_version, seq)"
4324
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
4325
+ (event["event_id"], event["tenant_id"], event["project_id"],
4326
+ event["source_type"], event["source_id"],
4327
+ event["idempotency_key"], event["observed_at"],
4328
+ event["recorded_at"], event["valid_from"], event["valid_to"],
4329
+ event["actor_type"], event["actor_id"], event["authority"],
4330
+ event["sensitivity"], event["capture_mode"],
4331
+ event["payload_digest"], event["stored_payload_digest"],
4332
+ canonical_json(event["payload"])
4333
+ if event["payload"] is not None else None,
4334
+ event["schema_version"], event["seq"]))
4335
+ # Replay must be at least as tolerant as live ingestion. Live
4336
+ # processing quarantines an event whose extraction raises and
4337
+ # carries on (ADR-036); if replay instead aborted, a single
4338
+ # quarantined event would make the projection permanently
4339
+ # unrebuildable and CCG-006 unverifiable from then on.
4340
+ try:
4341
+ with fresh.store.transaction():
4342
+ # The replay store has its own chain metadata. Process
4343
+ # the exact row canonical to that store rather than the
4344
+ # source store's otherwise-identical row.
4345
+ fresh.process_event(fresh.store.get_event(
4346
+ event["event_id"], tenant_id=self.tenant_id,
4347
+ project_id=project_id))
4348
+ fresh.store.mark_processed(
4349
+ event["event_id"], PROCESSOR_VERSION, "ok")
4350
+ except Exception as exc:
4351
+ fresh.store.mark_processed(
4352
+ event["event_id"], PROCESSOR_VERSION,
4353
+ "quarantined", repr(exc))
4354
+ return fresh