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,594 @@
|
|
|
1
|
+
"""Finite legal-log verification and canonical ordering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
7
|
+
from itertools import pairwise
|
|
8
|
+
|
|
9
|
+
from .digest import digest_json
|
|
10
|
+
from .model import DEFAULT_GATE_BUNDLE_KINDS, DependencyRef, Envelope, EnvelopeClass, Horizon, OrderEdge
|
|
11
|
+
from .result import CheckBuilder, CheckResult
|
|
12
|
+
from .security import scan_for_sensitive_data
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def digest_log(envelopes: Iterable[Envelope]) -> str:
|
|
16
|
+
"""Digest a log as an unordered finite set of envelopes."""
|
|
17
|
+
|
|
18
|
+
return digest_json(sorted((env.to_json() for env in envelopes), key=lambda item: item["eid"]))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EnvelopeVerifier:
|
|
22
|
+
"""Checker for the finite `LegalLog` conditions used by this package."""
|
|
23
|
+
|
|
24
|
+
footprint = frozenset({"EnvelopeVerifier", "ClockWatermark"})
|
|
25
|
+
|
|
26
|
+
def verify(self, horizon: Horizon, envelopes: Sequence[Envelope]) -> CheckResult:
|
|
27
|
+
builder = CheckBuilder(footprint=set(self.footprint))
|
|
28
|
+
self._check_horizon(horizon, builder)
|
|
29
|
+
self._check_required_manifest(horizon, builder)
|
|
30
|
+
id_map = self._check_unique_ids(envelopes, builder)
|
|
31
|
+
self._check_payload_security(horizon, envelopes, builder)
|
|
32
|
+
self._check_protected_slots(envelopes, builder)
|
|
33
|
+
self._check_event_projection(horizon, envelopes, builder)
|
|
34
|
+
self._check_dependencies(envelopes, id_map, builder)
|
|
35
|
+
self._check_commit_groups(horizon, envelopes, id_map, builder)
|
|
36
|
+
self._check_capacity(horizon, envelopes, builder)
|
|
37
|
+
self._check_writer_authority(horizon, envelopes, builder)
|
|
38
|
+
self._check_protected_constructor_authority(horizon, envelopes, builder)
|
|
39
|
+
self._check_versions(horizon, envelopes, builder)
|
|
40
|
+
self._check_gate_bundles(horizon, envelopes, builder)
|
|
41
|
+
try:
|
|
42
|
+
self.canonical_order(horizon, envelopes)
|
|
43
|
+
except ValueError as exc:
|
|
44
|
+
builder.error("canonical-order", str(exc))
|
|
45
|
+
return builder.result(digest=digest_log(envelopes))
|
|
46
|
+
|
|
47
|
+
def canonical_order(self, horizon: Horizon, envelopes: Sequence[Envelope]) -> tuple[Envelope, ...]:
|
|
48
|
+
"""Return the deterministic canonical linear extension.
|
|
49
|
+
|
|
50
|
+
Ordering constraints are audit-order event edges, explicit dependency
|
|
51
|
+
references, and declared commit-group sequence edges.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
if not envelopes:
|
|
55
|
+
return ()
|
|
56
|
+
|
|
57
|
+
id_map = {env.eid: env for env in envelopes}
|
|
58
|
+
env_by_event: dict[str, list[Envelope]] = defaultdict(list)
|
|
59
|
+
for env in envelopes:
|
|
60
|
+
env_by_event[env.event].append(env)
|
|
61
|
+
|
|
62
|
+
successors: dict[str, set[str]] = {env.eid: set() for env in envelopes}
|
|
63
|
+
indegree: dict[str, int] = {env.eid: 0 for env in envelopes}
|
|
64
|
+
|
|
65
|
+
def add_edge(before: str, after: str) -> None:
|
|
66
|
+
if before == after or after in successors[before]:
|
|
67
|
+
return
|
|
68
|
+
successors[before].add(after)
|
|
69
|
+
indegree[after] += 1
|
|
70
|
+
|
|
71
|
+
for edge in horizon.audit_order:
|
|
72
|
+
for before_env in env_by_event.get(edge.before, ()):
|
|
73
|
+
for after_env in env_by_event.get(edge.after, ()):
|
|
74
|
+
add_edge(before_env.eid, after_env.eid)
|
|
75
|
+
|
|
76
|
+
for env in envelopes:
|
|
77
|
+
for dep in env.dependencies:
|
|
78
|
+
for dep_eid in _matching_dependency_eids(dep, envelopes):
|
|
79
|
+
if dep_eid in id_map:
|
|
80
|
+
add_edge(dep_eid, env.eid)
|
|
81
|
+
|
|
82
|
+
for eids in horizon.commit_groups.values():
|
|
83
|
+
present = [eid for eid in eids if eid in id_map]
|
|
84
|
+
for before, after in pairwise(present):
|
|
85
|
+
add_edge(before, after)
|
|
86
|
+
|
|
87
|
+
event_rank = {event: index for index, event in enumerate(horizon.events)}
|
|
88
|
+
|
|
89
|
+
def priority(eid: str) -> tuple[int, int, str, str]:
|
|
90
|
+
env = id_map[eid]
|
|
91
|
+
return (env.commit_time, event_rank.get(env.event, len(event_rank)), env.slot, env.eid)
|
|
92
|
+
|
|
93
|
+
ready = sorted((eid for eid, degree in indegree.items() if degree == 0), key=priority)
|
|
94
|
+
ordered: list[str] = []
|
|
95
|
+
while ready:
|
|
96
|
+
eid = ready.pop(0)
|
|
97
|
+
ordered.append(eid)
|
|
98
|
+
for child in sorted(successors[eid], key=priority):
|
|
99
|
+
indegree[child] -= 1
|
|
100
|
+
if indegree[child] == 0:
|
|
101
|
+
ready.append(child)
|
|
102
|
+
ready.sort(key=priority)
|
|
103
|
+
|
|
104
|
+
if len(ordered) != len(envelopes):
|
|
105
|
+
cyclic = sorted(eid for eid, degree in indegree.items() if degree > 0)
|
|
106
|
+
raise ValueError(f"canonical extension does not exist; cyclic constraints: {cyclic}")
|
|
107
|
+
|
|
108
|
+
return tuple(id_map[eid] for eid in ordered)
|
|
109
|
+
|
|
110
|
+
def _check_horizon(self, horizon: Horizon, builder: CheckBuilder) -> None:
|
|
111
|
+
for name, edges in (
|
|
112
|
+
("causal_order", horizon.causal_order),
|
|
113
|
+
("availability_order", horizon.availability_order),
|
|
114
|
+
("audit_order", horizon.audit_order),
|
|
115
|
+
):
|
|
116
|
+
_check_acyclic(name, edges, builder)
|
|
117
|
+
if horizon.events and len(set(horizon.events)) != len(horizon.events):
|
|
118
|
+
builder.error("duplicate-events", "horizon event ids must be unique")
|
|
119
|
+
for cls, capacity in horizon.capacities.items():
|
|
120
|
+
if capacity < 0:
|
|
121
|
+
builder.error(
|
|
122
|
+
"negative-capacity",
|
|
123
|
+
"capacity must be non-negative",
|
|
124
|
+
details={"class": cls.value},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _check_required_manifest(self, horizon: Horizon, builder: CheckBuilder) -> None:
|
|
128
|
+
if not horizon.strict:
|
|
129
|
+
builder.warning(
|
|
130
|
+
"unsafe-manifest",
|
|
131
|
+
"manifest is marked non-strict; authority guarantees are not production-grade",
|
|
132
|
+
)
|
|
133
|
+
return
|
|
134
|
+
required = {
|
|
135
|
+
"capacities": bool(horizon.capacities),
|
|
136
|
+
"writer_authority": bool(horizon.writer_authority),
|
|
137
|
+
"version_intervals": bool(horizon.version_intervals),
|
|
138
|
+
"protected_constructors": bool(horizon.protected_constructors),
|
|
139
|
+
"gate_bundle_kinds": tuple(horizon.gate_bundle_kinds) == DEFAULT_GATE_BUNDLE_KINDS,
|
|
140
|
+
"clock_policy": bool(horizon.clock_policy),
|
|
141
|
+
"certificate_families": bool(horizon.certificate_families),
|
|
142
|
+
"risk_modes": bool(horizon.risk_modes),
|
|
143
|
+
}
|
|
144
|
+
for field, present in required.items():
|
|
145
|
+
if not present:
|
|
146
|
+
builder.error(
|
|
147
|
+
"incomplete-manifest",
|
|
148
|
+
"strict manifest is missing a required safety table",
|
|
149
|
+
location=field,
|
|
150
|
+
)
|
|
151
|
+
for kind in DEFAULT_GATE_BUNDLE_KINDS:
|
|
152
|
+
allowed = horizon.protected_constructors.get(kind)
|
|
153
|
+
if allowed != (horizon.executor_writer,):
|
|
154
|
+
builder.error(
|
|
155
|
+
"protected-constructor-policy",
|
|
156
|
+
"gate bundle constructors must be reserved to the executor writer",
|
|
157
|
+
location=kind,
|
|
158
|
+
details={"expected": horizon.executor_writer, "actual": list(allowed or ())},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _check_unique_ids(self, envelopes: Sequence[Envelope], builder: CheckBuilder) -> dict[str, Envelope]:
|
|
162
|
+
id_map: dict[str, Envelope] = {}
|
|
163
|
+
for index, env in enumerate(envelopes):
|
|
164
|
+
if not env.eid:
|
|
165
|
+
builder.error("empty-eid", "envelope id must be non-empty", location=f"envelopes[{index}]")
|
|
166
|
+
continue
|
|
167
|
+
if env.eid in id_map:
|
|
168
|
+
builder.error("duplicate-eid", "envelope ids must be unique", location=env.eid)
|
|
169
|
+
id_map[env.eid] = env
|
|
170
|
+
return id_map
|
|
171
|
+
|
|
172
|
+
def _check_payload_security(self, horizon: Horizon, envelopes: Sequence[Envelope], builder: CheckBuilder) -> None:
|
|
173
|
+
for env in envelopes:
|
|
174
|
+
for issue in scan_for_sensitive_data(env.payload, allow_local_paths=horizon.allow_local_paths):
|
|
175
|
+
builder.error(
|
|
176
|
+
"sensitive-payload",
|
|
177
|
+
"payload contains a secret-looking value or machine-local path",
|
|
178
|
+
location=f"{env.eid}:{issue.path}",
|
|
179
|
+
details=issue.to_json(),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _check_protected_slots(self, envelopes: Sequence[Envelope], builder: CheckBuilder) -> None:
|
|
183
|
+
seen: dict[tuple[str, str, str, str], str] = {}
|
|
184
|
+
for env in envelopes:
|
|
185
|
+
try:
|
|
186
|
+
slot = env.protected_slot
|
|
187
|
+
except ValueError as exc:
|
|
188
|
+
builder.error("payload-kind", str(exc), location=env.eid)
|
|
189
|
+
continue
|
|
190
|
+
previous = seen.get(slot)
|
|
191
|
+
if previous is not None:
|
|
192
|
+
builder.error(
|
|
193
|
+
"duplicate-protected-slot",
|
|
194
|
+
"protected slots must be unique",
|
|
195
|
+
location=env.eid,
|
|
196
|
+
details={"previous": previous, "slot": list(slot)},
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
seen[slot] = env.eid
|
|
200
|
+
|
|
201
|
+
def _check_event_projection(self, horizon: Horizon, envelopes: Sequence[Envelope], builder: CheckBuilder) -> None:
|
|
202
|
+
if horizon.events:
|
|
203
|
+
event_set = set(horizon.events)
|
|
204
|
+
for env in envelopes:
|
|
205
|
+
if env.event not in event_set:
|
|
206
|
+
builder.error("unknown-event", "envelope event is not in the horizon", location=env.eid)
|
|
207
|
+
|
|
208
|
+
projection = {env.event for env in envelopes}
|
|
209
|
+
for relation_name, edges in (
|
|
210
|
+
("causal", horizon.causal_order),
|
|
211
|
+
("availability", horizon.availability_order),
|
|
212
|
+
("audit", horizon.audit_order),
|
|
213
|
+
):
|
|
214
|
+
for edge in edges:
|
|
215
|
+
if edge.after in projection and edge.before not in projection:
|
|
216
|
+
builder.error(
|
|
217
|
+
"downset-violation",
|
|
218
|
+
"event projection is not downward closed",
|
|
219
|
+
details={
|
|
220
|
+
"relation": relation_name,
|
|
221
|
+
"missing": edge.before,
|
|
222
|
+
"required_by": edge.after,
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def _check_dependencies(
|
|
227
|
+
self,
|
|
228
|
+
envelopes: Sequence[Envelope],
|
|
229
|
+
id_map: Mapping[str, Envelope],
|
|
230
|
+
builder: CheckBuilder,
|
|
231
|
+
) -> None:
|
|
232
|
+
for env in envelopes:
|
|
233
|
+
for dep in env.dependencies:
|
|
234
|
+
matches = _matching_dependency_eids(dep, envelopes)
|
|
235
|
+
if dep.eid is not None and dep.eid not in id_map:
|
|
236
|
+
builder.error("missing-dependency", "dependency envelope id is absent", location=env.eid)
|
|
237
|
+
elif dep.eid is None and not matches:
|
|
238
|
+
builder.error(
|
|
239
|
+
"missing-dependency",
|
|
240
|
+
"dependency event/slot reference is absent",
|
|
241
|
+
location=env.eid,
|
|
242
|
+
details=dep.to_json(),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def _check_commit_groups(
|
|
246
|
+
self,
|
|
247
|
+
horizon: Horizon,
|
|
248
|
+
envelopes: Sequence[Envelope],
|
|
249
|
+
id_map: Mapping[str, Envelope],
|
|
250
|
+
builder: CheckBuilder,
|
|
251
|
+
) -> None:
|
|
252
|
+
grouped: dict[str, list[str]] = defaultdict(list)
|
|
253
|
+
for env in envelopes:
|
|
254
|
+
if env.commit_group:
|
|
255
|
+
grouped[env.commit_group].append(env.eid)
|
|
256
|
+
for group, present in grouped.items():
|
|
257
|
+
expected = horizon.commit_groups.get(group)
|
|
258
|
+
if expected is None:
|
|
259
|
+
if horizon.commit_groups:
|
|
260
|
+
builder.error(
|
|
261
|
+
"unknown-commit-group",
|
|
262
|
+
"commit group is not declared by the horizon",
|
|
263
|
+
location=group,
|
|
264
|
+
)
|
|
265
|
+
continue
|
|
266
|
+
missing = [eid for eid in expected if eid not in id_map]
|
|
267
|
+
extra = [eid for eid in present if eid not in expected]
|
|
268
|
+
if missing or extra:
|
|
269
|
+
builder.error(
|
|
270
|
+
"partial-commit-group",
|
|
271
|
+
"commit group must be fully present or absent",
|
|
272
|
+
location=group,
|
|
273
|
+
details={"missing": missing, "extra": extra},
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def _check_capacity(self, horizon: Horizon, envelopes: Sequence[Envelope], builder: CheckBuilder) -> None:
|
|
277
|
+
if not horizon.capacities:
|
|
278
|
+
return
|
|
279
|
+
counts = Counter(env.envelope_class for env in envelopes)
|
|
280
|
+
for cls in EnvelopeClass:
|
|
281
|
+
used = counts.get(cls, 0)
|
|
282
|
+
capacity = horizon.capacities.get(cls)
|
|
283
|
+
if capacity is not None and used > capacity:
|
|
284
|
+
builder.error(
|
|
285
|
+
"capacity-exceeded",
|
|
286
|
+
"envelope class capacity is exceeded",
|
|
287
|
+
details={"class": cls.value, "used": used, "capacity": capacity},
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _check_writer_authority(self, horizon: Horizon, envelopes: Sequence[Envelope], builder: CheckBuilder) -> None:
|
|
291
|
+
if not horizon.writer_authority:
|
|
292
|
+
return
|
|
293
|
+
for env in envelopes:
|
|
294
|
+
try:
|
|
295
|
+
kind = env.kind
|
|
296
|
+
except ValueError:
|
|
297
|
+
continue
|
|
298
|
+
allowed = horizon.writer_authority.get(kind, horizon.writer_authority.get("*"))
|
|
299
|
+
if allowed is None:
|
|
300
|
+
builder.error(
|
|
301
|
+
"unknown-writer-family",
|
|
302
|
+
"payload kind has no writer table entry",
|
|
303
|
+
location=env.eid,
|
|
304
|
+
)
|
|
305
|
+
continue
|
|
306
|
+
if env.writer not in allowed:
|
|
307
|
+
builder.error(
|
|
308
|
+
"writer-authority",
|
|
309
|
+
"writer is not authorized for this payload kind",
|
|
310
|
+
location=env.eid,
|
|
311
|
+
details={"kind": kind, "writer": env.writer, "allowed": list(allowed)},
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def _check_protected_constructor_authority(
|
|
315
|
+
self, horizon: Horizon, envelopes: Sequence[Envelope], builder: CheckBuilder
|
|
316
|
+
) -> None:
|
|
317
|
+
protected = dict(horizon.protected_constructors)
|
|
318
|
+
for env in envelopes:
|
|
319
|
+
try:
|
|
320
|
+
kind = env.kind
|
|
321
|
+
except ValueError:
|
|
322
|
+
continue
|
|
323
|
+
allowed = protected.get(kind)
|
|
324
|
+
if allowed is None:
|
|
325
|
+
continue
|
|
326
|
+
if env.writer not in allowed:
|
|
327
|
+
builder.error(
|
|
328
|
+
"protected-writer-authority",
|
|
329
|
+
"protected constructor was not emitted by its reserved writer",
|
|
330
|
+
location=env.eid,
|
|
331
|
+
details={"kind": kind, "writer": env.writer, "allowed": list(allowed)},
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def _check_versions(self, horizon: Horizon, envelopes: Sequence[Envelope], builder: CheckBuilder) -> None:
|
|
335
|
+
if not horizon.version_intervals:
|
|
336
|
+
return
|
|
337
|
+
for env in envelopes:
|
|
338
|
+
try:
|
|
339
|
+
kind = env.kind
|
|
340
|
+
except ValueError:
|
|
341
|
+
continue
|
|
342
|
+
interval = horizon.version_intervals.get(kind, horizon.version_intervals.get("*"))
|
|
343
|
+
if interval is None:
|
|
344
|
+
builder.error(
|
|
345
|
+
"unknown-version-family",
|
|
346
|
+
"payload kind has no version interval",
|
|
347
|
+
location=env.eid,
|
|
348
|
+
)
|
|
349
|
+
continue
|
|
350
|
+
if not interval.contains(env.version):
|
|
351
|
+
builder.error(
|
|
352
|
+
"version-out-of-range",
|
|
353
|
+
"envelope version is outside the accepted manifest interval",
|
|
354
|
+
location=env.eid,
|
|
355
|
+
details={
|
|
356
|
+
"version": env.version,
|
|
357
|
+
"minimum": interval.minimum,
|
|
358
|
+
"maximum": interval.maximum,
|
|
359
|
+
},
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def _check_gate_bundles(self, horizon: Horizon, envelopes: Sequence[Envelope], builder: CheckBuilder) -> None:
|
|
363
|
+
if not horizon.gate_bundle_kinds:
|
|
364
|
+
return
|
|
365
|
+
bundle_kinds = tuple(horizon.gate_bundle_kinds)
|
|
366
|
+
bundle_kind_set = set(bundle_kinds)
|
|
367
|
+
grouped: dict[str, list[Envelope]] = defaultdict(list)
|
|
368
|
+
for env in envelopes:
|
|
369
|
+
try:
|
|
370
|
+
kind = env.kind
|
|
371
|
+
except ValueError:
|
|
372
|
+
continue
|
|
373
|
+
if kind in bundle_kind_set:
|
|
374
|
+
if not env.commit_group:
|
|
375
|
+
builder.error(
|
|
376
|
+
"gate-bundle-missing-group",
|
|
377
|
+
"gate bundle envelope must belong to an atomic commit group",
|
|
378
|
+
location=env.eid,
|
|
379
|
+
)
|
|
380
|
+
continue
|
|
381
|
+
grouped[env.commit_group].append(env)
|
|
382
|
+
|
|
383
|
+
for group, rows in grouped.items():
|
|
384
|
+
self._check_one_gate_bundle(horizon, group, rows, envelopes, builder)
|
|
385
|
+
|
|
386
|
+
def _check_one_gate_bundle(
|
|
387
|
+
self,
|
|
388
|
+
horizon: Horizon,
|
|
389
|
+
group: str,
|
|
390
|
+
rows: Sequence[Envelope],
|
|
391
|
+
envelopes: Sequence[Envelope],
|
|
392
|
+
builder: CheckBuilder,
|
|
393
|
+
) -> None:
|
|
394
|
+
bundle_kinds = tuple(horizon.gate_bundle_kinds)
|
|
395
|
+
if len(rows) != len(bundle_kinds):
|
|
396
|
+
builder.error(
|
|
397
|
+
"gate-bundle-size",
|
|
398
|
+
"gate bundle must contain exactly the required five rows",
|
|
399
|
+
location=group,
|
|
400
|
+
details={"expected": list(bundle_kinds), "actual": [env.kind for env in rows]},
|
|
401
|
+
)
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
by_kind: dict[str, Envelope] = {}
|
|
405
|
+
for env in rows:
|
|
406
|
+
if env.kind in by_kind:
|
|
407
|
+
builder.error("gate-bundle-duplicate-kind", "gate bundle kind appears twice", location=env.eid)
|
|
408
|
+
by_kind[env.kind] = env
|
|
409
|
+
if env.writer != horizon.executor_writer:
|
|
410
|
+
builder.error(
|
|
411
|
+
"gate-bundle-writer",
|
|
412
|
+
"gate bundle row must be emitted by the executor writer",
|
|
413
|
+
location=env.eid,
|
|
414
|
+
details={"writer": env.writer, "expected": horizon.executor_writer},
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
missing = [kind for kind in bundle_kinds if kind not in by_kind]
|
|
418
|
+
if missing:
|
|
419
|
+
builder.error(
|
|
420
|
+
"gate-bundle-missing-kind",
|
|
421
|
+
"gate bundle is missing required rows",
|
|
422
|
+
location=group,
|
|
423
|
+
details={"missing": missing},
|
|
424
|
+
)
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
commit_times = {env.commit_time for env in rows}
|
|
428
|
+
if len(commit_times) != 1:
|
|
429
|
+
builder.error(
|
|
430
|
+
"gate-bundle-commit-time",
|
|
431
|
+
"gate bundle rows must share one commit time",
|
|
432
|
+
location=group,
|
|
433
|
+
details={"commit_times": sorted(commit_times)},
|
|
434
|
+
)
|
|
435
|
+
commit_time = next(iter(commit_times)) if commit_times else None
|
|
436
|
+
if horizon.strict and commit_time is not None:
|
|
437
|
+
interleaved = [
|
|
438
|
+
env.eid for env in envelopes if env.commit_time == commit_time and env.commit_group not in {group}
|
|
439
|
+
]
|
|
440
|
+
if interleaved:
|
|
441
|
+
builder.error(
|
|
442
|
+
"gate-bundle-interleaving",
|
|
443
|
+
"strict gate bundle commit time cannot contain unrelated visible writes",
|
|
444
|
+
location=group,
|
|
445
|
+
details={"interleaved": interleaved},
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
ordered = sorted(rows, key=lambda env: (env.commit_time, _slot_rank(env.slot), env.eid))
|
|
449
|
+
actual_order = [env.kind for env in ordered]
|
|
450
|
+
if actual_order != list(bundle_kinds):
|
|
451
|
+
builder.error(
|
|
452
|
+
"gate-bundle-order",
|
|
453
|
+
"gate bundle rows must appear in the required order",
|
|
454
|
+
location=group,
|
|
455
|
+
details={"expected": list(bundle_kinds), "actual": actual_order},
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
gate = by_kind["GateCheck"]
|
|
459
|
+
claim = by_kind["OutboxClaim"]
|
|
460
|
+
use = by_kind["UseCap"]
|
|
461
|
+
consume = by_kind["ConsumeResource"]
|
|
462
|
+
close = by_kind["RiskClose"]
|
|
463
|
+
request = gate.payload.get("request")
|
|
464
|
+
if not isinstance(request, Mapping):
|
|
465
|
+
builder.error("gate-bundle-request", "GateCheck row must bind the gate request", location=gate.eid)
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
expected_fields = {
|
|
469
|
+
"gate_id": gate.payload.get("gate_id"),
|
|
470
|
+
"bundle_id": gate.payload.get("bundle_id"),
|
|
471
|
+
"frame_id": gate.payload.get("frame_id"),
|
|
472
|
+
"action": gate.payload.get("action"),
|
|
473
|
+
"outbox_id": request.get("outbox_id"),
|
|
474
|
+
"capability_id": request.get("capability_id"),
|
|
475
|
+
"lease_id": request.get("lease_id"),
|
|
476
|
+
"risk_id": request.get("risk_id"),
|
|
477
|
+
"hypothesis_id": request.get("hypothesis_id"),
|
|
478
|
+
"ledger_digest": request.get("ledger_digest"),
|
|
479
|
+
"source_digest": gate.payload.get("source_digest"),
|
|
480
|
+
}
|
|
481
|
+
if expected_fields["bundle_id"] != group:
|
|
482
|
+
builder.error("gate-bundle-id", "GateCheck bundle id must match commit group", location=gate.eid)
|
|
483
|
+
if expected_fields["gate_id"] != request.get("gate_id"):
|
|
484
|
+
builder.error("gate-record-coherence", "GateCheck gate id must match request", location=gate.eid)
|
|
485
|
+
if expected_fields["frame_id"] != request.get("frame_id") or expected_fields["action"] != request.get("action"):
|
|
486
|
+
builder.error("gate-record-coherence", "GateCheck frame/action must match request", location=gate.eid)
|
|
487
|
+
|
|
488
|
+
checks = (
|
|
489
|
+
(
|
|
490
|
+
claim,
|
|
491
|
+
{
|
|
492
|
+
"gate_id": "gate_id",
|
|
493
|
+
"outbox_id": "outbox_id",
|
|
494
|
+
"frame_id": "frame_id",
|
|
495
|
+
"action": "action",
|
|
496
|
+
"source_digest": "source_digest",
|
|
497
|
+
},
|
|
498
|
+
),
|
|
499
|
+
(
|
|
500
|
+
use,
|
|
501
|
+
{
|
|
502
|
+
"capability_id": "capability_id",
|
|
503
|
+
"outbox_id": "outbox_id",
|
|
504
|
+
"frame_id": "frame_id",
|
|
505
|
+
"action": "action",
|
|
506
|
+
},
|
|
507
|
+
),
|
|
508
|
+
(consume, {"lease_id": "lease_id", "frame_id": "frame_id"}),
|
|
509
|
+
(
|
|
510
|
+
close,
|
|
511
|
+
{
|
|
512
|
+
"risk_id": "risk_id",
|
|
513
|
+
"hypothesis_id": "hypothesis_id",
|
|
514
|
+
"frame_id": "frame_id",
|
|
515
|
+
"ledger_digest": "ledger_digest",
|
|
516
|
+
},
|
|
517
|
+
),
|
|
518
|
+
)
|
|
519
|
+
for env, field_map in checks:
|
|
520
|
+
for payload_field, expected_key in field_map.items():
|
|
521
|
+
if env.payload.get(payload_field) != expected_fields[expected_key]:
|
|
522
|
+
builder.error(
|
|
523
|
+
"gate-bundle-coherence",
|
|
524
|
+
"gate bundle row does not match the GateCheck request tuple",
|
|
525
|
+
location=env.eid,
|
|
526
|
+
details={
|
|
527
|
+
"field": payload_field,
|
|
528
|
+
"expected": expected_fields[expected_key],
|
|
529
|
+
"actual": env.payload.get(payload_field),
|
|
530
|
+
},
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
if (
|
|
534
|
+
close.commit_time < claim.commit_time
|
|
535
|
+
or close.commit_time < use.commit_time
|
|
536
|
+
or close.commit_time < consume.commit_time
|
|
537
|
+
):
|
|
538
|
+
builder.error(
|
|
539
|
+
"gate-bundle-risk-close-order",
|
|
540
|
+
"risk close must not precede claim, capability use, or resource consume",
|
|
541
|
+
location=close.eid,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def legal_log(horizon: Horizon, envelopes: Sequence[Envelope]) -> CheckResult:
|
|
546
|
+
return EnvelopeVerifier().verify(horizon, envelopes)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def canonical_order(horizon: Horizon, envelopes: Sequence[Envelope]) -> tuple[Envelope, ...]:
|
|
550
|
+
return EnvelopeVerifier().canonical_order(horizon, envelopes)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _matching_dependency_eids(dep: DependencyRef, envelopes: Sequence[Envelope]) -> tuple[str, ...]:
|
|
554
|
+
if dep.eid is not None:
|
|
555
|
+
return (dep.eid,)
|
|
556
|
+
matches = []
|
|
557
|
+
for env in envelopes:
|
|
558
|
+
if dep.event is not None and env.event != dep.event:
|
|
559
|
+
continue
|
|
560
|
+
if dep.slot is not None and env.slot != dep.slot:
|
|
561
|
+
continue
|
|
562
|
+
matches.append(env.eid)
|
|
563
|
+
return tuple(matches)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _check_acyclic(name: str, edges: Sequence[OrderEdge], builder: CheckBuilder) -> None:
|
|
567
|
+
graph: dict[str, set[str]] = defaultdict(set)
|
|
568
|
+
indegree: dict[str, int] = defaultdict(int)
|
|
569
|
+
nodes: set[str] = set()
|
|
570
|
+
for edge in edges:
|
|
571
|
+
nodes.update((edge.before, edge.after))
|
|
572
|
+
if edge.after not in graph[edge.before]:
|
|
573
|
+
graph[edge.before].add(edge.after)
|
|
574
|
+
indegree[edge.after] += 1
|
|
575
|
+
indegree.setdefault(edge.before, indegree.get(edge.before, 0))
|
|
576
|
+
|
|
577
|
+
ready = [node for node in nodes if indegree.get(node, 0) == 0]
|
|
578
|
+
visited = 0
|
|
579
|
+
while ready:
|
|
580
|
+
node = ready.pop()
|
|
581
|
+
visited += 1
|
|
582
|
+
for child in graph[node]:
|
|
583
|
+
indegree[child] -= 1
|
|
584
|
+
if indegree[child] == 0:
|
|
585
|
+
ready.append(child)
|
|
586
|
+
if visited != len(nodes):
|
|
587
|
+
builder.error("cyclic-order", "horizon order relation must be acyclic", location=name)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _slot_rank(slot: str) -> tuple[int, str]:
|
|
591
|
+
try:
|
|
592
|
+
return (int(slot), slot)
|
|
593
|
+
except ValueError:
|
|
594
|
+
return (10_000, slot)
|