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,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
+ )