problem-frame-gate 0.3.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.
- problem_frame_gate/__init__.py +104 -0
- problem_frame_gate/_version.py +1 -0
- problem_frame_gate/certificates.py +116 -0
- problem_frame_gate/cli.py +183 -0
- problem_frame_gate/digest.py +69 -0
- problem_frame_gate/errors.py +17 -0
- problem_frame_gate/fold.py +425 -0
- problem_frame_gate/formation.py +155 -0
- problem_frame_gate/gate.py +365 -0
- problem_frame_gate/join.py +150 -0
- problem_frame_gate/model.py +441 -0
- problem_frame_gate/patch.py +191 -0
- problem_frame_gate/py.typed +1 -0
- problem_frame_gate/records.py +210 -0
- problem_frame_gate/result.py +148 -0
- problem_frame_gate/risk.py +203 -0
- problem_frame_gate/security.py +88 -0
- problem_frame_gate/verifier.py +594 -0
- problem_frame_gate-0.3.0.dist-info/METADATA +153 -0
- problem_frame_gate-0.3.0.dist-info/RECORD +24 -0
- problem_frame_gate-0.3.0.dist-info/WHEEL +4 -0
- problem_frame_gate-0.3.0.dist-info/entry_points.txt +2 -0
- problem_frame_gate-0.3.0.dist-info/licenses/LICENSE +175 -0
- problem_frame_gate-0.3.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Execution gate for external actions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .certificates import all_certificates_live
|
|
10
|
+
from .fold import FoldKernel, FoldState
|
|
11
|
+
from .formation import check_well_audited
|
|
12
|
+
from .model import Envelope, EnvelopeClass, Horizon, Status
|
|
13
|
+
from .result import CheckBuilder, CheckResult
|
|
14
|
+
from .risk import check_risk_spend_live
|
|
15
|
+
from .verifier import EnvelopeVerifier, digest_log
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class GateRequest:
|
|
20
|
+
"""All fields the executor gate must bind into the audit log."""
|
|
21
|
+
|
|
22
|
+
gate_id: str
|
|
23
|
+
bundle_id: str
|
|
24
|
+
frame_id: str
|
|
25
|
+
action: str
|
|
26
|
+
outbox_id: str
|
|
27
|
+
capability_id: str
|
|
28
|
+
lease_id: str
|
|
29
|
+
risk_id: str
|
|
30
|
+
hypothesis_id: str
|
|
31
|
+
risk_mode: str
|
|
32
|
+
risk_cert_id: str
|
|
33
|
+
source_time: int
|
|
34
|
+
commit_time: int
|
|
35
|
+
executor_id: str = "executor"
|
|
36
|
+
resource_amount: Any = 1
|
|
37
|
+
ledger_digest: str | None = None
|
|
38
|
+
expected_source_digest: str | None = None
|
|
39
|
+
required_certificate_ids: tuple[str, ...] = ()
|
|
40
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
def to_json(self) -> dict[str, Any]:
|
|
43
|
+
return {
|
|
44
|
+
"gate_id": self.gate_id,
|
|
45
|
+
"bundle_id": self.bundle_id,
|
|
46
|
+
"frame_id": self.frame_id,
|
|
47
|
+
"action": self.action,
|
|
48
|
+
"outbox_id": self.outbox_id,
|
|
49
|
+
"capability_id": self.capability_id,
|
|
50
|
+
"lease_id": self.lease_id,
|
|
51
|
+
"risk_id": self.risk_id,
|
|
52
|
+
"hypothesis_id": self.hypothesis_id,
|
|
53
|
+
"risk_mode": self.risk_mode,
|
|
54
|
+
"risk_cert_id": self.risk_cert_id,
|
|
55
|
+
"source_time": self.source_time,
|
|
56
|
+
"commit_time": self.commit_time,
|
|
57
|
+
"executor_id": self.executor_id,
|
|
58
|
+
"resource_amount": self.resource_amount,
|
|
59
|
+
"ledger_digest": self.ledger_digest,
|
|
60
|
+
"expected_source_digest": self.expected_source_digest,
|
|
61
|
+
"required_certificate_ids": list(self.required_certificate_ids),
|
|
62
|
+
"metadata": dict(self.metadata),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class GateRecord:
|
|
68
|
+
"""Finite row that binds a gate decision to one source prefix."""
|
|
69
|
+
|
|
70
|
+
gate_id: str
|
|
71
|
+
bundle_id: str
|
|
72
|
+
frame_id: str
|
|
73
|
+
action: str
|
|
74
|
+
outbox_id: str
|
|
75
|
+
capability_id: str
|
|
76
|
+
lease_id: str
|
|
77
|
+
risk_id: str
|
|
78
|
+
hypothesis_id: str
|
|
79
|
+
risk_mode: str
|
|
80
|
+
risk_cert_id: str
|
|
81
|
+
source_digest: str
|
|
82
|
+
ledger_digest: str | None
|
|
83
|
+
transcript_digest: str
|
|
84
|
+
source_time: int
|
|
85
|
+
commit_time: int
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_request(cls, request: GateRequest, *, source_digest: str, transcript_digest: str) -> GateRecord:
|
|
89
|
+
return cls(
|
|
90
|
+
gate_id=request.gate_id,
|
|
91
|
+
bundle_id=request.bundle_id,
|
|
92
|
+
frame_id=request.frame_id,
|
|
93
|
+
action=request.action,
|
|
94
|
+
outbox_id=request.outbox_id,
|
|
95
|
+
capability_id=request.capability_id,
|
|
96
|
+
lease_id=request.lease_id,
|
|
97
|
+
risk_id=request.risk_id,
|
|
98
|
+
hypothesis_id=request.hypothesis_id,
|
|
99
|
+
risk_mode=request.risk_mode,
|
|
100
|
+
risk_cert_id=request.risk_cert_id,
|
|
101
|
+
source_digest=source_digest,
|
|
102
|
+
ledger_digest=request.ledger_digest,
|
|
103
|
+
transcript_digest=transcript_digest,
|
|
104
|
+
source_time=request.source_time,
|
|
105
|
+
commit_time=request.commit_time,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def to_json(self) -> dict[str, Any]:
|
|
109
|
+
return {
|
|
110
|
+
"gate_id": self.gate_id,
|
|
111
|
+
"bundle_id": self.bundle_id,
|
|
112
|
+
"frame_id": self.frame_id,
|
|
113
|
+
"action": self.action,
|
|
114
|
+
"outbox_id": self.outbox_id,
|
|
115
|
+
"capability_id": self.capability_id,
|
|
116
|
+
"lease_id": self.lease_id,
|
|
117
|
+
"risk_id": self.risk_id,
|
|
118
|
+
"hypothesis_id": self.hypothesis_id,
|
|
119
|
+
"risk_mode": self.risk_mode,
|
|
120
|
+
"risk_cert_id": self.risk_cert_id,
|
|
121
|
+
"source_digest": self.source_digest,
|
|
122
|
+
"ledger_digest": self.ledger_digest,
|
|
123
|
+
"transcript_digest": self.transcript_digest,
|
|
124
|
+
"source_time": self.source_time,
|
|
125
|
+
"commit_time": self.commit_time,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class GateBundle:
|
|
131
|
+
"""Atomic five-row executor-gate bundle."""
|
|
132
|
+
|
|
133
|
+
record: GateRecord
|
|
134
|
+
envelopes: tuple[Envelope, ...]
|
|
135
|
+
|
|
136
|
+
def __iter__(self) -> Iterator[Envelope]:
|
|
137
|
+
return iter(self.envelopes)
|
|
138
|
+
|
|
139
|
+
def __len__(self) -> int:
|
|
140
|
+
return len(self.envelopes)
|
|
141
|
+
|
|
142
|
+
def to_json(self) -> dict[str, Any]:
|
|
143
|
+
return {
|
|
144
|
+
"record": self.record.to_json(),
|
|
145
|
+
"envelopes": [env.to_json() for env in self.envelopes],
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
def verify(self, horizon: Horizon, source: Sequence[Envelope]) -> CheckResult:
|
|
149
|
+
return EnvelopeVerifier().verify(horizon, tuple(source) + self.envelopes)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ExecutorGate:
|
|
153
|
+
"""Before-use checker and atomic bundle producer."""
|
|
154
|
+
|
|
155
|
+
footprint = frozenset(
|
|
156
|
+
{
|
|
157
|
+
"ExecutorGate",
|
|
158
|
+
"FoldKernel",
|
|
159
|
+
"EnvelopeVerifier",
|
|
160
|
+
"ClauseKernel",
|
|
161
|
+
"ClockWatermark",
|
|
162
|
+
"RiskLedger",
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def __init__(self, fold_kernel: FoldKernel | None = None) -> None:
|
|
167
|
+
self.fold_kernel = fold_kernel or FoldKernel()
|
|
168
|
+
|
|
169
|
+
def check(self, horizon: Horizon, envelopes: Sequence[Envelope], request: GateRequest) -> CheckResult:
|
|
170
|
+
builder = CheckBuilder(footprint=set(self.footprint))
|
|
171
|
+
if request.source_time >= request.commit_time:
|
|
172
|
+
builder.error("gate-clock-order", "source time must be before commit time")
|
|
173
|
+
|
|
174
|
+
prefix = tuple(env for env in envelopes if env.commit_time <= request.source_time)
|
|
175
|
+
source_digest = digest_log(prefix)
|
|
176
|
+
if request.expected_source_digest is not None and request.expected_source_digest != source_digest:
|
|
177
|
+
builder.error(
|
|
178
|
+
"gate-source-digest",
|
|
179
|
+
"source-prefix digest does not match the gate request",
|
|
180
|
+
details={"expected": request.expected_source_digest, "actual": source_digest},
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
state = self.fold_kernel.fold(horizon, prefix)
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
builder.error("gate-source-fold", f"source prefix does not fold: {exc}")
|
|
187
|
+
return builder.result(digest=source_digest)
|
|
188
|
+
|
|
189
|
+
builder_result = builder.result(digest=source_digest)
|
|
190
|
+
semantic = self._check_state(state, request)
|
|
191
|
+
certificates = all_certificates_live(
|
|
192
|
+
state, request.required_certificate_ids, request.source_time, horizon=horizon
|
|
193
|
+
)
|
|
194
|
+
risk = check_risk_spend_live(
|
|
195
|
+
state,
|
|
196
|
+
risk_id=request.risk_id,
|
|
197
|
+
hypothesis_id=request.hypothesis_id,
|
|
198
|
+
mode=request.risk_mode,
|
|
199
|
+
cert_id=request.risk_cert_id,
|
|
200
|
+
at_time=request.source_time,
|
|
201
|
+
ledger_digest=request.ledger_digest,
|
|
202
|
+
horizon=horizon,
|
|
203
|
+
)
|
|
204
|
+
return builder_result.merge(check_well_audited(state), semantic, certificates, risk)
|
|
205
|
+
|
|
206
|
+
def create_bundle(
|
|
207
|
+
self,
|
|
208
|
+
horizon: Horizon,
|
|
209
|
+
envelopes: Sequence[Envelope],
|
|
210
|
+
request: GateRequest,
|
|
211
|
+
*,
|
|
212
|
+
writer: str = "executor-gate",
|
|
213
|
+
owner: str = "executor-gate",
|
|
214
|
+
version: int = 1,
|
|
215
|
+
) -> GateBundle:
|
|
216
|
+
result = self.check(horizon, envelopes, request)
|
|
217
|
+
if not result.ok:
|
|
218
|
+
messages = "; ".join(issue.message for issue in result.issues if issue.severity == "error")
|
|
219
|
+
raise ValueError(f"gate request rejected: {messages}")
|
|
220
|
+
|
|
221
|
+
source_prefix = tuple(env for env in envelopes if env.commit_time <= request.source_time)
|
|
222
|
+
source_digest = digest_log(source_prefix)
|
|
223
|
+
transcript_digest = result.digest or source_digest
|
|
224
|
+
record = GateRecord.from_request(request, source_digest=source_digest, transcript_digest=transcript_digest)
|
|
225
|
+
payloads: tuple[Mapping[str, Any], ...] = (
|
|
226
|
+
{
|
|
227
|
+
"kind": "GateCheck",
|
|
228
|
+
"gate_id": request.gate_id,
|
|
229
|
+
"bundle_id": request.bundle_id,
|
|
230
|
+
"frame_id": request.frame_id,
|
|
231
|
+
"action": request.action,
|
|
232
|
+
"source_digest": source_digest,
|
|
233
|
+
"request": request.to_json(),
|
|
234
|
+
"gate_record": record.to_json(),
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
"kind": "OutboxClaim",
|
|
238
|
+
"gate_id": request.gate_id,
|
|
239
|
+
"outbox_id": request.outbox_id,
|
|
240
|
+
"frame_id": request.frame_id,
|
|
241
|
+
"action": request.action,
|
|
242
|
+
"source_digest": source_digest,
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
"kind": "UseCap",
|
|
246
|
+
"capability_id": request.capability_id,
|
|
247
|
+
"outbox_id": request.outbox_id,
|
|
248
|
+
"frame_id": request.frame_id,
|
|
249
|
+
"action": request.action,
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
"kind": "ConsumeResource",
|
|
253
|
+
"lease_id": request.lease_id,
|
|
254
|
+
"frame_id": request.frame_id,
|
|
255
|
+
"amount": request.resource_amount,
|
|
256
|
+
"consumer": request.executor_id,
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
"kind": "RiskClose",
|
|
260
|
+
"risk_id": request.risk_id,
|
|
261
|
+
"hypothesis_id": request.hypothesis_id,
|
|
262
|
+
"frame_id": request.frame_id,
|
|
263
|
+
"ledger_digest": request.ledger_digest,
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
bundle = tuple(
|
|
267
|
+
Envelope(
|
|
268
|
+
eid=f"{request.bundle_id}:{index}",
|
|
269
|
+
event=f"{request.bundle_id}:{index}",
|
|
270
|
+
slot=str(index),
|
|
271
|
+
commit_time=request.commit_time,
|
|
272
|
+
writer=writer,
|
|
273
|
+
owner=owner,
|
|
274
|
+
version=version,
|
|
275
|
+
envelope_class=EnvelopeClass.NORMAL,
|
|
276
|
+
payload=payload,
|
|
277
|
+
commit_group=request.bundle_id,
|
|
278
|
+
)
|
|
279
|
+
for index, payload in enumerate(payloads)
|
|
280
|
+
)
|
|
281
|
+
gate_bundle = GateBundle(record=record, envelopes=bundle)
|
|
282
|
+
verify = gate_bundle.verify(horizon, envelopes)
|
|
283
|
+
if not verify.ok:
|
|
284
|
+
messages = "; ".join(issue.message for issue in verify.issues if issue.severity == "error")
|
|
285
|
+
raise ValueError(f"created gate bundle failed verification: {messages}")
|
|
286
|
+
return gate_bundle
|
|
287
|
+
|
|
288
|
+
def _check_state(self, state: FoldState, request: GateRequest) -> CheckResult:
|
|
289
|
+
builder = CheckBuilder(footprint=set(self.footprint))
|
|
290
|
+
frames = state.component("frames")
|
|
291
|
+
frame_record = frames.get(request.frame_id)
|
|
292
|
+
if frame_record is None:
|
|
293
|
+
builder.error(
|
|
294
|
+
"gate-frame-missing",
|
|
295
|
+
"frame is absent from source prefix",
|
|
296
|
+
location=request.frame_id,
|
|
297
|
+
)
|
|
298
|
+
return builder.result()
|
|
299
|
+
if frame_record.get("status") != Status.ACTIVE.value:
|
|
300
|
+
builder.error(
|
|
301
|
+
"gate-frame-inactive",
|
|
302
|
+
"external action requires an active frame",
|
|
303
|
+
location=request.frame_id,
|
|
304
|
+
details={"status": frame_record.get("status")},
|
|
305
|
+
)
|
|
306
|
+
frame = frame_record.get("frame", {})
|
|
307
|
+
if isinstance(frame, Mapping) and frame.get("actions") and request.action not in frame.get("actions", ()):
|
|
308
|
+
builder.error(
|
|
309
|
+
"gate-action-not-allowed",
|
|
310
|
+
"action is not in the frame action set",
|
|
311
|
+
location=request.action,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
cap = state.component("capabilities").get(request.capability_id)
|
|
315
|
+
if cap is None:
|
|
316
|
+
builder.error("gate-capability-missing", "capability is absent", location=request.capability_id)
|
|
317
|
+
else:
|
|
318
|
+
if cap.get("status") != "unused":
|
|
319
|
+
builder.error(
|
|
320
|
+
"gate-capability-not-live",
|
|
321
|
+
"capability is not unused",
|
|
322
|
+
location=request.capability_id,
|
|
323
|
+
)
|
|
324
|
+
if cap.get("frame_id") != request.frame_id or cap.get("action") != request.action:
|
|
325
|
+
builder.error(
|
|
326
|
+
"gate-capability-scope",
|
|
327
|
+
"capability scope does not match request",
|
|
328
|
+
location=request.capability_id,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
lease = state.component("resources").get(request.lease_id)
|
|
332
|
+
if lease is None:
|
|
333
|
+
builder.error("gate-resource-missing", "resource lease is absent", location=request.lease_id)
|
|
334
|
+
else:
|
|
335
|
+
if lease.get("status") != "leased":
|
|
336
|
+
builder.error(
|
|
337
|
+
"gate-resource-not-live",
|
|
338
|
+
"resource lease is not live",
|
|
339
|
+
location=request.lease_id,
|
|
340
|
+
)
|
|
341
|
+
if lease.get("frame_id") != request.frame_id:
|
|
342
|
+
builder.error(
|
|
343
|
+
"gate-resource-scope",
|
|
344
|
+
"resource lease frame does not match request",
|
|
345
|
+
location=request.lease_id,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
outbox = state.component("outboxes").get(request.outbox_id)
|
|
349
|
+
if outbox is None:
|
|
350
|
+
builder.error("gate-outbox-missing", "outbox authorization is absent", location=request.outbox_id)
|
|
351
|
+
else:
|
|
352
|
+
if outbox.get("status") != "authorized":
|
|
353
|
+
builder.error(
|
|
354
|
+
"gate-outbox-not-authorized",
|
|
355
|
+
"outbox is not in authorized state",
|
|
356
|
+
location=request.outbox_id,
|
|
357
|
+
)
|
|
358
|
+
if outbox.get("frame_id") != request.frame_id or outbox.get("action") != request.action:
|
|
359
|
+
builder.error(
|
|
360
|
+
"gate-outbox-scope",
|
|
361
|
+
"outbox scope does not match request",
|
|
362
|
+
location=request.outbox_id,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
return builder.result()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Finite branch join checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .digest import digest_json
|
|
10
|
+
from .fold import FoldKernel, FoldState
|
|
11
|
+
from .model import Envelope, Horizon
|
|
12
|
+
from .result import CheckBuilder, CheckResult
|
|
13
|
+
from .verifier import digest_log
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class JoinProposal:
|
|
18
|
+
branches: tuple[tuple[Envelope, ...], ...]
|
|
19
|
+
ancestor: tuple[Envelope, ...] = ()
|
|
20
|
+
repairs: tuple[Envelope, ...] = ()
|
|
21
|
+
affected_invariants: tuple[str, ...] = ()
|
|
22
|
+
repair_rechecks: tuple[str, ...] = ()
|
|
23
|
+
transcript_digest: str | None = None
|
|
24
|
+
|
|
25
|
+
def to_json(self) -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"branches": [[env.to_json() for env in branch] for branch in self.branches],
|
|
28
|
+
"ancestor": [env.to_json() for env in self.ancestor],
|
|
29
|
+
"repairs": [env.to_json() for env in self.repairs],
|
|
30
|
+
"affected_invariants": list(self.affected_invariants),
|
|
31
|
+
"repair_rechecks": list(self.repair_rechecks),
|
|
32
|
+
"transcript_digest": self.transcript_digest,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class JoinChecker:
|
|
37
|
+
"""Algebraic union plus default linear-resource checks via folding."""
|
|
38
|
+
|
|
39
|
+
footprint = frozenset({"JoinKernel", "FoldKernel", "EnvelopeVerifier", "ClauseKernel"})
|
|
40
|
+
|
|
41
|
+
def __init__(self, fold_kernel: FoldKernel | None = None) -> None:
|
|
42
|
+
self.fold_kernel = fold_kernel or FoldKernel()
|
|
43
|
+
|
|
44
|
+
def check(self, horizon: Horizon, proposal: JoinProposal) -> CheckResult:
|
|
45
|
+
builder = CheckBuilder(footprint=set(self.footprint))
|
|
46
|
+
self._check_common_ancestor(proposal, builder)
|
|
47
|
+
by_eid: dict[str, Envelope] = {}
|
|
48
|
+
for branch_index, branch in enumerate(proposal.branches):
|
|
49
|
+
for env in branch:
|
|
50
|
+
previous = by_eid.get(env.eid)
|
|
51
|
+
if previous is not None and digest_json(previous.to_json()) != digest_json(env.to_json()):
|
|
52
|
+
builder.error(
|
|
53
|
+
"join-eid-conflict",
|
|
54
|
+
"same envelope id has different contents across branches",
|
|
55
|
+
location=env.eid,
|
|
56
|
+
details={"branch": branch_index},
|
|
57
|
+
)
|
|
58
|
+
by_eid.setdefault(env.eid, env)
|
|
59
|
+
for repair in proposal.repairs:
|
|
60
|
+
previous = by_eid.get(repair.eid)
|
|
61
|
+
if previous is not None and digest_json(previous.to_json()) != digest_json(repair.to_json()):
|
|
62
|
+
builder.error(
|
|
63
|
+
"join-repair-conflict",
|
|
64
|
+
"repair envelope conflicts with existing id",
|
|
65
|
+
location=repair.eid,
|
|
66
|
+
)
|
|
67
|
+
by_eid[repair.eid] = repair
|
|
68
|
+
|
|
69
|
+
target = tuple(by_eid.values())
|
|
70
|
+
try:
|
|
71
|
+
folded = self.fold_kernel.fold(horizon, target)
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
builder.error("join-target-fold", f"joined target is not safe under default components: {exc}")
|
|
74
|
+
return builder.result(digest=digest_log(target))
|
|
75
|
+
self._check_repairs_folded(proposal, target, builder)
|
|
76
|
+
self._check_frame_invalidation(folded, proposal, builder)
|
|
77
|
+
|
|
78
|
+
return builder.result(digest=digest_log(target))
|
|
79
|
+
|
|
80
|
+
def _check_common_ancestor(self, proposal: JoinProposal, builder: CheckBuilder) -> None:
|
|
81
|
+
ancestor = {env.eid: digest_json(env.to_json()) for env in proposal.ancestor}
|
|
82
|
+
if not ancestor:
|
|
83
|
+
builder.error("join-ancestor-missing", "join proposal must cite a common ancestor")
|
|
84
|
+
return
|
|
85
|
+
for branch_index, branch in enumerate(proposal.branches):
|
|
86
|
+
branch_map = {env.eid: digest_json(env.to_json()) for env in branch}
|
|
87
|
+
for eid, digest in ancestor.items():
|
|
88
|
+
if branch_map.get(eid) != digest:
|
|
89
|
+
builder.error(
|
|
90
|
+
"join-ancestor",
|
|
91
|
+
"branch does not contain the exact common ancestor envelope",
|
|
92
|
+
location=eid,
|
|
93
|
+
details={"branch": branch_index},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _check_repairs_folded(
|
|
97
|
+
self, proposal: JoinProposal, target: tuple[Envelope, ...], builder: CheckBuilder
|
|
98
|
+
) -> None:
|
|
99
|
+
target_ids = {env.eid for env in target}
|
|
100
|
+
repair_ids = {env.eid for env in proposal.repairs}
|
|
101
|
+
for repair_id in repair_ids:
|
|
102
|
+
if repair_id not in target_ids:
|
|
103
|
+
builder.error("join-repair-folded", "repair row is not folded into the join target", location=repair_id)
|
|
104
|
+
for invariant in proposal.affected_invariants:
|
|
105
|
+
if invariant not in proposal.repair_rechecks:
|
|
106
|
+
builder.error(
|
|
107
|
+
"join-repair-recheck",
|
|
108
|
+
"affected invariant must be branch-stable or have an accepted repair recheck",
|
|
109
|
+
location=invariant,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _check_frame_invalidation(self, folded: FoldState, proposal: JoinProposal, builder: CheckBuilder) -> None:
|
|
113
|
+
components = folded.components
|
|
114
|
+
frames = components.get("frames", {})
|
|
115
|
+
non_active = {
|
|
116
|
+
frame_id
|
|
117
|
+
for frame_id, record in frames.items()
|
|
118
|
+
if record.get("status") in {"suspended", "invalid", "withdrawn"}
|
|
119
|
+
}
|
|
120
|
+
for frame_id in non_active:
|
|
121
|
+
for cap_id, cap in components.get("capabilities", {}).items():
|
|
122
|
+
if cap.get("frame_id") == frame_id and cap.get("status") == "unused":
|
|
123
|
+
builder.error(
|
|
124
|
+
"join-frame-invalidates-capability",
|
|
125
|
+
"join leaves a live cap for non-active frame",
|
|
126
|
+
location=cap_id,
|
|
127
|
+
)
|
|
128
|
+
for outbox_id, outbox in components.get("outboxes", {}).items():
|
|
129
|
+
if outbox.get("frame_id") == frame_id and outbox.get("status") == "authorized":
|
|
130
|
+
builder.error(
|
|
131
|
+
"join-frame-invalidates-outbox",
|
|
132
|
+
"join leaves an authorized outbox for non-active frame",
|
|
133
|
+
location=outbox_id,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def union_join(
|
|
138
|
+
horizon: Horizon,
|
|
139
|
+
branches: Sequence[Sequence[Envelope]],
|
|
140
|
+
repairs: Sequence[Envelope] = (),
|
|
141
|
+
ancestor: Sequence[Envelope] = (),
|
|
142
|
+
) -> CheckResult:
|
|
143
|
+
return JoinChecker().check(
|
|
144
|
+
horizon,
|
|
145
|
+
JoinProposal(
|
|
146
|
+
branches=tuple(tuple(branch) for branch in branches),
|
|
147
|
+
ancestor=tuple(ancestor),
|
|
148
|
+
repairs=tuple(repairs),
|
|
149
|
+
),
|
|
150
|
+
)
|