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.
- causal_continuity_engine/__init__.py +21 -0
- causal_continuity_engine/api.py +1264 -0
- causal_continuity_engine/capabilities.py +548 -0
- causal_continuity_engine/capsule.py +996 -0
- causal_continuity_engine/cli.py +1338 -0
- causal_continuity_engine/core.py +468 -0
- causal_continuity_engine/engine.py +4354 -0
- causal_continuity_engine/evidence.py +971 -0
- causal_continuity_engine/extraction.py +263 -0
- causal_continuity_engine/github.py +657 -0
- causal_continuity_engine/graph.py +809 -0
- causal_continuity_engine/invalidation.py +1040 -0
- causal_continuity_engine/lamport.py +306 -0
- causal_continuity_engine/learning.py +466 -0
- causal_continuity_engine/memory.py +610 -0
- causal_continuity_engine/ontology.py +108 -0
- causal_continuity_engine/partial.py +309 -0
- causal_continuity_engine/policy.py +940 -0
- causal_continuity_engine/proof.py +920 -0
- causal_continuity_engine/py.typed +1 -0
- causal_continuity_engine/redaction.py +94 -0
- causal_continuity_engine/resume.py +653 -0
- causal_continuity_engine/store.py +1174 -0
- causal_continuity_engine/verifiers.py +1006 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/SPEC.md +515 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/__init__.py +0 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/continuitybench/__init__.py +0 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/continuitybench/run.py +146 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/continuitybench/scenarios.py +544 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.anchor.v1.json +62 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.capsule.v1.json +112 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.continuity-receipt.v1.json +315 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.event.v1.json +73 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.proof-predicate.v1.json +35 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.proof.v1.json +242 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.recovery.v1.json +109 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.resume.v1.json +332 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/__init__.py +1 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/schema_validation.py +61 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_api_contract.py +1051 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_cli.py +664 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_conformance.py +758 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_distribution_environment.py +26 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_engine_e2e.py +286 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_github_redaction.py +193 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_instrument_validation.py +535 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_invalidation_resume.py +205 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_learning_partial_api.py +380 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_memory_extraction.py +151 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions.py +406 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round10_release.py +842 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round11_deep_integrity.py +831 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round12_release_blockers.py +206 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round13_jcs.py +212 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round14_state_integrity.py +1056 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round15_capsule_invalidation_github.py +251 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round15_public_boundaries.py +561 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round15_storage_boundaries.py +707 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round16_cli_api_core_capabilities.py +285 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round2.py +388 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round3.py +490 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round4.py +557 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round5.py +583 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round6.py +559 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round7.py +579 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_boundaries.py +558 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_engine.py +843 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_portability.py +2250 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_state.py +705 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round9_integrity.py +885 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round9_scope_freshness.py +179 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_release_boundary_validation.py +676 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_release_controls.py +840 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_sqlite_lifecycle.py +52 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_store_graph.py +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_trust.py +398 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/action_intent_wrong_type.json +170 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/actor_wrong_type.json +170 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/created_at_invalid_calendar_date.json +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/created_at_noncanonical_offset.json +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_authoritative_failure.json +114 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_authoritative_pass.json +107 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_no_results.json +99 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_self_asserted_pass.json +105 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/generate.py +498 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/incomplete_failed_check.json +168 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/index.json +240 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/input_item_wrong_type.json +163 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/invalid_result_value.json +170 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/lamport_fingerprint_spoof.json +1458 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/lamport_no_registry.json +1454 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/lamport_truncated_key.json +1454 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/missing_required_field.json +157 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/non_binary64_integer.json +174 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/policy_decision_wrong_type.json +146 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/retry_cannot_launder_a_failure.json +197 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/self_asserted_cannot_satisfy.json +173 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/self_assertion_cannot_poison_authoritative_pass.json +194 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/status_contradicts_contents.json +173 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/summary_contradicts_contents.json +170 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/tamper_statement.json +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/tamper_statement_resealed.json +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/task_id_under_unrelated_field.json +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unbound_task.json +165 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_action_intent_field.json +173 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_algorithm.json +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_schema_version.json +170 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_top_level_field.json +175 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unverified_forged_body_no_key.json +170 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unverified_hmac_no_key.json +170 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_hmac.json +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_hmac_jcs_edges.json +186 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_hmac_unicode.json +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_lamport.json +1458 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/wrong_project.json +173 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/verifiers/__init__.py +0 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/verifiers/verify_proof.py +1109 -0
- causal_continuity_engine-0.1.0.dist-info/METADATA +720 -0
- causal_continuity_engine-0.1.0.dist-info/RECORD +124 -0
- causal_continuity_engine-0.1.0.dist-info/WHEEL +5 -0
- causal_continuity_engine-0.1.0.dist-info/entry_points.txt +2 -0
- causal_continuity_engine-0.1.0.dist-info/licenses/LICENSE.txt +202 -0
- causal_continuity_engine-0.1.0.dist-info/licenses/NOTICE +6 -0
- 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
|