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,996 @@
|
|
|
1
|
+
"""Session Capsules: signed, portable, model-neutral state (MIG-003..005, 008).
|
|
2
|
+
|
|
3
|
+
A capsule carries observable state only — packets, decisions, assumptions,
|
|
4
|
+
environment, lineage. Hidden chain-of-thought is structurally excluded
|
|
5
|
+
(MIG-008/ADR-005): known hidden-reasoning keys are stripped defensively at
|
|
6
|
+
export, and the capsule schema has no field for them.
|
|
7
|
+
|
|
8
|
+
Import verifies schema version, content digests, and signature (tamper
|
|
9
|
+
detection), then runs a deterministic challenge step (MIG-005) whose result
|
|
10
|
+
gates autonomy above level 1.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import copy
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
from .core import (
|
|
20
|
+
digest_obj,
|
|
21
|
+
is_canonical_utc_timestamp,
|
|
22
|
+
is_public_identifier,
|
|
23
|
+
new_id,
|
|
24
|
+
utcnow,
|
|
25
|
+
validate_human_text,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
CAPSULE_SCHEMA = "cce.capsule.v1"
|
|
29
|
+
|
|
30
|
+
_HIDDEN_KEYS = {"chain_of_thought", "hidden_reasoning", "reasoning_trace",
|
|
31
|
+
"internal_monologue", "scratchpad", "thinking"}
|
|
32
|
+
|
|
33
|
+
_CAPSULE_ID = re.compile(r"^cap_[0-9a-f]{24}$")
|
|
34
|
+
_PACKET_ID = re.compile(r"^rsp_[0-9a-f]{24}$")
|
|
35
|
+
_SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
36
|
+
_HEX_256 = re.compile(r"^[0-9a-f]{64}$")
|
|
37
|
+
|
|
38
|
+
_PACKET_REQUIRED = {
|
|
39
|
+
"schema_version", "packet_id", "generated_at", "project_state_at",
|
|
40
|
+
"project_state_basis", "target", "mission", "authority",
|
|
41
|
+
"accepted_decisions", "verified_progress", "invalidations",
|
|
42
|
+
"assumptions", "open_work", "environment", "trust",
|
|
43
|
+
"continuity_lineage", "evidence_index", "evidence_coverage",
|
|
44
|
+
"omissions", "recent_context", "token_estimate", "packet_digest",
|
|
45
|
+
}
|
|
46
|
+
_PACKET_ALLOWED = _PACKET_REQUIRED | {"signature"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _nonempty(value) -> bool:
|
|
50
|
+
return isinstance(value, str) and bool(value.strip())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _safe_capsule_text(value, *, field: str) -> str:
|
|
54
|
+
if not isinstance(value, str) or not value.strip():
|
|
55
|
+
raise CapsuleError(f"{field} must be a non-empty string")
|
|
56
|
+
try:
|
|
57
|
+
return validate_human_text(value, field=field)
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
raise CapsuleError(str(exc)) from None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _timestamp(value) -> bool:
|
|
63
|
+
return is_canonical_utc_timestamp(value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _finite_probability(value) -> bool:
|
|
67
|
+
return (
|
|
68
|
+
not isinstance(value, bool)
|
|
69
|
+
and isinstance(value, (int, float))
|
|
70
|
+
and math.isfinite(value)
|
|
71
|
+
and 0 <= value <= 1
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _finite_number(value) -> bool:
|
|
76
|
+
return (
|
|
77
|
+
not isinstance(value, bool)
|
|
78
|
+
and isinstance(value, (int, float))
|
|
79
|
+
and math.isfinite(value)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _string_or_none(value) -> bool:
|
|
84
|
+
return value is None or isinstance(value, str)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _string_list(value) -> bool:
|
|
88
|
+
return (
|
|
89
|
+
isinstance(value, list)
|
|
90
|
+
and all(_nonempty(item) for item in value)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _summary(value) -> bool:
|
|
95
|
+
required = {
|
|
96
|
+
"node_id", "entity_type", "status", "criticality", "confidence",
|
|
97
|
+
"authority", "summary",
|
|
98
|
+
}
|
|
99
|
+
return (
|
|
100
|
+
isinstance(value, dict)
|
|
101
|
+
and set(value) == required
|
|
102
|
+
and is_public_identifier(value.get("node_id"))
|
|
103
|
+
and _nonempty(value.get("entity_type"))
|
|
104
|
+
and _string_or_none(value.get("status"))
|
|
105
|
+
and _string_or_none(value.get("criticality"))
|
|
106
|
+
and (
|
|
107
|
+
value.get("confidence") is None
|
|
108
|
+
or _finite_probability(value.get("confidence"))
|
|
109
|
+
)
|
|
110
|
+
and _string_or_none(value.get("authority"))
|
|
111
|
+
and isinstance(value.get("summary"), str)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _summary_list(value) -> bool:
|
|
116
|
+
return isinstance(value, list) and all(_summary(item) for item in value)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _invalidation(value) -> bool:
|
|
120
|
+
required = {
|
|
121
|
+
"invalidation_id", "status", "trigger_type", "target", "severity",
|
|
122
|
+
"affected_count", "minimal_causal_path", "recommended_action",
|
|
123
|
+
}
|
|
124
|
+
affected = value.get("affected_count") if isinstance(value, dict) else None
|
|
125
|
+
path = value.get("minimal_causal_path") if isinstance(value, dict) else None
|
|
126
|
+
return (
|
|
127
|
+
isinstance(value, dict)
|
|
128
|
+
and set(value) == required
|
|
129
|
+
and is_public_identifier(value.get("invalidation_id"))
|
|
130
|
+
and _string_or_none(value.get("status"))
|
|
131
|
+
and _string_or_none(value.get("trigger_type"))
|
|
132
|
+
and _string_or_none(value.get("target"))
|
|
133
|
+
and _string_or_none(value.get("severity"))
|
|
134
|
+
and (
|
|
135
|
+
affected is None
|
|
136
|
+
or not isinstance(affected, bool)
|
|
137
|
+
and isinstance(affected, int)
|
|
138
|
+
and affected >= 0
|
|
139
|
+
)
|
|
140
|
+
and (
|
|
141
|
+
path is None
|
|
142
|
+
or isinstance(path, list)
|
|
143
|
+
and all(is_public_identifier(item) for item in path)
|
|
144
|
+
)
|
|
145
|
+
and _string_or_none(value.get("recommended_action"))
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _evidence_index_item(value) -> bool:
|
|
150
|
+
return (
|
|
151
|
+
isinstance(value, dict)
|
|
152
|
+
and set(value) == {"claim_id", "evidence_ids"}
|
|
153
|
+
and is_public_identifier(value.get("claim_id"))
|
|
154
|
+
and _string_list(value.get("evidence_ids"))
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _recent_context_item(value) -> bool:
|
|
159
|
+
return (
|
|
160
|
+
isinstance(value, dict)
|
|
161
|
+
and set(value) == {"node_id", "entity_type", "summary", "score"}
|
|
162
|
+
and is_public_identifier(value.get("node_id"))
|
|
163
|
+
and _nonempty(value.get("entity_type"))
|
|
164
|
+
and isinstance(value.get("summary"), str)
|
|
165
|
+
and _finite_number(value.get("score"))
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _strip_hidden(obj):
|
|
170
|
+
if isinstance(obj, dict):
|
|
171
|
+
return {k: _strip_hidden(v) for k, v in obj.items() if k not in _HIDDEN_KEYS}
|
|
172
|
+
if isinstance(obj, list):
|
|
173
|
+
return [_strip_hidden(v) for v in obj]
|
|
174
|
+
return obj
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _contains_hidden(obj) -> bool:
|
|
178
|
+
"""Whether a capsule carries a field the portable schema forbids."""
|
|
179
|
+
if isinstance(obj, dict):
|
|
180
|
+
return any(k in _HIDDEN_KEYS or _contains_hidden(v)
|
|
181
|
+
for k, v in obj.items())
|
|
182
|
+
if isinstance(obj, list):
|
|
183
|
+
return any(_contains_hidden(v) for v in obj)
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class CapsuleError(Exception):
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class CapsuleManager:
|
|
192
|
+
def __init__(
|
|
193
|
+
self, store, graph, composer, policy=None, tenant_id=None,
|
|
194
|
+
state_basis_provider=None):
|
|
195
|
+
self.store = store
|
|
196
|
+
self.graph = graph
|
|
197
|
+
self.composer = composer
|
|
198
|
+
self.policy = policy
|
|
199
|
+
self.tenant_id = tenant_id
|
|
200
|
+
self.state_basis_provider = state_basis_provider
|
|
201
|
+
|
|
202
|
+
def _state_basis(self, project_id: str) -> dict | None:
|
|
203
|
+
if self.state_basis_provider is None:
|
|
204
|
+
return None
|
|
205
|
+
basis = self.state_basis_provider(project_id)
|
|
206
|
+
if not isinstance(basis, dict):
|
|
207
|
+
raise CapsuleError("capsule state-basis provider returned malformed state")
|
|
208
|
+
return basis
|
|
209
|
+
|
|
210
|
+
def _require_project(self, tenant_id: str, project_id: str) -> dict:
|
|
211
|
+
if self.tenant_id is not None and tenant_id != self.tenant_id:
|
|
212
|
+
raise CapsuleError(
|
|
213
|
+
f"capsule tenant {tenant_id!r} is not this engine's tenant")
|
|
214
|
+
try:
|
|
215
|
+
return self.graph.get(
|
|
216
|
+
project_id, tenant_id=tenant_id, project_id=project_id,
|
|
217
|
+
entity_type="project")
|
|
218
|
+
except KeyError as exc:
|
|
219
|
+
raise CapsuleError(
|
|
220
|
+
f"capsule project {tenant_id}/{project_id} does not exist") from exc
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _authenticator_valid(capsule: dict, signer) -> bool:
|
|
224
|
+
signature = capsule.get("signature")
|
|
225
|
+
if not isinstance(signature, dict):
|
|
226
|
+
return False
|
|
227
|
+
if signature.get("algorithm") != getattr(signer, "algorithm", None):
|
|
228
|
+
return False
|
|
229
|
+
try:
|
|
230
|
+
if not signer.verify(capsule):
|
|
231
|
+
return False
|
|
232
|
+
except (KeyError, TypeError, ValueError):
|
|
233
|
+
return False
|
|
234
|
+
if bool(getattr(signer, "self_authenticating", False)):
|
|
235
|
+
return True
|
|
236
|
+
derive = getattr(signer, "derive_fingerprint", None)
|
|
237
|
+
registry = getattr(signer, "registered_fingerprints", None)
|
|
238
|
+
if not callable(derive) or registry is None:
|
|
239
|
+
return False
|
|
240
|
+
actual = derive(signature)
|
|
241
|
+
return bool(
|
|
242
|
+
actual
|
|
243
|
+
and signature.get("fingerprint") == actual
|
|
244
|
+
and actual in set(registry))
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _validate_signature_shape(signature, *, label: str) -> None:
|
|
248
|
+
if not isinstance(signature, dict):
|
|
249
|
+
raise CapsuleError(f"{label} signature must be an object")
|
|
250
|
+
algorithm = signature.get("algorithm")
|
|
251
|
+
if algorithm == "hmac-sha256":
|
|
252
|
+
if set(signature) != {"key_id", "algorithm", "value"}:
|
|
253
|
+
raise CapsuleError(f"{label} HMAC signature shape is malformed")
|
|
254
|
+
if (not _nonempty(signature.get("key_id"))
|
|
255
|
+
or not isinstance(signature.get("value"), str)
|
|
256
|
+
or _HEX_256.fullmatch(signature["value"]) is None):
|
|
257
|
+
raise CapsuleError(f"{label} HMAC signature fields are malformed")
|
|
258
|
+
return
|
|
259
|
+
if algorithm == "lamport-sha256/1":
|
|
260
|
+
required = {
|
|
261
|
+
"key_id", "algorithm", "fingerprint", "public_key", "value"}
|
|
262
|
+
if set(signature) != required:
|
|
263
|
+
raise CapsuleError(f"{label} Lamport signature shape is malformed")
|
|
264
|
+
public = signature.get("public_key")
|
|
265
|
+
value = signature.get("value")
|
|
266
|
+
valid_public = (
|
|
267
|
+
isinstance(public, list)
|
|
268
|
+
and len(public) == 256
|
|
269
|
+
and all(
|
|
270
|
+
isinstance(pair, list)
|
|
271
|
+
and len(pair) == 2
|
|
272
|
+
and all(
|
|
273
|
+
isinstance(block, str)
|
|
274
|
+
and _HEX_256.fullmatch(block) is not None
|
|
275
|
+
for block in pair)
|
|
276
|
+
for pair in public)
|
|
277
|
+
)
|
|
278
|
+
valid_value = (
|
|
279
|
+
isinstance(value, list)
|
|
280
|
+
and len(value) == 256
|
|
281
|
+
and all(
|
|
282
|
+
isinstance(block, str)
|
|
283
|
+
and _HEX_256.fullmatch(block) is not None
|
|
284
|
+
for block in value)
|
|
285
|
+
)
|
|
286
|
+
if (not _nonempty(signature.get("key_id"))
|
|
287
|
+
or not isinstance(signature.get("fingerprint"), str)
|
|
288
|
+
or _SHA256.fullmatch(signature["fingerprint"]) is None
|
|
289
|
+
or not valid_public or not valid_value):
|
|
290
|
+
raise CapsuleError(f"{label} Lamport signature fields are malformed")
|
|
291
|
+
return
|
|
292
|
+
raise CapsuleError(f"{label} signature algorithm is unsupported")
|
|
293
|
+
|
|
294
|
+
@staticmethod
|
|
295
|
+
def _validate_resume_packet(packet) -> None:
|
|
296
|
+
if not isinstance(packet, dict):
|
|
297
|
+
raise CapsuleError("capsule resume_packet must be an object")
|
|
298
|
+
missing = _PACKET_REQUIRED - set(packet)
|
|
299
|
+
unknown = set(packet) - _PACKET_ALLOWED
|
|
300
|
+
if missing or unknown:
|
|
301
|
+
raise CapsuleError(
|
|
302
|
+
"capsule resume_packet has missing or unknown fields: "
|
|
303
|
+
f"missing={sorted(missing)}, unknown={sorted(unknown)}")
|
|
304
|
+
if packet.get("schema_version") != "cce.resume.v1":
|
|
305
|
+
raise CapsuleError("capsule resume_packet schema_version is unsupported")
|
|
306
|
+
packet_id = packet.get("packet_id")
|
|
307
|
+
if not isinstance(packet_id, str) or _PACKET_ID.fullmatch(packet_id) is None:
|
|
308
|
+
raise CapsuleError("capsule resume_packet packet_id is malformed")
|
|
309
|
+
if not _timestamp(packet.get("generated_at")):
|
|
310
|
+
raise CapsuleError("capsule resume_packet generated_at is malformed")
|
|
311
|
+
watermark = packet.get("project_state_at")
|
|
312
|
+
if watermark is not None and not _nonempty(watermark):
|
|
313
|
+
raise CapsuleError("capsule resume_packet project_state_at is malformed")
|
|
314
|
+
|
|
315
|
+
basis = packet.get("project_state_basis")
|
|
316
|
+
if basis is not None:
|
|
317
|
+
if not isinstance(basis, dict) or set(basis) != {
|
|
318
|
+
"event_seq", "control_basis_digest", "artifact_inputs"}:
|
|
319
|
+
raise CapsuleError(
|
|
320
|
+
"capsule resume_packet project_state_basis is malformed")
|
|
321
|
+
event_seq = basis.get("event_seq")
|
|
322
|
+
artifacts = basis.get("artifact_inputs")
|
|
323
|
+
if (isinstance(event_seq, bool) or not isinstance(event_seq, int)
|
|
324
|
+
or event_seq < 0
|
|
325
|
+
or not isinstance(basis.get("control_basis_digest"), str)
|
|
326
|
+
or _SHA256.fullmatch(
|
|
327
|
+
basis["control_basis_digest"]) is None
|
|
328
|
+
or not isinstance(artifacts, dict)
|
|
329
|
+
or any(
|
|
330
|
+
not _nonempty(path)
|
|
331
|
+
or not isinstance(digest, str)
|
|
332
|
+
or _SHA256.fullmatch(digest) is None
|
|
333
|
+
for path, digest in artifacts.items())):
|
|
334
|
+
raise CapsuleError(
|
|
335
|
+
"capsule resume_packet project_state_basis is malformed")
|
|
336
|
+
|
|
337
|
+
mission = packet.get("mission")
|
|
338
|
+
authority = packet.get("authority")
|
|
339
|
+
assumptions = packet.get("assumptions")
|
|
340
|
+
open_work = packet.get("open_work")
|
|
341
|
+
trust = packet.get("trust")
|
|
342
|
+
lineage = packet.get("continuity_lineage")
|
|
343
|
+
if (not isinstance(packet.get("target"), dict)
|
|
344
|
+
or not isinstance(mission, dict)
|
|
345
|
+
or set(mission) != {
|
|
346
|
+
"project", "objective", "target", "pinned_control_state",
|
|
347
|
+
"retired_control_state"}
|
|
348
|
+
or not _nonempty(mission.get("project"))
|
|
349
|
+
or not isinstance(mission.get("objective"), str)
|
|
350
|
+
or not isinstance(mission.get("target"), dict)
|
|
351
|
+
or not _summary_list(mission.get("pinned_control_state"))
|
|
352
|
+
or not _summary_list(mission.get("retired_control_state"))):
|
|
353
|
+
raise CapsuleError("capsule resume_packet mission shape is malformed")
|
|
354
|
+
|
|
355
|
+
precedence = (
|
|
356
|
+
authority.get("instruction_precedence")
|
|
357
|
+
if isinstance(authority, dict) else None
|
|
358
|
+
)
|
|
359
|
+
if (not isinstance(authority, dict)
|
|
360
|
+
or set(authority) != {
|
|
361
|
+
"instruction_precedence", "active_requirements",
|
|
362
|
+
"active_constraints"}
|
|
363
|
+
or not _string_list(precedence)
|
|
364
|
+
or len(precedence) != len(set(precedence))
|
|
365
|
+
or not _summary_list(authority.get("active_requirements"))
|
|
366
|
+
or not _summary_list(authority.get("active_constraints"))):
|
|
367
|
+
raise CapsuleError("capsule resume_packet authority shape is malformed")
|
|
368
|
+
|
|
369
|
+
if (not isinstance(assumptions, dict)
|
|
370
|
+
or set(assumptions) != {"active", "uncertain"}
|
|
371
|
+
or not _summary_list(assumptions.get("active"))
|
|
372
|
+
or not _summary_list(assumptions.get("uncertain"))):
|
|
373
|
+
raise CapsuleError("capsule resume_packet assumptions shape is malformed")
|
|
374
|
+
|
|
375
|
+
next_safe = (
|
|
376
|
+
open_work.get("next_safe_action")
|
|
377
|
+
if isinstance(open_work, dict) else None
|
|
378
|
+
)
|
|
379
|
+
next_safe_valid = _summary(next_safe) or (
|
|
380
|
+
isinstance(next_safe, dict)
|
|
381
|
+
and set(next_safe) == {"summary"}
|
|
382
|
+
and isinstance(next_safe.get("summary"), str)
|
|
383
|
+
)
|
|
384
|
+
if (not isinstance(open_work, dict)
|
|
385
|
+
or set(open_work) != {"tasks", "blockers", "next_safe_action"}
|
|
386
|
+
or not _summary_list(open_work.get("tasks"))
|
|
387
|
+
or not _summary_list(open_work.get("blockers"))
|
|
388
|
+
or not next_safe_valid):
|
|
389
|
+
raise CapsuleError("capsule resume_packet open_work shape is malformed")
|
|
390
|
+
|
|
391
|
+
summary_fields = ("accepted_decisions", "verified_progress")
|
|
392
|
+
if any(not _summary_list(packet.get(field)) for field in summary_fields):
|
|
393
|
+
raise CapsuleError(
|
|
394
|
+
"capsule resume_packet decision/progress summaries are malformed")
|
|
395
|
+
invalidations = packet.get("invalidations")
|
|
396
|
+
if (not isinstance(invalidations, list)
|
|
397
|
+
or any(not _invalidation(item) for item in invalidations)):
|
|
398
|
+
raise CapsuleError("capsule resume_packet invalidations are malformed")
|
|
399
|
+
evidence_index = packet.get("evidence_index")
|
|
400
|
+
if (not isinstance(evidence_index, list)
|
|
401
|
+
or any(not _evidence_index_item(item) for item in evidence_index)):
|
|
402
|
+
raise CapsuleError("capsule resume_packet evidence_index is malformed")
|
|
403
|
+
omissions = packet.get("omissions")
|
|
404
|
+
if (not isinstance(omissions, list)
|
|
405
|
+
or any(not isinstance(item, dict) for item in omissions)):
|
|
406
|
+
raise CapsuleError("capsule resume_packet omissions are malformed")
|
|
407
|
+
recent_context = packet.get("recent_context")
|
|
408
|
+
if (not isinstance(recent_context, list)
|
|
409
|
+
or any(not _recent_context_item(item) for item in recent_context)):
|
|
410
|
+
raise CapsuleError("capsule resume_packet recent_context is malformed")
|
|
411
|
+
environment = packet.get("environment")
|
|
412
|
+
if not (
|
|
413
|
+
isinstance(environment, dict)
|
|
414
|
+
or isinstance(environment, list)
|
|
415
|
+
and all(isinstance(item, dict) for item in environment)):
|
|
416
|
+
raise CapsuleError(
|
|
417
|
+
"capsule resume_packet environment must be an object or "
|
|
418
|
+
"an array of objects")
|
|
419
|
+
autonomy = trust.get("autonomy_level") if isinstance(trust, dict) else None
|
|
420
|
+
if (not isinstance(trust, dict)
|
|
421
|
+
or set(trust) != {
|
|
422
|
+
"autonomy_level", "required_verifiers", "completed_checks",
|
|
423
|
+
"failed_or_stale_checks", "gaps"}
|
|
424
|
+
or autonomy is not None
|
|
425
|
+
and (
|
|
426
|
+
isinstance(autonomy, bool)
|
|
427
|
+
or not isinstance(autonomy, int)
|
|
428
|
+
or not 0 <= autonomy <= 3
|
|
429
|
+
)
|
|
430
|
+
or not _string_list(trust.get("required_verifiers"))
|
|
431
|
+
or not _summary_list(trust.get("completed_checks"))
|
|
432
|
+
or not _summary_list(trust.get("failed_or_stale_checks"))
|
|
433
|
+
or not _string_list(trust.get("gaps"))):
|
|
434
|
+
raise CapsuleError("capsule resume_packet trust shape is malformed")
|
|
435
|
+
if (not isinstance(lineage, dict)
|
|
436
|
+
or set(lineage) != {
|
|
437
|
+
"source_session", "checkpoints", "packet_generation_time"}
|
|
438
|
+
or lineage.get("source_session") is not None
|
|
439
|
+
and not is_public_identifier(lineage.get("source_session"))
|
|
440
|
+
or not isinstance(lineage.get("checkpoints"), list)
|
|
441
|
+
or any(not is_public_identifier(item)
|
|
442
|
+
for item in lineage.get("checkpoints", []))
|
|
443
|
+
or not _timestamp(lineage.get("packet_generation_time"))):
|
|
444
|
+
raise CapsuleError(
|
|
445
|
+
"capsule resume_packet continuity_lineage is malformed")
|
|
446
|
+
coverage = packet.get("evidence_coverage")
|
|
447
|
+
tokens = packet.get("token_estimate")
|
|
448
|
+
if not _finite_probability(coverage):
|
|
449
|
+
raise CapsuleError(
|
|
450
|
+
"capsule resume_packet evidence_coverage must be finite "
|
|
451
|
+
"and between zero and one")
|
|
452
|
+
if isinstance(tokens, bool) or not isinstance(tokens, int) or tokens < 0:
|
|
453
|
+
raise CapsuleError(
|
|
454
|
+
"capsule resume_packet token_estimate must be a non-negative integer")
|
|
455
|
+
|
|
456
|
+
digest = packet.get("packet_digest")
|
|
457
|
+
if not isinstance(digest, str) or _SHA256.fullmatch(digest) is None:
|
|
458
|
+
raise CapsuleError("capsule resume_packet packet_digest is malformed")
|
|
459
|
+
unsigned = {
|
|
460
|
+
key: value for key, value in packet.items()
|
|
461
|
+
if key not in ("packet_digest", "signature")}
|
|
462
|
+
try:
|
|
463
|
+
expected = digest_obj(unsigned)
|
|
464
|
+
except (TypeError, ValueError, OverflowError, RecursionError) as exc:
|
|
465
|
+
raise CapsuleError(
|
|
466
|
+
f"capsule resume_packet is not finite canonical JSON: {exc}") from None
|
|
467
|
+
if digest != expected:
|
|
468
|
+
raise CapsuleError(
|
|
469
|
+
"capsule resume_packet digest mismatch: internal digest is inconsistent")
|
|
470
|
+
if "signature" in packet:
|
|
471
|
+
CapsuleManager._validate_signature_shape(
|
|
472
|
+
packet["signature"], label="resume packet")
|
|
473
|
+
|
|
474
|
+
# ----------------------------------------------------------------- export
|
|
475
|
+
|
|
476
|
+
def _observable_state(self, tenant_id: str, project_id: str) -> dict:
|
|
477
|
+
return {
|
|
478
|
+
"active_assumptions": [
|
|
479
|
+
{"node_id": n["node_id"],
|
|
480
|
+
"statement": n["data"].get("statement"),
|
|
481
|
+
"status": n["status"], "criticality": n["criticality"],
|
|
482
|
+
"confidence": n["confidence"]}
|
|
483
|
+
for n in self.graph.current(
|
|
484
|
+
project_id, "assumption",
|
|
485
|
+
status=["active", "supported", "uncertain"],
|
|
486
|
+
tenant_id=tenant_id)
|
|
487
|
+
],
|
|
488
|
+
"open_invalidations": [
|
|
489
|
+
n["node_id"] for n in self.graph.current(
|
|
490
|
+
project_id, "invalidation",
|
|
491
|
+
status=["open", "pending_confirmation"],
|
|
492
|
+
tenant_id=tenant_id)
|
|
493
|
+
],
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
def export(
|
|
497
|
+
self,
|
|
498
|
+
*,
|
|
499
|
+
tenant_id: str,
|
|
500
|
+
project_id: str,
|
|
501
|
+
session_id: str | None,
|
|
502
|
+
source_model: str,
|
|
503
|
+
source_runtime: str,
|
|
504
|
+
target_adapter: str,
|
|
505
|
+
signer,
|
|
506
|
+
token_budget: int = 8000,
|
|
507
|
+
) -> dict:
|
|
508
|
+
"""Export one transaction-consistent, signed portable snapshot."""
|
|
509
|
+
with self.store.transaction():
|
|
510
|
+
return self._export_locked(
|
|
511
|
+
tenant_id=tenant_id, project_id=project_id,
|
|
512
|
+
session_id=session_id, source_model=source_model,
|
|
513
|
+
source_runtime=source_runtime, target_adapter=target_adapter,
|
|
514
|
+
signer=signer, token_budget=token_budget)
|
|
515
|
+
|
|
516
|
+
def _export_locked(
|
|
517
|
+
self,
|
|
518
|
+
*,
|
|
519
|
+
tenant_id: str,
|
|
520
|
+
project_id: str,
|
|
521
|
+
session_id: str | None,
|
|
522
|
+
source_model: str,
|
|
523
|
+
source_runtime: str,
|
|
524
|
+
target_adapter: str,
|
|
525
|
+
signer,
|
|
526
|
+
token_budget: int = 8000,
|
|
527
|
+
) -> dict:
|
|
528
|
+
source_model = _safe_capsule_text(
|
|
529
|
+
source_model, field="capsule source model")
|
|
530
|
+
source_runtime = _safe_capsule_text(
|
|
531
|
+
source_runtime, field="capsule source runtime")
|
|
532
|
+
target_adapter = _safe_capsule_text(
|
|
533
|
+
target_adapter, field="capsule target adapter")
|
|
534
|
+
self._require_project(tenant_id, project_id)
|
|
535
|
+
if session_id is not None and not _nonempty(session_id):
|
|
536
|
+
raise CapsuleError(
|
|
537
|
+
"capsule source session must be a non-empty string when present")
|
|
538
|
+
if session_id is not None:
|
|
539
|
+
try:
|
|
540
|
+
self.graph.get(
|
|
541
|
+
session_id, tenant_id=tenant_id, project_id=project_id,
|
|
542
|
+
entity_type="session")
|
|
543
|
+
except KeyError:
|
|
544
|
+
raise CapsuleError(
|
|
545
|
+
"capsule source session is not a session in the requested scope"
|
|
546
|
+
) from None
|
|
547
|
+
packet = self.composer.compose(
|
|
548
|
+
tenant_id=tenant_id, project_id=project_id,
|
|
549
|
+
token_budget=token_budget, session_id=session_id,
|
|
550
|
+
state_basis=self._state_basis(project_id),
|
|
551
|
+
)
|
|
552
|
+
body = {
|
|
553
|
+
"schema_version": CAPSULE_SCHEMA,
|
|
554
|
+
"capsule_id": new_id("capsule"),
|
|
555
|
+
"created_at": utcnow(),
|
|
556
|
+
"tenant_id": tenant_id,
|
|
557
|
+
"project_id": project_id,
|
|
558
|
+
"source": {
|
|
559
|
+
"session_id": session_id,
|
|
560
|
+
"model": source_model,
|
|
561
|
+
"runtime": source_runtime,
|
|
562
|
+
},
|
|
563
|
+
"target": {"adapter": target_adapter},
|
|
564
|
+
"resume_packet": packet,
|
|
565
|
+
"observable_state": self._observable_state(
|
|
566
|
+
tenant_id, project_id),
|
|
567
|
+
"lineage": {
|
|
568
|
+
"exported_from_session": session_id,
|
|
569
|
+
"event_watermark": packet.get("project_state_at"),
|
|
570
|
+
},
|
|
571
|
+
}
|
|
572
|
+
body = _strip_hidden(body)
|
|
573
|
+
body["content_digest"] = digest_obj(
|
|
574
|
+
{k: v for k, v in body.items() if k not in ("content_digest", "signature")})
|
|
575
|
+
body["signature"] = signer.sign(body)
|
|
576
|
+
self.store.audit(actor="cce", action="capsule.export",
|
|
577
|
+
object_id=body["capsule_id"],
|
|
578
|
+
detail=f"{source_model}/{source_runtime} -> {target_adapter}")
|
|
579
|
+
return body
|
|
580
|
+
|
|
581
|
+
# ----------------------------------------------------------------- import
|
|
582
|
+
|
|
583
|
+
def validate(
|
|
584
|
+
self, capsule: dict, signer, *, expected_tenant_id: str | None = None,
|
|
585
|
+
expected_project_id: str | None = None) -> dict:
|
|
586
|
+
"""Schema + digest + signature validation. Raises CapsuleError on
|
|
587
|
+
tampering; returns a validation report."""
|
|
588
|
+
top_fields = {
|
|
589
|
+
"schema_version", "capsule_id", "created_at", "tenant_id",
|
|
590
|
+
"project_id", "source", "target", "resume_packet",
|
|
591
|
+
"observable_state", "lineage", "content_digest", "signature",
|
|
592
|
+
}
|
|
593
|
+
if not isinstance(capsule, dict):
|
|
594
|
+
raise CapsuleError("capsule must be an object")
|
|
595
|
+
if capsule.get("schema_version") != CAPSULE_SCHEMA:
|
|
596
|
+
raise CapsuleError(
|
|
597
|
+
f"unsupported capsule schema {capsule.get('schema_version')!r}")
|
|
598
|
+
if _contains_hidden(capsule):
|
|
599
|
+
raise CapsuleError(
|
|
600
|
+
"capsule contains hidden-reasoning fields forbidden by the portable schema")
|
|
601
|
+
missing = top_fields - set(capsule)
|
|
602
|
+
unknown = set(capsule) - top_fields
|
|
603
|
+
if "signature" in missing:
|
|
604
|
+
raise CapsuleError("capsule signature is missing")
|
|
605
|
+
if missing or unknown:
|
|
606
|
+
raise CapsuleError(
|
|
607
|
+
f"capsule has missing or unknown top-level fields: "
|
|
608
|
+
f"missing={sorted(missing)}, unknown={sorted(unknown)}")
|
|
609
|
+
capsule_id = capsule.get("capsule_id")
|
|
610
|
+
if (not isinstance(capsule_id, str)
|
|
611
|
+
or _CAPSULE_ID.fullmatch(capsule_id) is None):
|
|
612
|
+
raise CapsuleError("capsule_id is malformed")
|
|
613
|
+
if not _timestamp(capsule.get("created_at")):
|
|
614
|
+
raise CapsuleError("capsule created_at is malformed")
|
|
615
|
+
tenant_id = capsule.get("tenant_id")
|
|
616
|
+
project_id = capsule.get("project_id")
|
|
617
|
+
if (not is_public_identifier(tenant_id)
|
|
618
|
+
or not is_public_identifier(project_id)):
|
|
619
|
+
raise CapsuleError(
|
|
620
|
+
"capsule tenant_id and project_id are malformed identifiers")
|
|
621
|
+
if expected_tenant_id is not None and tenant_id != expected_tenant_id:
|
|
622
|
+
raise CapsuleError("capsule belongs to another tenant")
|
|
623
|
+
if expected_project_id is not None and project_id != expected_project_id:
|
|
624
|
+
raise CapsuleError("capsule belongs to another project")
|
|
625
|
+
self._require_project(tenant_id, project_id)
|
|
626
|
+
source = capsule.get("source")
|
|
627
|
+
target = capsule.get("target")
|
|
628
|
+
state = capsule.get("observable_state")
|
|
629
|
+
lineage = capsule.get("lineage")
|
|
630
|
+
if (not isinstance(source, dict)
|
|
631
|
+
or set(source) != {"session_id", "model", "runtime"}
|
|
632
|
+
or source.get("session_id") is not None
|
|
633
|
+
and not is_public_identifier(source.get("session_id"))
|
|
634
|
+
or not _nonempty(source.get("model"))
|
|
635
|
+
or not _nonempty(source.get("runtime"))
|
|
636
|
+
or not isinstance(target, dict)
|
|
637
|
+
or set(target) != {"adapter"}
|
|
638
|
+
or not _nonempty(target.get("adapter"))
|
|
639
|
+
or not isinstance(state, dict)
|
|
640
|
+
or set(state) != {"active_assumptions", "open_invalidations"}
|
|
641
|
+
or not isinstance(state.get("active_assumptions"), list)
|
|
642
|
+
or not isinstance(state.get("open_invalidations"), list)
|
|
643
|
+
or not isinstance(lineage, dict)
|
|
644
|
+
or set(lineage) != {"exported_from_session", "event_watermark"}
|
|
645
|
+
or lineage.get("exported_from_session") !=
|
|
646
|
+
source.get("session_id")):
|
|
647
|
+
raise CapsuleError("capsule portable-state shape is malformed")
|
|
648
|
+
self._validate_resume_packet(capsule.get("resume_packet"))
|
|
649
|
+
if (lineage.get("event_watermark") is not None
|
|
650
|
+
and not _nonempty(lineage.get("event_watermark"))):
|
|
651
|
+
raise CapsuleError("capsule lineage event_watermark is malformed")
|
|
652
|
+
if (
|
|
653
|
+
lineage.get("event_watermark")
|
|
654
|
+
!= capsule["resume_packet"].get("project_state_at")
|
|
655
|
+
):
|
|
656
|
+
raise CapsuleError(
|
|
657
|
+
"capsule lineage event_watermark does not match resume_packet")
|
|
658
|
+
assumption_fields = {
|
|
659
|
+
"node_id", "statement", "status", "criticality", "confidence"}
|
|
660
|
+
assumptions = state["active_assumptions"]
|
|
661
|
+
if any(
|
|
662
|
+
not isinstance(item, dict)
|
|
663
|
+
or set(item) != assumption_fields
|
|
664
|
+
or not is_public_identifier(item.get("node_id"))
|
|
665
|
+
or item.get("statement") is not None
|
|
666
|
+
and not isinstance(item.get("statement"), str)
|
|
667
|
+
or not isinstance(item.get("status"), str)
|
|
668
|
+
or item.get("status") not in {
|
|
669
|
+
"active", "supported", "uncertain"}
|
|
670
|
+
or item.get("criticality") is not None
|
|
671
|
+
and not _nonempty(item.get("criticality"))
|
|
672
|
+
or item.get("confidence") is not None
|
|
673
|
+
and not _finite_probability(item.get("confidence"))
|
|
674
|
+
for item in assumptions):
|
|
675
|
+
raise CapsuleError("capsule assumption state is malformed")
|
|
676
|
+
assumption_ids = [item["node_id"] for item in assumptions]
|
|
677
|
+
invalidation_ids = state["open_invalidations"]
|
|
678
|
+
if len(set(assumption_ids)) != len(assumption_ids):
|
|
679
|
+
raise CapsuleError("capsule assumption ids must be unique")
|
|
680
|
+
if (any(not is_public_identifier(node_id)
|
|
681
|
+
for node_id in invalidation_ids)
|
|
682
|
+
or len(set(invalidation_ids)) != len(invalidation_ids)):
|
|
683
|
+
raise CapsuleError(
|
|
684
|
+
"capsule invalidation ids must be non-empty and unique")
|
|
685
|
+
content_digest = capsule.get("content_digest")
|
|
686
|
+
if (not isinstance(content_digest, str)
|
|
687
|
+
or _SHA256.fullmatch(content_digest) is None):
|
|
688
|
+
raise CapsuleError("capsule content_digest is malformed")
|
|
689
|
+
self._validate_signature_shape(
|
|
690
|
+
capsule.get("signature"), label="capsule")
|
|
691
|
+
body = {k: v for k, v in capsule.items()
|
|
692
|
+
if k not in ("content_digest", "signature")}
|
|
693
|
+
try:
|
|
694
|
+
expected = digest_obj(body)
|
|
695
|
+
except (TypeError, ValueError, OverflowError, RecursionError) as exc:
|
|
696
|
+
raise CapsuleError(
|
|
697
|
+
f"capsule is not finite canonical JSON: {exc}") from None
|
|
698
|
+
if content_digest != expected:
|
|
699
|
+
raise CapsuleError("content digest mismatch: capsule was modified")
|
|
700
|
+
# Import is a trust transition, so an absent trust root cannot turn
|
|
701
|
+
# signature verification into an optional check. A content digest
|
|
702
|
+
# detects accidental change; it authenticates no producer.
|
|
703
|
+
if signer is None:
|
|
704
|
+
raise CapsuleError("a trusted signer is required to import a capsule")
|
|
705
|
+
if not self._authenticator_valid(capsule, signer):
|
|
706
|
+
raise CapsuleError(
|
|
707
|
+
"signature authenticity could not be established")
|
|
708
|
+
return {"valid": True, "capsule_id": capsule["capsule_id"],
|
|
709
|
+
"content_digest": expected}
|
|
710
|
+
|
|
711
|
+
def challenge(self, capsule: dict) -> dict:
|
|
712
|
+
"""Deterministic challenge step (MIG-005): the importing runtime must
|
|
713
|
+
confront uncertainty before acting. Autonomy above level 1 requires
|
|
714
|
+
challenge.passed."""
|
|
715
|
+
if not isinstance(capsule, dict):
|
|
716
|
+
raise CapsuleError("capsule challenge input must be an object")
|
|
717
|
+
packet = capsule.get("resume_packet")
|
|
718
|
+
state = capsule.get("observable_state")
|
|
719
|
+
self._validate_resume_packet(packet)
|
|
720
|
+
if (not isinstance(state, dict)
|
|
721
|
+
or set(state) != {
|
|
722
|
+
"active_assumptions", "open_invalidations"}
|
|
723
|
+
or not isinstance(state.get("active_assumptions"), list)
|
|
724
|
+
or not isinstance(state.get("open_invalidations"), list)):
|
|
725
|
+
raise CapsuleError(
|
|
726
|
+
"capsule challenge observable_state is malformed")
|
|
727
|
+
assumption_fields = {
|
|
728
|
+
"node_id", "statement", "status", "criticality", "confidence"}
|
|
729
|
+
assumptions = state["active_assumptions"]
|
|
730
|
+
if any(
|
|
731
|
+
not isinstance(item, dict)
|
|
732
|
+
or set(item) != assumption_fields
|
|
733
|
+
or not is_public_identifier(item.get("node_id"))
|
|
734
|
+
or item.get("statement") is not None
|
|
735
|
+
and not isinstance(item.get("statement"), str)
|
|
736
|
+
or not isinstance(item.get("status"), str)
|
|
737
|
+
or item.get("status") not in {
|
|
738
|
+
"active", "supported", "uncertain"}
|
|
739
|
+
or item.get("criticality") is not None
|
|
740
|
+
and not _nonempty(item.get("criticality"))
|
|
741
|
+
or item.get("confidence") is not None
|
|
742
|
+
and not _finite_probability(item.get("confidence"))
|
|
743
|
+
for item in assumptions):
|
|
744
|
+
raise CapsuleError(
|
|
745
|
+
"capsule challenge assumption state is malformed")
|
|
746
|
+
assumption_ids = [item["node_id"] for item in assumptions]
|
|
747
|
+
invalidation_ids = state["open_invalidations"]
|
|
748
|
+
if len(set(assumption_ids)) != len(assumption_ids):
|
|
749
|
+
raise CapsuleError(
|
|
750
|
+
"capsule challenge assumption ids must be unique")
|
|
751
|
+
if (any(not is_public_identifier(node_id)
|
|
752
|
+
for node_id in invalidation_ids)
|
|
753
|
+
or len(set(invalidation_ids)) != len(invalidation_ids)):
|
|
754
|
+
raise CapsuleError(
|
|
755
|
+
"capsule challenge invalidation ids must be non-empty and unique")
|
|
756
|
+
uncertainties = []
|
|
757
|
+
conflicts = []
|
|
758
|
+
for a in assumptions:
|
|
759
|
+
if a.get("status") == "uncertain":
|
|
760
|
+
uncertainties.append({
|
|
761
|
+
"kind": "uncertain_assumption", "node_id": a["node_id"],
|
|
762
|
+
"statement": a.get("statement"),
|
|
763
|
+
})
|
|
764
|
+
else:
|
|
765
|
+
confidence = a.get("confidence")
|
|
766
|
+
# `0.0 or 1.0` used to turn the least-confident possible
|
|
767
|
+
# assumption into certainty. Missing/non-numeric confidence
|
|
768
|
+
# is unresolved too; absence of a measure is not a high one.
|
|
769
|
+
if isinstance(confidence, bool) or not isinstance(
|
|
770
|
+
confidence, (int, float)):
|
|
771
|
+
uncertainties.append({
|
|
772
|
+
"kind": "missing_confidence_assumption",
|
|
773
|
+
"node_id": a["node_id"], "statement": a.get("statement"),
|
|
774
|
+
})
|
|
775
|
+
elif confidence < 0.5:
|
|
776
|
+
uncertainties.append({
|
|
777
|
+
"kind": "low_confidence_assumption", "node_id": a["node_id"],
|
|
778
|
+
"statement": a.get("statement"),
|
|
779
|
+
})
|
|
780
|
+
for inv in invalidation_ids:
|
|
781
|
+
conflicts.append({"kind": "open_invalidation", "node_id": inv})
|
|
782
|
+
gaps = packet.get("trust", {}).get("gaps", [])
|
|
783
|
+
missing_env = packet.get("environment") in (None, {}, []) or (
|
|
784
|
+
isinstance(packet.get("environment"), dict)
|
|
785
|
+
and "note" in packet.get("environment", {}))
|
|
786
|
+
questions = (
|
|
787
|
+
[f"Assumption {u['node_id']} is uncertain: {u.get('statement')!r}."
|
|
788
|
+
f" Confirm, narrow, or reject before relying on it."
|
|
789
|
+
for u in uncertainties]
|
|
790
|
+
+ [f"Invalidation {c['node_id']} is open; its blast radius must be"
|
|
791
|
+
f" reviewed before dependent work continues." for c in conflicts]
|
|
792
|
+
+ ([f"Required verifier(s) not yet passed: {', '.join(gaps)}."] if gaps else [])
|
|
793
|
+
+ (["No environment fingerprint present; re-derive the toolchain before"
|
|
794
|
+
" executing anything."] if missing_env else [])
|
|
795
|
+
)
|
|
796
|
+
# Every item that produces a challenge question is a gate. Previously
|
|
797
|
+
# uncertainty and a missing environment were reported to the reader
|
|
798
|
+
# while `passed=True` granted unrestricted autonomy anyway.
|
|
799
|
+
passed = not uncertainties and not conflicts and not gaps and not missing_env
|
|
800
|
+
score = max(0.0, 1.0 - 0.15 * len(uncertainties) - 0.3 * len(conflicts)
|
|
801
|
+
- 0.2 * len(gaps) - (0.2 if missing_env else 0.0))
|
|
802
|
+
return {
|
|
803
|
+
"passed": passed,
|
|
804
|
+
"migration_score": round(score, 3),
|
|
805
|
+
"uncertainties": uncertainties,
|
|
806
|
+
"conflicts": conflicts,
|
|
807
|
+
"verifier_gaps": gaps,
|
|
808
|
+
"environment_missing": missing_env,
|
|
809
|
+
"questions": questions,
|
|
810
|
+
"max_autonomy_until_resolved": 1 if not passed else None,
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
def _challenge_against_live_target(
|
|
814
|
+
self, capsule: dict, tenant_id: str, project_id: str) -> dict:
|
|
815
|
+
"""Challenge the signed source state plus the current target state.
|
|
816
|
+
|
|
817
|
+
A capsule authenticates what was exported; it does not freeze the
|
|
818
|
+
target project. New invalidations and verifier gaps that appear after
|
|
819
|
+
export must therefore participate in the migration gate.
|
|
820
|
+
"""
|
|
821
|
+
source_session = capsule["source"].get("session_id")
|
|
822
|
+
if source_session is not None:
|
|
823
|
+
try:
|
|
824
|
+
self.graph.get(
|
|
825
|
+
source_session, tenant_id=tenant_id,
|
|
826
|
+
project_id=project_id, entity_type="session")
|
|
827
|
+
except KeyError:
|
|
828
|
+
# A portable source id need not exist in the target graph.
|
|
829
|
+
# Missing and foreign-scoped values are deliberately
|
|
830
|
+
# equivalent and neither may become local packet lineage.
|
|
831
|
+
source_session = None
|
|
832
|
+
live_packet = self.composer.compose(
|
|
833
|
+
tenant_id=tenant_id, project_id=project_id,
|
|
834
|
+
token_budget=8000,
|
|
835
|
+
session_id=source_session,
|
|
836
|
+
state_basis=self._state_basis(project_id))
|
|
837
|
+
source_packet = capsule.get("resume_packet") or {}
|
|
838
|
+
# Presentation is budget-dependent: verified/open-work detail and
|
|
839
|
+
# environment lists may be shortened without the project changing.
|
|
840
|
+
# The embedded state basis commits the complete semantic control
|
|
841
|
+
# surface before rendering, so it is the only sound drift comparator.
|
|
842
|
+
source_basis = source_packet.get("project_state_basis")
|
|
843
|
+
target_basis = live_packet.get("project_state_basis")
|
|
844
|
+
source_control_digest = (
|
|
845
|
+
source_basis.get("control_basis_digest")
|
|
846
|
+
if isinstance(source_basis, dict) else None)
|
|
847
|
+
target_control_digest = (
|
|
848
|
+
target_basis.get("control_basis_digest")
|
|
849
|
+
if isinstance(target_basis, dict) else None)
|
|
850
|
+
source_watermark = (
|
|
851
|
+
capsule.get("lineage") or {}).get("event_watermark")
|
|
852
|
+
target_watermark = live_packet.get("project_state_at")
|
|
853
|
+
source_gaps = (source_packet.get("trust") or {}).get("gaps") or []
|
|
854
|
+
live_trust = live_packet.setdefault("trust", {})
|
|
855
|
+
live_gaps = live_trust.get("gaps") or []
|
|
856
|
+
live_trust["gaps"] = sorted(set(source_gaps) | set(live_gaps))
|
|
857
|
+
live_packet["packet_digest"] = digest_obj({
|
|
858
|
+
key: value for key, value in live_packet.items()
|
|
859
|
+
if key not in ("signature", "packet_digest")})
|
|
860
|
+
source_omissions = copy.deepcopy(source_packet.get("omissions") or [])
|
|
861
|
+
target_omissions = copy.deepcopy(live_packet.get("omissions") or [])
|
|
862
|
+
|
|
863
|
+
source_state = capsule["observable_state"]
|
|
864
|
+
live_state = self._observable_state(tenant_id, project_id)
|
|
865
|
+
assumptions = {
|
|
866
|
+
item["node_id"]: copy.deepcopy(item)
|
|
867
|
+
for item in source_state["active_assumptions"]}
|
|
868
|
+
# Current state wins for the same stable node id; source-only state is
|
|
869
|
+
# retained so migration cannot erase a warning by omission.
|
|
870
|
+
assumptions.update({
|
|
871
|
+
item["node_id"]: item
|
|
872
|
+
for item in live_state["active_assumptions"]})
|
|
873
|
+
challenge_input = {
|
|
874
|
+
"resume_packet": live_packet,
|
|
875
|
+
"observable_state": {
|
|
876
|
+
"active_assumptions": [
|
|
877
|
+
assumptions[node_id] for node_id in sorted(assumptions)],
|
|
878
|
+
"open_invalidations": sorted(set(
|
|
879
|
+
source_state["open_invalidations"])
|
|
880
|
+
| set(live_state["open_invalidations"])),
|
|
881
|
+
},
|
|
882
|
+
}
|
|
883
|
+
result = self.challenge(challenge_input)
|
|
884
|
+
drift = []
|
|
885
|
+
if source_watermark != target_watermark:
|
|
886
|
+
drift.append({
|
|
887
|
+
"kind": "target_event_frontier_changed",
|
|
888
|
+
"node_id": project_id,
|
|
889
|
+
"source": source_watermark,
|
|
890
|
+
"target": target_watermark,
|
|
891
|
+
})
|
|
892
|
+
if source_basis != target_basis:
|
|
893
|
+
drift.append({
|
|
894
|
+
"kind": "target_control_state_changed",
|
|
895
|
+
"node_id": project_id,
|
|
896
|
+
"source_digest": source_control_digest,
|
|
897
|
+
"target_digest": target_control_digest,
|
|
898
|
+
})
|
|
899
|
+
if drift:
|
|
900
|
+
result["conflicts"].extend(drift)
|
|
901
|
+
result["questions"].append(
|
|
902
|
+
"The target project changed after capsule export. Review the "
|
|
903
|
+
"new event frontier and control state before restoring autonomy.")
|
|
904
|
+
result["passed"] = False
|
|
905
|
+
result["migration_score"] = round(max(
|
|
906
|
+
0.0, result["migration_score"] - 0.3 * len(drift)), 3)
|
|
907
|
+
result["max_autonomy_until_resolved"] = 1
|
|
908
|
+
result["control_drift"] = drift
|
|
909
|
+
result["live_state_reconciled"] = True
|
|
910
|
+
result["source_event_watermark"] = source_watermark
|
|
911
|
+
result["target_event_watermark"] = target_watermark
|
|
912
|
+
result["source_control_digest"] = source_control_digest
|
|
913
|
+
result["target_control_digest"] = target_control_digest
|
|
914
|
+
# Source warnings remain visible even when a fresh target rendering
|
|
915
|
+
# has a larger budget. Keep provenance separate while the challenge
|
|
916
|
+
# itself operates on the conservative union of verifier gaps and
|
|
917
|
+
# observable uncertainty above.
|
|
918
|
+
result["source_verifier_gaps"] = sorted(set(source_gaps))
|
|
919
|
+
result["target_verifier_gaps"] = sorted(set(live_gaps))
|
|
920
|
+
result["source_packet_omissions"] = source_omissions
|
|
921
|
+
result["target_packet_omissions"] = target_omissions
|
|
922
|
+
return result
|
|
923
|
+
|
|
924
|
+
def import_capsule(
|
|
925
|
+
self,
|
|
926
|
+
capsule: dict,
|
|
927
|
+
*,
|
|
928
|
+
signer,
|
|
929
|
+
target_model: str,
|
|
930
|
+
target_runtime: str,
|
|
931
|
+
actor: str = "cce",
|
|
932
|
+
expected_tenant_id: str | None = None,
|
|
933
|
+
expected_project_id: str | None = None,
|
|
934
|
+
) -> dict:
|
|
935
|
+
"""Validate, challenge, and create the migrated session with lineage
|
|
936
|
+
(MIG-004). Returns {session, challenge, validation}."""
|
|
937
|
+
target_model = _safe_capsule_text(
|
|
938
|
+
target_model, field="capsule target model")
|
|
939
|
+
target_runtime = _safe_capsule_text(
|
|
940
|
+
target_runtime, field="capsule target runtime")
|
|
941
|
+
actor = _safe_capsule_text(actor, field="capsule import actor")
|
|
942
|
+
validation = self.validate(
|
|
943
|
+
capsule, signer, expected_tenant_id=expected_tenant_id,
|
|
944
|
+
expected_project_id=expected_project_id)
|
|
945
|
+
tenant_id = capsule["tenant_id"]
|
|
946
|
+
project_id = capsule["project_id"]
|
|
947
|
+
# Fresh target challenge, session, lineage, enforced downgrade, and
|
|
948
|
+
# audit are one snapshot/write unit. A concurrent invalidation cannot
|
|
949
|
+
# land between the migration gate and the session it authorizes.
|
|
950
|
+
with self.store.transaction():
|
|
951
|
+
challenge = self._challenge_against_live_target(
|
|
952
|
+
capsule, tenant_id, project_id)
|
|
953
|
+
source_session = capsule["source"].get("session_id")
|
|
954
|
+
if source_session:
|
|
955
|
+
try:
|
|
956
|
+
self.graph.get(
|
|
957
|
+
source_session, tenant_id=tenant_id,
|
|
958
|
+
project_id=project_id, entity_type="session")
|
|
959
|
+
except KeyError:
|
|
960
|
+
# A portable source id need not exist in the target graph.
|
|
961
|
+
# Never probe globally: doing so distinguishes a foreign
|
|
962
|
+
# session from an absent one and turns import into a scope
|
|
963
|
+
# oracle. Both mean that no local lineage edge can be made.
|
|
964
|
+
source_session = None
|
|
965
|
+
session = self.graph.put_node(
|
|
966
|
+
entity_type="session", tenant_id=tenant_id,
|
|
967
|
+
project_id=project_id, status="active",
|
|
968
|
+
data={
|
|
969
|
+
"model": target_model, "runtime": target_runtime,
|
|
970
|
+
"started_at": utcnow(),
|
|
971
|
+
"migrated_from_capsule": capsule["capsule_id"],
|
|
972
|
+
"source_model": capsule["source"].get("model"),
|
|
973
|
+
"source_runtime": capsule["source"].get("runtime"),
|
|
974
|
+
"challenge": challenge,
|
|
975
|
+
},
|
|
976
|
+
)
|
|
977
|
+
if source_session:
|
|
978
|
+
self.graph.put_edge(
|
|
979
|
+
edge_type="migrated_from", src_id=session.id,
|
|
980
|
+
dst_id=source_session, tenant_id=tenant_id,
|
|
981
|
+
project_id=project_id,
|
|
982
|
+
)
|
|
983
|
+
# AUT-005/MIG-005: the challenge is a real gate, not a report.
|
|
984
|
+
if not challenge["passed"] and self.policy is not None:
|
|
985
|
+
self.policy.downgrade(
|
|
986
|
+
project_id, "migration",
|
|
987
|
+
ceiling=challenge["max_autonomy_until_resolved"] or 1,
|
|
988
|
+
actor=actor)
|
|
989
|
+
challenge["enforced_ceiling"] = \
|
|
990
|
+
self.policy.active_downgrade_ceiling(project_id)
|
|
991
|
+
self.store.audit(
|
|
992
|
+
actor=actor, action="capsule.import",
|
|
993
|
+
object_id=capsule["capsule_id"],
|
|
994
|
+
detail=f"-> {target_model}/{target_runtime}"
|
|
995
|
+
f" challenge_passed={challenge['passed']}")
|
|
996
|
+
return {"session": session, "challenge": challenge, "validation": validation}
|