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.
@@ -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)