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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. causal_continuity_engine/__init__.py +21 -0
  2. causal_continuity_engine/api.py +1264 -0
  3. causal_continuity_engine/capabilities.py +548 -0
  4. causal_continuity_engine/capsule.py +996 -0
  5. causal_continuity_engine/cli.py +1338 -0
  6. causal_continuity_engine/core.py +468 -0
  7. causal_continuity_engine/engine.py +4354 -0
  8. causal_continuity_engine/evidence.py +971 -0
  9. causal_continuity_engine/extraction.py +263 -0
  10. causal_continuity_engine/github.py +657 -0
  11. causal_continuity_engine/graph.py +809 -0
  12. causal_continuity_engine/invalidation.py +1040 -0
  13. causal_continuity_engine/lamport.py +306 -0
  14. causal_continuity_engine/learning.py +466 -0
  15. causal_continuity_engine/memory.py +610 -0
  16. causal_continuity_engine/ontology.py +108 -0
  17. causal_continuity_engine/partial.py +309 -0
  18. causal_continuity_engine/policy.py +940 -0
  19. causal_continuity_engine/proof.py +920 -0
  20. causal_continuity_engine/py.typed +1 -0
  21. causal_continuity_engine/redaction.py +94 -0
  22. causal_continuity_engine/resume.py +653 -0
  23. causal_continuity_engine/store.py +1174 -0
  24. causal_continuity_engine/verifiers.py +1006 -0
  25. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/SPEC.md +515 -0
  26. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/__init__.py +0 -0
  27. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/continuitybench/__init__.py +0 -0
  28. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/continuitybench/run.py +146 -0
  29. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/continuitybench/scenarios.py +544 -0
  30. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.anchor.v1.json +62 -0
  31. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.capsule.v1.json +112 -0
  32. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.continuity-receipt.v1.json +315 -0
  33. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.event.v1.json +73 -0
  34. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.proof-predicate.v1.json +35 -0
  35. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.proof.v1.json +242 -0
  36. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.recovery.v1.json +109 -0
  37. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.resume.v1.json +332 -0
  38. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/__init__.py +1 -0
  39. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/schema_validation.py +61 -0
  40. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_api_contract.py +1051 -0
  41. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_cli.py +664 -0
  42. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_conformance.py +758 -0
  43. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_distribution_environment.py +26 -0
  44. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_engine_e2e.py +286 -0
  45. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_github_redaction.py +193 -0
  46. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_instrument_validation.py +535 -0
  47. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_invalidation_resume.py +205 -0
  48. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_learning_partial_api.py +380 -0
  49. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_memory_extraction.py +151 -0
  50. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions.py +406 -0
  51. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round10_release.py +842 -0
  52. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round11_deep_integrity.py +831 -0
  53. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round12_release_blockers.py +206 -0
  54. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round13_jcs.py +212 -0
  55. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round14_state_integrity.py +1056 -0
  56. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round15_capsule_invalidation_github.py +251 -0
  57. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round15_public_boundaries.py +561 -0
  58. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round15_storage_boundaries.py +707 -0
  59. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round16_cli_api_core_capabilities.py +285 -0
  60. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round2.py +388 -0
  61. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round3.py +490 -0
  62. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round4.py +557 -0
  63. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round5.py +583 -0
  64. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round6.py +559 -0
  65. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round7.py +579 -0
  66. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_boundaries.py +558 -0
  67. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_engine.py +843 -0
  68. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_portability.py +2250 -0
  69. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_state.py +705 -0
  70. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round9_integrity.py +885 -0
  71. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round9_scope_freshness.py +179 -0
  72. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_release_boundary_validation.py +676 -0
  73. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_release_controls.py +840 -0
  74. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_sqlite_lifecycle.py +52 -0
  75. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_store_graph.py +172 -0
  76. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_trust.py +398 -0
  77. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/action_intent_wrong_type.json +170 -0
  78. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/actor_wrong_type.json +170 -0
  79. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/created_at_invalid_calendar_date.json +172 -0
  80. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/created_at_noncanonical_offset.json +172 -0
  81. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_authoritative_failure.json +114 -0
  82. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_authoritative_pass.json +107 -0
  83. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_no_results.json +99 -0
  84. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_self_asserted_pass.json +105 -0
  85. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/generate.py +498 -0
  86. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/incomplete_failed_check.json +168 -0
  87. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/index.json +240 -0
  88. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/input_item_wrong_type.json +163 -0
  89. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/invalid_result_value.json +170 -0
  90. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/lamport_fingerprint_spoof.json +1458 -0
  91. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/lamport_no_registry.json +1454 -0
  92. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/lamport_truncated_key.json +1454 -0
  93. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/missing_required_field.json +157 -0
  94. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/non_binary64_integer.json +174 -0
  95. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/policy_decision_wrong_type.json +146 -0
  96. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/retry_cannot_launder_a_failure.json +197 -0
  97. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/self_asserted_cannot_satisfy.json +173 -0
  98. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/self_assertion_cannot_poison_authoritative_pass.json +194 -0
  99. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/status_contradicts_contents.json +173 -0
  100. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/summary_contradicts_contents.json +170 -0
  101. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/tamper_statement.json +172 -0
  102. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/tamper_statement_resealed.json +172 -0
  103. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/task_id_under_unrelated_field.json +172 -0
  104. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unbound_task.json +165 -0
  105. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_action_intent_field.json +173 -0
  106. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_algorithm.json +172 -0
  107. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_schema_version.json +170 -0
  108. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_top_level_field.json +175 -0
  109. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unverified_forged_body_no_key.json +170 -0
  110. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unverified_hmac_no_key.json +170 -0
  111. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_hmac.json +172 -0
  112. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_hmac_jcs_edges.json +186 -0
  113. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_hmac_unicode.json +172 -0
  114. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_lamport.json +1458 -0
  115. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/wrong_project.json +173 -0
  116. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/verifiers/__init__.py +0 -0
  117. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/verifiers/verify_proof.py +1109 -0
  118. causal_continuity_engine-0.1.0.dist-info/METADATA +720 -0
  119. causal_continuity_engine-0.1.0.dist-info/RECORD +124 -0
  120. causal_continuity_engine-0.1.0.dist-info/WHEEL +5 -0
  121. causal_continuity_engine-0.1.0.dist-info/entry_points.txt +2 -0
  122. causal_continuity_engine-0.1.0.dist-info/licenses/LICENSE.txt +202 -0
  123. causal_continuity_engine-0.1.0.dist-info/licenses/NOTICE +6 -0
  124. causal_continuity_engine-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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}