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,441 @@
|
|
|
1
|
+
"""Portable data model for finite audit logs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
JsonMap = Mapping[str, Any]
|
|
11
|
+
DEFAULT_GATE_BUNDLE_KINDS = ("GateCheck", "OutboxClaim", "UseCap", "ConsumeResource", "RiskClose")
|
|
12
|
+
DEFAULT_RISK_MODES = ("fixed", "selectedEvent", "conditionalSelective", "anytime")
|
|
13
|
+
DEFAULT_PROTECTED_CONSTRUCTORS = {
|
|
14
|
+
"GateCheck": ("executor-gate",),
|
|
15
|
+
"OutboxClaim": ("executor-gate",),
|
|
16
|
+
"UseCap": ("executor-gate",),
|
|
17
|
+
"ConsumeResource": ("executor-gate",),
|
|
18
|
+
"RiskClose": ("executor-gate",),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EnvelopeClass(str, Enum):
|
|
23
|
+
NORMAL = "normal"
|
|
24
|
+
ABORT = "abort"
|
|
25
|
+
FAIL_CLOSED = "failClosed"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ClauseRole(str, Enum):
|
|
29
|
+
INVARIANT = "invariant"
|
|
30
|
+
LIVENESS = "liveness"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Status(str, Enum):
|
|
34
|
+
INACTIVE = "inactive"
|
|
35
|
+
ACTIVE = "active"
|
|
36
|
+
DIAGNOSTIC_ACTIVE = "diagnosticActive"
|
|
37
|
+
SUSPENDED = "suspended"
|
|
38
|
+
INVALID = "invalid"
|
|
39
|
+
BLOCKED = "blocked"
|
|
40
|
+
WITHDRAWN = "withdrawn"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class OrderEdge:
|
|
45
|
+
before: str
|
|
46
|
+
after: str
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_value(cls, value: Sequence[str] | Mapping[str, str]) -> OrderEdge:
|
|
50
|
+
if isinstance(value, Mapping):
|
|
51
|
+
return cls(str(value["before"]), str(value["after"]))
|
|
52
|
+
if len(value) != 2:
|
|
53
|
+
raise ValueError("order edge must contain exactly two event ids")
|
|
54
|
+
return cls(str(value[0]), str(value[1]))
|
|
55
|
+
|
|
56
|
+
def to_json(self) -> list[str]:
|
|
57
|
+
return [self.before, self.after]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class VersionInterval:
|
|
62
|
+
minimum: int = 1
|
|
63
|
+
maximum: int = 1
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_value(cls, value: Sequence[int] | Mapping[str, int]) -> VersionInterval:
|
|
67
|
+
if isinstance(value, Mapping):
|
|
68
|
+
return cls(
|
|
69
|
+
int(value.get("minimum", value.get("min", 1))),
|
|
70
|
+
int(value.get("maximum", value.get("max", 1))),
|
|
71
|
+
)
|
|
72
|
+
if len(value) != 2:
|
|
73
|
+
raise ValueError("version interval must contain two bounds")
|
|
74
|
+
return cls(int(value[0]), int(value[1]))
|
|
75
|
+
|
|
76
|
+
def contains(self, version: int) -> bool:
|
|
77
|
+
return self.minimum <= version <= self.maximum
|
|
78
|
+
|
|
79
|
+
def to_json(self) -> dict[str, int]:
|
|
80
|
+
return {"minimum": self.minimum, "maximum": self.maximum}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class DependencyRef:
|
|
85
|
+
"""A dependency by envelope id, or by event plus slot."""
|
|
86
|
+
|
|
87
|
+
eid: str | None = None
|
|
88
|
+
event: str | None = None
|
|
89
|
+
slot: str | None = None
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_value(cls, value: str | Mapping[str, Any]) -> DependencyRef:
|
|
93
|
+
if isinstance(value, str):
|
|
94
|
+
return cls(eid=value)
|
|
95
|
+
return cls(
|
|
96
|
+
eid=str(value["eid"]) if value.get("eid") is not None else None,
|
|
97
|
+
event=str(value["event"]) if value.get("event") is not None else None,
|
|
98
|
+
slot=str(value["slot"]) if value.get("slot") is not None else None,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def to_json(self) -> dict[str, str]:
|
|
102
|
+
data: dict[str, str] = {}
|
|
103
|
+
if self.eid is not None:
|
|
104
|
+
data["eid"] = self.eid
|
|
105
|
+
if self.event is not None:
|
|
106
|
+
data["event"] = self.event
|
|
107
|
+
if self.slot is not None:
|
|
108
|
+
data["slot"] = self.slot
|
|
109
|
+
return data
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True, slots=True)
|
|
113
|
+
class Envelope:
|
|
114
|
+
"""One append-only audit-log envelope."""
|
|
115
|
+
|
|
116
|
+
eid: str
|
|
117
|
+
event: str
|
|
118
|
+
slot: str
|
|
119
|
+
commit_time: int
|
|
120
|
+
writer: str
|
|
121
|
+
owner: str
|
|
122
|
+
version: int
|
|
123
|
+
envelope_class: EnvelopeClass
|
|
124
|
+
payload: JsonMap
|
|
125
|
+
dependencies: tuple[DependencyRef, ...] = ()
|
|
126
|
+
commit_group: str | None = None
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def from_mapping(cls, value: Mapping[str, Any]) -> Envelope:
|
|
130
|
+
payload = value.get("payload")
|
|
131
|
+
if not isinstance(payload, Mapping):
|
|
132
|
+
raise ValueError("envelope payload must be a JSON object")
|
|
133
|
+
return cls(
|
|
134
|
+
eid=str(value["eid"]),
|
|
135
|
+
event=str(value["event"]),
|
|
136
|
+
slot=str(value.get("slot", value.get("lambda", ""))),
|
|
137
|
+
commit_time=int(value.get("commit_time", value.get("commit", 0))),
|
|
138
|
+
writer=str(value["writer"]),
|
|
139
|
+
owner=str(value.get("owner", "")),
|
|
140
|
+
version=int(value.get("version", 1)),
|
|
141
|
+
envelope_class=EnvelopeClass(value.get("class", value.get("envelope_class", "normal"))),
|
|
142
|
+
payload=dict(payload),
|
|
143
|
+
dependencies=tuple(DependencyRef.from_value(dep) for dep in value.get("dependencies", ())),
|
|
144
|
+
commit_group=str(value["commit_group"]) if value.get("commit_group") is not None else None,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def kind(self) -> str:
|
|
149
|
+
kind = self.payload.get("kind")
|
|
150
|
+
if not isinstance(kind, str) or not kind:
|
|
151
|
+
raise ValueError(f"envelope {self.eid} payload.kind must be a non-empty string")
|
|
152
|
+
return kind
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def object_key(self) -> str:
|
|
156
|
+
for key in (
|
|
157
|
+
"object",
|
|
158
|
+
"object_id",
|
|
159
|
+
"frame_id",
|
|
160
|
+
"cert_id",
|
|
161
|
+
"capability_id",
|
|
162
|
+
"risk_id",
|
|
163
|
+
"outbox_id",
|
|
164
|
+
"lease_id",
|
|
165
|
+
"evidence_id",
|
|
166
|
+
"source_id",
|
|
167
|
+
"gate_id",
|
|
168
|
+
"bundle_id",
|
|
169
|
+
):
|
|
170
|
+
value = self.payload.get(key)
|
|
171
|
+
if value is not None:
|
|
172
|
+
return str(value)
|
|
173
|
+
return ""
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def protected_slot(self) -> tuple[str, str, str, str]:
|
|
177
|
+
return (self.event, self.slot, self.kind, self.object_key)
|
|
178
|
+
|
|
179
|
+
def to_json(self) -> dict[str, Any]:
|
|
180
|
+
data: dict[str, Any] = {
|
|
181
|
+
"eid": self.eid,
|
|
182
|
+
"event": self.event,
|
|
183
|
+
"slot": self.slot,
|
|
184
|
+
"commit": self.commit_time,
|
|
185
|
+
"writer": self.writer,
|
|
186
|
+
"owner": self.owner,
|
|
187
|
+
"version": self.version,
|
|
188
|
+
"class": self.envelope_class.value,
|
|
189
|
+
"payload": dict(self.payload),
|
|
190
|
+
}
|
|
191
|
+
if self.dependencies:
|
|
192
|
+
data["dependencies"] = [dep.to_json() for dep in self.dependencies]
|
|
193
|
+
if self.commit_group:
|
|
194
|
+
data["commit_group"] = self.commit_group
|
|
195
|
+
return data
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True, slots=True)
|
|
199
|
+
class Horizon:
|
|
200
|
+
"""Finite verifier configuration.
|
|
201
|
+
|
|
202
|
+
This is strict by default. Empty safety tables are rejected by the verifier.
|
|
203
|
+
Use :meth:`unsafe_for_tests` only for deliberately weak unit fixtures.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
strict: bool = True
|
|
207
|
+
events: tuple[str, ...] = ()
|
|
208
|
+
causal_order: tuple[OrderEdge, ...] = ()
|
|
209
|
+
availability_order: tuple[OrderEdge, ...] = ()
|
|
210
|
+
audit_order: tuple[OrderEdge, ...] = ()
|
|
211
|
+
capacities: Mapping[EnvelopeClass, int] = field(default_factory=dict)
|
|
212
|
+
writer_authority: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
|
|
213
|
+
version_intervals: Mapping[str, VersionInterval] = field(default_factory=dict)
|
|
214
|
+
commit_groups: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
|
|
215
|
+
protected_constructors: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
|
|
216
|
+
gate_bundle_kinds: tuple[str, ...] = DEFAULT_GATE_BUNDLE_KINDS
|
|
217
|
+
executor_writer: str = "executor-gate"
|
|
218
|
+
clock_policy: str = "integer-commit-time"
|
|
219
|
+
certificate_families: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
|
|
220
|
+
risk_modes: tuple[str, ...] = DEFAULT_RISK_MODES
|
|
221
|
+
env_assumptions: tuple[str, ...] = ()
|
|
222
|
+
codebook: tuple[str, ...] = ()
|
|
223
|
+
allow_local_paths: bool = False
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def from_mapping(cls, value: Mapping[str, Any]) -> Horizon:
|
|
227
|
+
return cls(
|
|
228
|
+
strict=bool(value.get("strict", True)),
|
|
229
|
+
events=tuple(str(v) for v in value.get("events", ())),
|
|
230
|
+
causal_order=_edges(value.get("causal_order", ())),
|
|
231
|
+
availability_order=_edges(value.get("availability_order", ())),
|
|
232
|
+
audit_order=_edges(value.get("audit_order", ())),
|
|
233
|
+
capacities={EnvelopeClass(k): int(v) for k, v in value.get("capacities", {}).items()},
|
|
234
|
+
writer_authority={
|
|
235
|
+
str(k): tuple(str(writer) for writer in writers)
|
|
236
|
+
for k, writers in value.get("writer_authority", {}).items()
|
|
237
|
+
},
|
|
238
|
+
version_intervals={
|
|
239
|
+
str(k): VersionInterval.from_value(v) for k, v in value.get("version_intervals", {}).items()
|
|
240
|
+
},
|
|
241
|
+
commit_groups={
|
|
242
|
+
str(k): tuple(str(eid) for eid in eids) for k, eids in value.get("commit_groups", {}).items()
|
|
243
|
+
},
|
|
244
|
+
protected_constructors={
|
|
245
|
+
str(k): tuple(str(writer) for writer in writers)
|
|
246
|
+
for k, writers in value.get("protected_constructors", {}).items()
|
|
247
|
+
},
|
|
248
|
+
gate_bundle_kinds=tuple(str(v) for v in value.get("gate_bundle_kinds", DEFAULT_GATE_BUNDLE_KINDS)),
|
|
249
|
+
executor_writer=str(value.get("executor_writer", "executor-gate")),
|
|
250
|
+
clock_policy=str(value.get("clock_policy", "integer-commit-time")),
|
|
251
|
+
certificate_families={
|
|
252
|
+
str(k): tuple(str(issuer) for issuer in issuers)
|
|
253
|
+
for k, issuers in value.get("certificate_families", {}).items()
|
|
254
|
+
},
|
|
255
|
+
risk_modes=tuple(str(v) for v in value.get("risk_modes", DEFAULT_RISK_MODES)),
|
|
256
|
+
env_assumptions=tuple(str(v) for v in value.get("env_assumptions", ())),
|
|
257
|
+
codebook=tuple(str(v) for v in value.get("codebook", ())),
|
|
258
|
+
allow_local_paths=bool(value.get("allow_local_paths", False)),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
@classmethod
|
|
262
|
+
def strict_default(
|
|
263
|
+
cls,
|
|
264
|
+
*,
|
|
265
|
+
agent_writers: tuple[str, ...] = ("agent",),
|
|
266
|
+
executor_writer: str = "executor-gate",
|
|
267
|
+
normal_capacity: int = 1000,
|
|
268
|
+
abort_capacity: int = 100,
|
|
269
|
+
fail_closed_capacity: int = 10,
|
|
270
|
+
) -> Horizon:
|
|
271
|
+
"""Return a strict manifest suitable for examples and production templates."""
|
|
272
|
+
|
|
273
|
+
protected = dict.fromkeys(DEFAULT_GATE_BUNDLE_KINDS, (executor_writer,))
|
|
274
|
+
writer_authority: dict[str, tuple[str, ...]] = {"*": (*agent_writers, executor_writer)}
|
|
275
|
+
writer_authority.update(protected)
|
|
276
|
+
return cls(
|
|
277
|
+
strict=True,
|
|
278
|
+
capacities={
|
|
279
|
+
EnvelopeClass.NORMAL: normal_capacity,
|
|
280
|
+
EnvelopeClass.ABORT: abort_capacity,
|
|
281
|
+
EnvelopeClass.FAIL_CLOSED: fail_closed_capacity,
|
|
282
|
+
},
|
|
283
|
+
writer_authority=writer_authority,
|
|
284
|
+
version_intervals={"*": VersionInterval(1, 1)},
|
|
285
|
+
protected_constructors=protected,
|
|
286
|
+
gate_bundle_kinds=DEFAULT_GATE_BUNDLE_KINDS,
|
|
287
|
+
executor_writer=executor_writer,
|
|
288
|
+
certificate_families={
|
|
289
|
+
"source": agent_writers,
|
|
290
|
+
"risk": agent_writers,
|
|
291
|
+
"approval": agent_writers,
|
|
292
|
+
"safety": agent_writers,
|
|
293
|
+
"formation": agent_writers,
|
|
294
|
+
},
|
|
295
|
+
risk_modes=DEFAULT_RISK_MODES,
|
|
296
|
+
codebook=DEFAULT_RISK_MODES,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
@classmethod
|
|
300
|
+
def unsafe_for_tests(cls) -> Horizon:
|
|
301
|
+
"""Return a deliberately weak manifest for tests that do not model authority."""
|
|
302
|
+
|
|
303
|
+
return cls(
|
|
304
|
+
strict=False,
|
|
305
|
+
protected_constructors={},
|
|
306
|
+
gate_bundle_kinds=(),
|
|
307
|
+
allow_local_paths=False,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def to_json(self) -> dict[str, Any]:
|
|
311
|
+
return {
|
|
312
|
+
"strict": self.strict,
|
|
313
|
+
"events": list(self.events),
|
|
314
|
+
"causal_order": [edge.to_json() for edge in self.causal_order],
|
|
315
|
+
"availability_order": [edge.to_json() for edge in self.availability_order],
|
|
316
|
+
"audit_order": [edge.to_json() for edge in self.audit_order],
|
|
317
|
+
"capacities": {key.value: value for key, value in self.capacities.items()},
|
|
318
|
+
"writer_authority": {key: list(value) for key, value in self.writer_authority.items()},
|
|
319
|
+
"version_intervals": {key: value.to_json() for key, value in self.version_intervals.items()},
|
|
320
|
+
"commit_groups": {key: list(value) for key, value in self.commit_groups.items()},
|
|
321
|
+
"protected_constructors": {key: list(value) for key, value in self.protected_constructors.items()},
|
|
322
|
+
"gate_bundle_kinds": list(self.gate_bundle_kinds),
|
|
323
|
+
"executor_writer": self.executor_writer,
|
|
324
|
+
"clock_policy": self.clock_policy,
|
|
325
|
+
"certificate_families": {key: list(value) for key, value in self.certificate_families.items()},
|
|
326
|
+
"risk_modes": list(self.risk_modes),
|
|
327
|
+
"env_assumptions": list(self.env_assumptions),
|
|
328
|
+
"codebook": list(self.codebook),
|
|
329
|
+
"allow_local_paths": self.allow_local_paths,
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass(frozen=True, slots=True)
|
|
334
|
+
class StrictManifest(Horizon):
|
|
335
|
+
"""Named strict manifest type for public APIs."""
|
|
336
|
+
|
|
337
|
+
@classmethod
|
|
338
|
+
def minimal(cls, **kwargs: Any) -> StrictManifest:
|
|
339
|
+
horizon = Horizon.strict_default(**kwargs)
|
|
340
|
+
return cls.from_mapping(horizon.to_json())
|
|
341
|
+
|
|
342
|
+
@classmethod
|
|
343
|
+
def from_mapping(cls, value: Mapping[str, Any]) -> StrictManifest:
|
|
344
|
+
horizon = Horizon.from_mapping(value)
|
|
345
|
+
data = horizon.to_json()
|
|
346
|
+
data["strict"] = True
|
|
347
|
+
return cls(
|
|
348
|
+
strict=True,
|
|
349
|
+
events=tuple(data["events"]),
|
|
350
|
+
causal_order=_edges(data["causal_order"]),
|
|
351
|
+
availability_order=_edges(data["availability_order"]),
|
|
352
|
+
audit_order=_edges(data["audit_order"]),
|
|
353
|
+
capacities={EnvelopeClass(k): int(v) for k, v in data["capacities"].items()},
|
|
354
|
+
writer_authority={k: tuple(v) for k, v in data["writer_authority"].items()},
|
|
355
|
+
version_intervals={k: VersionInterval.from_value(v) for k, v in data["version_intervals"].items()},
|
|
356
|
+
commit_groups={k: tuple(v) for k, v in data["commit_groups"].items()},
|
|
357
|
+
protected_constructors={k: tuple(v) for k, v in data["protected_constructors"].items()},
|
|
358
|
+
gate_bundle_kinds=tuple(data["gate_bundle_kinds"]),
|
|
359
|
+
executor_writer=str(data["executor_writer"]),
|
|
360
|
+
clock_policy=str(data["clock_policy"]),
|
|
361
|
+
certificate_families={k: tuple(v) for k, v in data["certificate_families"].items()},
|
|
362
|
+
risk_modes=tuple(data["risk_modes"]),
|
|
363
|
+
env_assumptions=tuple(data["env_assumptions"]),
|
|
364
|
+
codebook=tuple(data["codebook"]),
|
|
365
|
+
allow_local_paths=bool(data["allow_local_paths"]),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@dataclass(frozen=True, slots=True)
|
|
370
|
+
class Frame:
|
|
371
|
+
"""A bounded decision or problem frame controlled by the audit log."""
|
|
372
|
+
|
|
373
|
+
frame_id: str
|
|
374
|
+
scope: str
|
|
375
|
+
goal: str
|
|
376
|
+
evidence_ids: tuple[str, ...]
|
|
377
|
+
actions: tuple[str, ...]
|
|
378
|
+
acceptance: tuple[str, ...]
|
|
379
|
+
resources: tuple[str, ...] = ()
|
|
380
|
+
risk_ids: tuple[str, ...] = ()
|
|
381
|
+
obligations: tuple[str, ...] = ()
|
|
382
|
+
provenance: Mapping[str, Any] = field(default_factory=dict)
|
|
383
|
+
|
|
384
|
+
@classmethod
|
|
385
|
+
def from_payload(cls, payload: Mapping[str, Any]) -> Frame:
|
|
386
|
+
return cls(
|
|
387
|
+
frame_id=str(payload.get("frame_id", payload.get("object", ""))),
|
|
388
|
+
scope=str(payload.get("scope", "")),
|
|
389
|
+
goal=str(payload.get("goal", "")),
|
|
390
|
+
evidence_ids=tuple(str(v) for v in payload.get("evidence_ids", ())),
|
|
391
|
+
actions=tuple(str(v) for v in payload.get("actions", ())),
|
|
392
|
+
acceptance=tuple(str(v) for v in payload.get("acceptance", ())),
|
|
393
|
+
resources=tuple(str(v) for v in payload.get("resources", ())),
|
|
394
|
+
risk_ids=tuple(str(v) for v in payload.get("risk_ids", ())),
|
|
395
|
+
obligations=tuple(str(v) for v in payload.get("obligations", ())),
|
|
396
|
+
provenance=dict(payload.get("provenance", {})),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def to_json(self) -> dict[str, Any]:
|
|
400
|
+
return {
|
|
401
|
+
"frame_id": self.frame_id,
|
|
402
|
+
"scope": self.scope,
|
|
403
|
+
"goal": self.goal,
|
|
404
|
+
"evidence_ids": list(self.evidence_ids),
|
|
405
|
+
"actions": list(self.actions),
|
|
406
|
+
"acceptance": list(self.acceptance),
|
|
407
|
+
"resources": list(self.resources),
|
|
408
|
+
"risk_ids": list(self.risk_ids),
|
|
409
|
+
"obligations": list(self.obligations),
|
|
410
|
+
"provenance": dict(self.provenance),
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@dataclass(frozen=True, slots=True)
|
|
415
|
+
class AuditTranscript:
|
|
416
|
+
"""Finite record of objects read by a checker."""
|
|
417
|
+
|
|
418
|
+
checker: str
|
|
419
|
+
objects: tuple[str, ...] = ()
|
|
420
|
+
reads: tuple[str, ...] = ()
|
|
421
|
+
frontiers: tuple[str, ...] = ()
|
|
422
|
+
clocks: tuple[str, ...] = ()
|
|
423
|
+
capacities: tuple[str, ...] = ()
|
|
424
|
+
digests: tuple[str, ...] = ()
|
|
425
|
+
swaps: tuple[str, ...] = ()
|
|
426
|
+
|
|
427
|
+
def to_json(self) -> dict[str, Any]:
|
|
428
|
+
return {
|
|
429
|
+
"checker": self.checker,
|
|
430
|
+
"objects": list(self.objects),
|
|
431
|
+
"reads": list(self.reads),
|
|
432
|
+
"frontiers": list(self.frontiers),
|
|
433
|
+
"clocks": list(self.clocks),
|
|
434
|
+
"capacities": list(self.capacities),
|
|
435
|
+
"digests": list(self.digests),
|
|
436
|
+
"swaps": list(self.swaps),
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _edges(values: Sequence[Any]) -> tuple[OrderEdge, ...]:
|
|
441
|
+
return tuple(OrderEdge.from_value(value) for value in values)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Append-only patch installation checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .fold import FoldKernel, FoldState
|
|
10
|
+
from .model import Envelope, Horizon
|
|
11
|
+
from .result import CheckBuilder, CheckResult
|
|
12
|
+
from .verifier import digest_log
|
|
13
|
+
|
|
14
|
+
Invariant = Callable[[FoldState, Sequence[Envelope]], CheckResult]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class ReadFootprint:
|
|
19
|
+
invariant: str
|
|
20
|
+
entries: tuple[str, ...]
|
|
21
|
+
|
|
22
|
+
def to_json(self) -> dict[str, Any]:
|
|
23
|
+
return {"invariant": self.invariant, "entries": list(self.entries)}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class WriteClass:
|
|
28
|
+
name: str
|
|
29
|
+
object_id: str = ""
|
|
30
|
+
|
|
31
|
+
def to_json(self) -> dict[str, str]:
|
|
32
|
+
return {"name": self.name, "object_id": self.object_id}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class TouchMatrix:
|
|
37
|
+
cells: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def key(write_class: WriteClass, read_entry: str) -> str:
|
|
41
|
+
return f"{write_class.name}:{write_class.object_id}|{read_entry}"
|
|
42
|
+
|
|
43
|
+
def verdict(self, write_class: WriteClass, read_entry: str) -> str | None:
|
|
44
|
+
return self.cells.get(self.key(write_class, read_entry))
|
|
45
|
+
|
|
46
|
+
def to_json(self) -> dict[str, str]:
|
|
47
|
+
return dict(self.cells)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class AffectedClauseSet:
|
|
52
|
+
clauses: tuple[str, ...]
|
|
53
|
+
|
|
54
|
+
def to_json(self) -> list[str]:
|
|
55
|
+
return list(self.clauses)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class PatchProposal:
|
|
60
|
+
expected_source_digest: str
|
|
61
|
+
append: tuple[Envelope, ...]
|
|
62
|
+
affected_invariants: tuple[str, ...] = ()
|
|
63
|
+
write_classes: tuple[WriteClass, ...] = ()
|
|
64
|
+
read_footprints: tuple[ReadFootprint, ...] = ()
|
|
65
|
+
touch_matrix: TouchMatrix = field(default_factory=TouchMatrix)
|
|
66
|
+
transcript_digest: str | None = None
|
|
67
|
+
|
|
68
|
+
def to_json(self) -> dict[str, object]:
|
|
69
|
+
return {
|
|
70
|
+
"expected_source_digest": self.expected_source_digest,
|
|
71
|
+
"append": [env.to_json() for env in self.append],
|
|
72
|
+
"affected_invariants": list(self.affected_invariants),
|
|
73
|
+
"write_classes": [write.to_json() for write in self.write_classes],
|
|
74
|
+
"read_footprints": [read.to_json() for read in self.read_footprints],
|
|
75
|
+
"touch_matrix": self.touch_matrix.to_json(),
|
|
76
|
+
"transcript_digest": self.transcript_digest,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PatchChecker:
|
|
81
|
+
"""Practical append-only subset of the paper's patch checker."""
|
|
82
|
+
|
|
83
|
+
footprint = frozenset({"PatchKernel", "FoldKernel", "EnvelopeVerifier", "ClauseKernel"})
|
|
84
|
+
|
|
85
|
+
def __init__(self, fold_kernel: FoldKernel | None = None) -> None:
|
|
86
|
+
self.fold_kernel = fold_kernel or FoldKernel()
|
|
87
|
+
|
|
88
|
+
def check(
|
|
89
|
+
self,
|
|
90
|
+
horizon: Horizon,
|
|
91
|
+
source: Sequence[Envelope],
|
|
92
|
+
proposal: PatchProposal,
|
|
93
|
+
*,
|
|
94
|
+
invariants: dict[str, Invariant] | None = None,
|
|
95
|
+
) -> CheckResult:
|
|
96
|
+
builder = CheckBuilder(footprint=set(self.footprint))
|
|
97
|
+
source_digest = digest_log(source)
|
|
98
|
+
if proposal.expected_source_digest != source_digest:
|
|
99
|
+
builder.error(
|
|
100
|
+
"patch-source-digest",
|
|
101
|
+
"source digest does not match patch proposal",
|
|
102
|
+
details={"expected": proposal.expected_source_digest, "actual": source_digest},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
source_ids = {env.eid for env in source}
|
|
106
|
+
duplicate_appends = [env.eid for env in proposal.append if env.eid in source_ids]
|
|
107
|
+
if duplicate_appends:
|
|
108
|
+
builder.error(
|
|
109
|
+
"patch-not-append-only",
|
|
110
|
+
"patch contains existing envelope ids",
|
|
111
|
+
details={"eids": duplicate_appends},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
self._check_footprints(proposal, invariants or {}, builder)
|
|
115
|
+
|
|
116
|
+
target = tuple(source) + tuple(proposal.append)
|
|
117
|
+
try:
|
|
118
|
+
folded = self.fold_kernel.fold(horizon, target)
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
builder.error("patch-target-fold", f"patch target is not a legal folded log: {exc}")
|
|
121
|
+
return builder.result(digest=source_digest)
|
|
122
|
+
|
|
123
|
+
for name, invariant in (invariants or {}).items():
|
|
124
|
+
if name not in proposal.affected_invariants:
|
|
125
|
+
builder.warning(
|
|
126
|
+
"patch-invariant-not-rechecked",
|
|
127
|
+
"invariant was supplied but not listed as affected",
|
|
128
|
+
location=name,
|
|
129
|
+
)
|
|
130
|
+
continue
|
|
131
|
+
result = invariant(folded, target)
|
|
132
|
+
if not result.ok:
|
|
133
|
+
for issue in result.issues:
|
|
134
|
+
builder.error(issue.code, issue.message, location=issue.location, details=issue.details)
|
|
135
|
+
self._check_frame_invalidation(folded, proposal, builder)
|
|
136
|
+
|
|
137
|
+
return builder.result(digest=digest_log(target))
|
|
138
|
+
|
|
139
|
+
def _check_footprints(
|
|
140
|
+
self,
|
|
141
|
+
proposal: PatchProposal,
|
|
142
|
+
invariants: dict[str, Invariant],
|
|
143
|
+
builder: CheckBuilder,
|
|
144
|
+
) -> None:
|
|
145
|
+
footprint_by_invariant = {footprint.invariant: footprint for footprint in proposal.read_footprints}
|
|
146
|
+
affected = set(proposal.affected_invariants)
|
|
147
|
+
for name in invariants:
|
|
148
|
+
footprint = footprint_by_invariant.get(name)
|
|
149
|
+
if footprint is None:
|
|
150
|
+
builder.error("patch-footprint-missing", "invariant has no read footprint witness", location=name)
|
|
151
|
+
continue
|
|
152
|
+
touched = False
|
|
153
|
+
for write in proposal.write_classes:
|
|
154
|
+
for read_entry in footprint.entries:
|
|
155
|
+
verdict = proposal.touch_matrix.verdict(write, read_entry)
|
|
156
|
+
if verdict not in {"touch", "non_touch"}:
|
|
157
|
+
builder.error(
|
|
158
|
+
"patch-touch-cell",
|
|
159
|
+
"touch matrix must classify every write/read pair",
|
|
160
|
+
location=name,
|
|
161
|
+
details={"write": write.to_json(), "read": read_entry},
|
|
162
|
+
)
|
|
163
|
+
touched = touched or verdict == "touch"
|
|
164
|
+
if touched and name not in affected:
|
|
165
|
+
builder.error(
|
|
166
|
+
"patch-affected-completeness",
|
|
167
|
+
"touched invariant is missing from affected set",
|
|
168
|
+
location=name,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _check_frame_invalidation(self, folded: FoldState, proposal: PatchProposal, builder: CheckBuilder) -> None:
|
|
172
|
+
non_active_frames = {
|
|
173
|
+
str(env.payload.get("frame_id", env.payload.get("object", "")))
|
|
174
|
+
for env in proposal.append
|
|
175
|
+
if env.kind in {"Suspended", "Invalidated", "Withdrawn"}
|
|
176
|
+
}
|
|
177
|
+
for frame_id in non_active_frames:
|
|
178
|
+
for cap_id, cap in folded.component("capabilities").items():
|
|
179
|
+
if cap.get("frame_id") == frame_id and cap.get("status") == "unused":
|
|
180
|
+
builder.error(
|
|
181
|
+
"patch-frame-invalidates-capability",
|
|
182
|
+
"non-active frame patch must revoke, expire, or use outstanding capabilities",
|
|
183
|
+
location=cap_id,
|
|
184
|
+
)
|
|
185
|
+
for outbox_id, outbox in folded.component("outboxes").items():
|
|
186
|
+
if outbox.get("frame_id") == frame_id and outbox.get("status") == "authorized":
|
|
187
|
+
builder.error(
|
|
188
|
+
"patch-frame-invalidates-outbox",
|
|
189
|
+
"non-active frame patch must revoke or block unclaimed outboxes",
|
|
190
|
+
location=outbox_id,
|
|
191
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|