openworkproof 1.0.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.
- openworkproof/__init__.py +1 -0
- openworkproof/acceptance.py +3006 -0
- openworkproof/cli.py +164 -0
- openworkproof/composition.py +832 -0
- openworkproof/evidence.py +8281 -0
- openworkproof/execution_adapter.py +256 -0
- openworkproof/external_acceptor.py +211 -0
- openworkproof/mcp_server.py +2939 -0
- openworkproof/mcp_transport.py +72 -0
- openworkproof/models.py +3380 -0
- openworkproof/policy.py +2494 -0
- openworkproof/predicates.py +333 -0
- openworkproof/repo_pipeline/__init__.py +74 -0
- openworkproof/repo_pipeline/analysis.py +152 -0
- openworkproof/repo_pipeline/errors.py +64 -0
- openworkproof/repo_pipeline/models.py +80 -0
- openworkproof/repo_pipeline/output.py +68 -0
- openworkproof/repo_pipeline/reader.py +47 -0
- openworkproof/repo_pipeline/sources.py +120 -0
- openworkproof/repo_pipeline/traversal.py +253 -0
- openworkproof/repo_tools.py +8382 -0
- openworkproof/runtime_context.py +189 -0
- openworkproof/schema_registry.py +623 -0
- openworkproof/schemas/v0.1/acceptance-receipt.schema.json +1 -0
- openworkproof/schemas/v0.1/acceptance-rejection-receipt.schema.json +1 -0
- openworkproof/schemas/v0.1/action-receipt.schema.json +1 -0
- openworkproof/schemas/v0.1/capability-grant.schema.json +1 -0
- openworkproof/schemas/v0.1/schema-registry.json +1 -0
- openworkproof/schemas/v0.1/work-order.schema.json +1 -0
- openworkproof/signing.py +421 -0
- openworkproof/state.py +765 -0
- openworkproof/team_network_client.py +419 -0
- openworkproof/trusted_helper.py +269 -0
- openworkproof-1.0.0.dist-info/METADATA +578 -0
- openworkproof-1.0.0.dist-info/RECORD +39 -0
- openworkproof-1.0.0.dist-info/WHEEL +5 -0
- openworkproof-1.0.0.dist-info/entry_points.txt +2 -0
- openworkproof-1.0.0.dist-info/licenses/LICENSE +202 -0
- openworkproof-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,3006 @@
|
|
|
1
|
+
"""Deterministic proof composition and independent acceptance authority.
|
|
2
|
+
|
|
3
|
+
Package-internal module implementing the closed aggregate digests, the
|
|
4
|
+
CompositionReport construction authority, and the compose/request/prepare/
|
|
5
|
+
commit acceptance transactions from the design at
|
|
6
|
+
docs/superpowers/specs/2026-08-06-openworkproof-acceptance-transaction-design.md.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
import hashlib
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import secrets
|
|
17
|
+
import sqlite3
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
import rfc8785
|
|
21
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
22
|
+
|
|
23
|
+
import openworkproof.evidence as evidence
|
|
24
|
+
from openworkproof.composition import replay_authorization_causality
|
|
25
|
+
from openworkproof.models import (
|
|
26
|
+
AcceptanceReceipt,
|
|
27
|
+
AcceptanceRejectionReceipt,
|
|
28
|
+
ActionReceiptEnvelope,
|
|
29
|
+
AgentRequest,
|
|
30
|
+
ApprovalRequestedReceipt,
|
|
31
|
+
ComposeProofArguments,
|
|
32
|
+
CompositionReport,
|
|
33
|
+
CorrelationReference,
|
|
34
|
+
EvidenceRef,
|
|
35
|
+
FinalArtifact,
|
|
36
|
+
FrozenDict,
|
|
37
|
+
GrantIssuedReceipt,
|
|
38
|
+
IndependenceAssessment,
|
|
39
|
+
PredicateResult,
|
|
40
|
+
SystemEventReceipt,
|
|
41
|
+
ToolCallReceipt,
|
|
42
|
+
request_arguments_digest,
|
|
43
|
+
)
|
|
44
|
+
from openworkproof.policy import AuthorizationContext
|
|
45
|
+
from openworkproof.runtime_context import RuntimeContextError, require_current_context
|
|
46
|
+
from openworkproof.signing import key_id, sign_payload
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AcceptanceTransactionError(RuntimeError):
|
|
50
|
+
"""Composition or acceptance failed before proven commit."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AcceptanceCommittedError(AcceptanceTransactionError):
|
|
54
|
+
"""The exact result committed but a later operation failed."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, message: str, committed: object) -> None:
|
|
57
|
+
super().__init__(message)
|
|
58
|
+
self.committed = committed
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AcceptanceCommitIndeterminateError(AcceptanceTransactionError):
|
|
62
|
+
"""Readback could prove neither rollback nor the exact commit."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def causal_graph_root(
|
|
66
|
+
receipts: tuple[ActionReceiptEnvelope, ...],
|
|
67
|
+
) -> str:
|
|
68
|
+
ordered = tuple(sorted(receipts, key=lambda item: item.sequence))
|
|
69
|
+
if (
|
|
70
|
+
ordered != receipts
|
|
71
|
+
or len({item.receipt_id for item in ordered}) != len(ordered)
|
|
72
|
+
):
|
|
73
|
+
raise AcceptanceTransactionError("receipt prefix is not canonical")
|
|
74
|
+
nodes = [
|
|
75
|
+
{
|
|
76
|
+
"receipt_id": item.receipt_id,
|
|
77
|
+
"receipt_digest": item.digest,
|
|
78
|
+
"parent_receipt_ids": list(item.parent_receipt_ids),
|
|
79
|
+
}
|
|
80
|
+
for item in ordered
|
|
81
|
+
]
|
|
82
|
+
return hashlib.sha256(
|
|
83
|
+
rfc8785.dumps(
|
|
84
|
+
{
|
|
85
|
+
"domain": "openworkproof/causal-graph-root/0.1",
|
|
86
|
+
"nodes": nodes,
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
).hexdigest()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def evidence_snapshot_digest(
|
|
93
|
+
refs: tuple[EvidenceRef, ...],
|
|
94
|
+
) -> str:
|
|
95
|
+
ordered = tuple(sorted(refs, key=lambda item: item.path.encode("utf-8")))
|
|
96
|
+
if ordered != refs or len({item.path for item in ordered}) != len(ordered):
|
|
97
|
+
raise AcceptanceTransactionError("evidence snapshot is not canonical")
|
|
98
|
+
return hashlib.sha256(
|
|
99
|
+
rfc8785.dumps(
|
|
100
|
+
{
|
|
101
|
+
"domain": "openworkproof/evidence-snapshot/0.1",
|
|
102
|
+
"refs": [item.model_dump(mode="json") for item in ordered],
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
).hexdigest()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def composition_report_digest(report: CompositionReport) -> str:
|
|
109
|
+
return hashlib.sha256(
|
|
110
|
+
rfc8785.dumps(report.model_dump(mode="json"))
|
|
111
|
+
).hexdigest()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def acceptance_id(
|
|
115
|
+
*,
|
|
116
|
+
work_order_digest: str,
|
|
117
|
+
request_receipt_id: str,
|
|
118
|
+
request_receipt_digest: str,
|
|
119
|
+
report_digest: str,
|
|
120
|
+
evidence_snapshot: str,
|
|
121
|
+
) -> str:
|
|
122
|
+
return hashlib.sha256(
|
|
123
|
+
rfc8785.dumps(
|
|
124
|
+
{
|
|
125
|
+
"domain": "openworkproof/acceptance-id/0.1",
|
|
126
|
+
"work_order_digest": work_order_digest,
|
|
127
|
+
"request_receipt_id": request_receipt_id,
|
|
128
|
+
"request_receipt_digest": request_receipt_digest,
|
|
129
|
+
"composition_report_digest": report_digest,
|
|
130
|
+
"evidence_snapshot_digest": evidence_snapshot,
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
).hexdigest()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True, slots=True)
|
|
137
|
+
class CompositionTransactionResult:
|
|
138
|
+
initiator_receipt: ToolCallReceipt
|
|
139
|
+
report: CompositionReport
|
|
140
|
+
trigger_receipt: SystemEventReceipt
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True, slots=True)
|
|
144
|
+
class AcceptanceSigningDraft:
|
|
145
|
+
signing_domain: Literal["acceptance-receipt"]
|
|
146
|
+
acceptance_id: str
|
|
147
|
+
payload: FrozenDict
|
|
148
|
+
canonical_payload: bytes
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _freeze_second(clock_value: datetime) -> datetime:
|
|
152
|
+
return evidence._freeze_trusted_utc_second(clock_value)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _role_for(
|
|
156
|
+
receipt: ActionReceiptEnvelope,
|
|
157
|
+
work_order,
|
|
158
|
+
) -> str | None:
|
|
159
|
+
for binding in work_order.key_bindings:
|
|
160
|
+
if (
|
|
161
|
+
binding.subject_id == receipt.actor_id
|
|
162
|
+
and binding.key_id == receipt.actor_key_id
|
|
163
|
+
):
|
|
164
|
+
return binding.role
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _derive_composition_report(
|
|
169
|
+
context: AuthorizationContext,
|
|
170
|
+
initiator: ToolCallReceipt,
|
|
171
|
+
prefix: tuple[ActionReceiptEnvelope, ...],
|
|
172
|
+
now: datetime,
|
|
173
|
+
) -> CompositionReport:
|
|
174
|
+
"""Derive the canonical report over the prefix ending at initiator."""
|
|
175
|
+
work_order = context.work_order
|
|
176
|
+
replay_authorization_causality(work_order, prefix)
|
|
177
|
+
coverage: dict[str, bool] = {}
|
|
178
|
+
for dimension in work_order.required_evidence_dimensions:
|
|
179
|
+
coverage[dimension] = False
|
|
180
|
+
# The authority dimension is proven by the successful causal replay of
|
|
181
|
+
# the signed authorization history itself; it needs no artifact file.
|
|
182
|
+
coverage["authority"] = True
|
|
183
|
+
for receipt in prefix:
|
|
184
|
+
for reference in receipt.evidence_refs:
|
|
185
|
+
relative = reference.path.removeprefix("evidence/")
|
|
186
|
+
for artifact in work_order.evidence_policy.artifacts:
|
|
187
|
+
if artifact.path != relative:
|
|
188
|
+
continue
|
|
189
|
+
coverage[artifact.evidence_dimension] = True
|
|
190
|
+
missing_dimensions = [
|
|
191
|
+
dimension
|
|
192
|
+
for dimension in work_order.required_evidence_dimensions
|
|
193
|
+
if not coverage[dimension]
|
|
194
|
+
]
|
|
195
|
+
complete = not missing_dimensions
|
|
196
|
+
unresolved_failures = (
|
|
197
|
+
()
|
|
198
|
+
if complete
|
|
199
|
+
else (
|
|
200
|
+
{
|
|
201
|
+
"code": "MISSING_EVIDENCE_DIMENSION",
|
|
202
|
+
"subject_ref": context.work_order.digest,
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
developer_ref: CorrelationReference | None = None
|
|
207
|
+
verifier_ref: CorrelationReference | None = None
|
|
208
|
+
test_refs: list[EvidenceRef] = []
|
|
209
|
+
verifier_receipts: list[ToolCallReceipt] = []
|
|
210
|
+
for receipt in prefix:
|
|
211
|
+
if not isinstance(receipt, ToolCallReceipt) or receipt.correlation_factors is None:
|
|
212
|
+
continue
|
|
213
|
+
role = _role_for(receipt, work_order)
|
|
214
|
+
if role == "Developer" and developer_ref is None:
|
|
215
|
+
developer_ref = CorrelationReference(
|
|
216
|
+
receipt_digest=receipt.digest,
|
|
217
|
+
factors=receipt.correlation_factors.model_dump(mode="json"),
|
|
218
|
+
)
|
|
219
|
+
elif role == "Verifier":
|
|
220
|
+
verifier_receipts.append(receipt)
|
|
221
|
+
test_refs.extend(receipt.evidence_refs)
|
|
222
|
+
# The verifier reference is the independent result receipt when the
|
|
223
|
+
# five-dimension recomposition chain is present; otherwise the first
|
|
224
|
+
# primary Verifier execution remains authoritative.
|
|
225
|
+
if verifier_receipts:
|
|
226
|
+
independent = next(
|
|
227
|
+
(
|
|
228
|
+
receipt
|
|
229
|
+
for receipt in reversed(verifier_receipts)
|
|
230
|
+
if any(
|
|
231
|
+
reference.path
|
|
232
|
+
in {
|
|
233
|
+
f"evidence/{artifact.path}"
|
|
234
|
+
for artifact in work_order.evidence_policy.artifacts
|
|
235
|
+
if artifact.purpose == "verifier_independent_result"
|
|
236
|
+
}
|
|
237
|
+
for reference in receipt.evidence_refs
|
|
238
|
+
)
|
|
239
|
+
),
|
|
240
|
+
None,
|
|
241
|
+
)
|
|
242
|
+
selected_verifier = independent or verifier_receipts[0]
|
|
243
|
+
verifier_ref = CorrelationReference(
|
|
244
|
+
receipt_digest=selected_verifier.digest,
|
|
245
|
+
factors=selected_verifier.correlation_factors.model_dump(mode="json"),
|
|
246
|
+
)
|
|
247
|
+
test_refs = tuple(
|
|
248
|
+
sorted(
|
|
249
|
+
set(test_refs),
|
|
250
|
+
key=lambda item: item.path.encode(),
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
if developer_ref is None:
|
|
254
|
+
raise AcceptanceTransactionError(
|
|
255
|
+
"composition requires a developer execution reference"
|
|
256
|
+
)
|
|
257
|
+
if verifier_ref is None:
|
|
258
|
+
raise AcceptanceTransactionError(
|
|
259
|
+
"composition requires a verifier execution reference"
|
|
260
|
+
)
|
|
261
|
+
shared = _shared_factor_codes(developer_ref.factors, verifier_ref.factors)
|
|
262
|
+
independence = IndependenceAssessment(
|
|
263
|
+
policy=work_order.independence_policy,
|
|
264
|
+
developer_reference=developer_ref.model_dump(mode="json"),
|
|
265
|
+
verifier_reference=verifier_ref.model_dump(mode="json"),
|
|
266
|
+
shared_factors=shared,
|
|
267
|
+
satisfied=complete,
|
|
268
|
+
)
|
|
269
|
+
warnings = _shared_factor_warnings(
|
|
270
|
+
developer_ref.factors,
|
|
271
|
+
verifier_ref.factors,
|
|
272
|
+
shared,
|
|
273
|
+
)
|
|
274
|
+
artifact_digests = _sorted_unique_evidence_refs(prefix)
|
|
275
|
+
checkpoint = context.replay_checkpoint
|
|
276
|
+
active_patch_id = context.active_patch_receipt_id
|
|
277
|
+
if active_patch_id is None:
|
|
278
|
+
raise AcceptanceTransactionError(
|
|
279
|
+
"composition requires a committed active patch"
|
|
280
|
+
)
|
|
281
|
+
receipt_digests = tuple(receipt.digest for receipt in prefix)
|
|
282
|
+
postconditions = _evaluate_postconditions(context)
|
|
283
|
+
all_passed = bool(postconditions) and all(
|
|
284
|
+
result.passed for result in postconditions
|
|
285
|
+
)
|
|
286
|
+
complete = complete and all_passed
|
|
287
|
+
verifier_conclusion = "proof_ready" if complete else "evidence_incomplete"
|
|
288
|
+
unresolved = unresolved_failures
|
|
289
|
+
if not complete and not unresolved:
|
|
290
|
+
unresolved = (
|
|
291
|
+
{
|
|
292
|
+
"code": "GLOBAL_POSTCONDITION_FAILED",
|
|
293
|
+
"subject_ref": work_order.digest,
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
return CompositionReport(
|
|
297
|
+
schema_version="openworkproof-composition-report/0.1",
|
|
298
|
+
work_order_digest=work_order.digest,
|
|
299
|
+
initiator_receipt_id=initiator.receipt_id,
|
|
300
|
+
initiator_receipt_digest=initiator.digest,
|
|
301
|
+
final_artifact=FinalArtifact(
|
|
302
|
+
active_patch_receipt_digest=active_patch_id,
|
|
303
|
+
candidate_commit=checkpoint.head_commit,
|
|
304
|
+
workspace_manifest_digest=checkpoint.workspace_manifest_digest,
|
|
305
|
+
).model_dump(mode="json"),
|
|
306
|
+
artifact_digests=tuple(
|
|
307
|
+
item.model_dump(mode="json") for item in artifact_digests
|
|
308
|
+
),
|
|
309
|
+
evidence_snapshot_digest=evidence_snapshot_digest(artifact_digests),
|
|
310
|
+
receipt_digests=receipt_digests,
|
|
311
|
+
causal_graph_root=causal_graph_root(prefix),
|
|
312
|
+
causal_complete=complete,
|
|
313
|
+
evidence_coverage=FrozenDict(coverage),
|
|
314
|
+
independence_assessment=independence.model_dump(mode="json"),
|
|
315
|
+
test_evidence_refs=tuple(
|
|
316
|
+
item.model_dump(mode="json") for item in test_refs
|
|
317
|
+
),
|
|
318
|
+
unresolved_failures=unresolved,
|
|
319
|
+
warnings=warnings,
|
|
320
|
+
global_postconditions=tuple(
|
|
321
|
+
item.model_dump(mode="json") for item in postconditions
|
|
322
|
+
),
|
|
323
|
+
global_postconditions_satisfied=all_passed,
|
|
324
|
+
verifier_conclusion=verifier_conclusion,
|
|
325
|
+
composed_at=now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _shared_factor_codes(developer, verifier) -> tuple[str, ...]:
|
|
330
|
+
shared = []
|
|
331
|
+
comparisons = (
|
|
332
|
+
(
|
|
333
|
+
"model",
|
|
334
|
+
(developer.model_id, developer.model_version)
|
|
335
|
+
== (verifier.model_id, verifier.model_version),
|
|
336
|
+
),
|
|
337
|
+
(
|
|
338
|
+
"prompt_template",
|
|
339
|
+
developer.prompt_template_digest == verifier.prompt_template_digest,
|
|
340
|
+
),
|
|
341
|
+
(
|
|
342
|
+
"context_source",
|
|
343
|
+
developer.context_source_digest == verifier.context_source_digest,
|
|
344
|
+
),
|
|
345
|
+
(
|
|
346
|
+
"toolchain",
|
|
347
|
+
developer.toolchain_id is not None
|
|
348
|
+
and developer.toolchain_id == verifier.toolchain_id,
|
|
349
|
+
),
|
|
350
|
+
(
|
|
351
|
+
"execution_context",
|
|
352
|
+
developer.execution_context_id is not None
|
|
353
|
+
and developer.container_instance_id_digest is not None
|
|
354
|
+
and (developer.execution_context_id, developer.container_instance_id_digest)
|
|
355
|
+
== (
|
|
356
|
+
verifier.execution_context_id,
|
|
357
|
+
verifier.container_instance_id_digest,
|
|
358
|
+
),
|
|
359
|
+
),
|
|
360
|
+
(
|
|
361
|
+
"controller",
|
|
362
|
+
developer.controller_id == verifier.controller_id,
|
|
363
|
+
),
|
|
364
|
+
(
|
|
365
|
+
"test_source",
|
|
366
|
+
developer.fixed_test_source_digest is not None
|
|
367
|
+
and developer.fixed_test_source_digest == verifier.fixed_test_source_digest,
|
|
368
|
+
),
|
|
369
|
+
)
|
|
370
|
+
for name, shared_value in comparisons:
|
|
371
|
+
if shared_value:
|
|
372
|
+
shared.append(name)
|
|
373
|
+
return tuple(shared)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
_WARNING_CODE_BY_FACTOR = {
|
|
377
|
+
"model": "SHARED_MODEL",
|
|
378
|
+
"prompt_template": "SHARED_PROMPT_TEMPLATE",
|
|
379
|
+
"context_source": "SHARED_CONTEXT_SOURCE",
|
|
380
|
+
"toolchain": "SHARED_TOOLCHAIN",
|
|
381
|
+
"execution_context": "SHARED_EXECUTION_CONTEXT",
|
|
382
|
+
"controller": "SHARED_CONTROLLER",
|
|
383
|
+
"test_source": "SHARED_TEST_SOURCE",
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _shared_factor_value(developer, verifier, factor: str) -> object:
|
|
388
|
+
if factor == "model":
|
|
389
|
+
return {
|
|
390
|
+
"model_id": developer.model_id,
|
|
391
|
+
"model_version": developer.model_version,
|
|
392
|
+
}
|
|
393
|
+
if factor == "execution_context":
|
|
394
|
+
return {
|
|
395
|
+
"execution_context_id": developer.execution_context_id,
|
|
396
|
+
"container_instance_id_digest": (
|
|
397
|
+
developer.container_instance_id_digest
|
|
398
|
+
),
|
|
399
|
+
}
|
|
400
|
+
if factor == "prompt_template":
|
|
401
|
+
return developer.prompt_template_digest
|
|
402
|
+
if factor == "context_source":
|
|
403
|
+
return developer.context_source_digest
|
|
404
|
+
if factor == "toolchain":
|
|
405
|
+
return developer.toolchain_id
|
|
406
|
+
if factor == "controller":
|
|
407
|
+
return developer.controller_id
|
|
408
|
+
if factor == "test_source":
|
|
409
|
+
return developer.fixed_test_source_digest
|
|
410
|
+
raise AssertionError(f"unknown shared factor: {factor}")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _shared_factor_warnings(
|
|
414
|
+
developer,
|
|
415
|
+
verifier,
|
|
416
|
+
shared: tuple[str, ...],
|
|
417
|
+
) -> tuple[dict, ...]:
|
|
418
|
+
return tuple(
|
|
419
|
+
sorted(
|
|
420
|
+
(
|
|
421
|
+
{
|
|
422
|
+
"code": _WARNING_CODE_BY_FACTOR[factor],
|
|
423
|
+
"subject_ref": hashlib.sha256(
|
|
424
|
+
rfc8785.dumps(
|
|
425
|
+
{
|
|
426
|
+
"domain": "openworkproof/shared-factor-ref/v0.1",
|
|
427
|
+
"factor": factor,
|
|
428
|
+
"value": _shared_factor_value(
|
|
429
|
+
developer, verifier, factor
|
|
430
|
+
),
|
|
431
|
+
}
|
|
432
|
+
)
|
|
433
|
+
).hexdigest(),
|
|
434
|
+
}
|
|
435
|
+
for factor in shared
|
|
436
|
+
),
|
|
437
|
+
key=lambda item: (
|
|
438
|
+
item["code"].encode("utf-8"),
|
|
439
|
+
item["subject_ref"].encode("utf-8"),
|
|
440
|
+
),
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _sorted_unique_evidence_refs(
|
|
446
|
+
prefix: tuple[ActionReceiptEnvelope, ...],
|
|
447
|
+
) -> tuple[EvidenceRef, ...]:
|
|
448
|
+
by_path: dict[str, EvidenceRef] = {}
|
|
449
|
+
for receipt in prefix:
|
|
450
|
+
for reference in receipt.evidence_refs:
|
|
451
|
+
by_path[reference.path] = reference
|
|
452
|
+
return tuple(
|
|
453
|
+
sorted(by_path.values(), key=lambda item: item.path.encode("utf-8"))
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _sorted_unique_evidence_refs_for_test(
|
|
458
|
+
prefix: tuple[ActionReceiptEnvelope, ...],
|
|
459
|
+
) -> tuple[EvidenceRef, ...]:
|
|
460
|
+
refs = [
|
|
461
|
+
reference
|
|
462
|
+
for receipt in prefix
|
|
463
|
+
for reference in receipt.evidence_refs
|
|
464
|
+
if reference.path.startswith("evidence/verifier-result/")
|
|
465
|
+
]
|
|
466
|
+
return tuple(sorted(refs, key=lambda item: item.path.encode("utf-8")))
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _evaluate_postconditions(
|
|
470
|
+
context: AuthorizationContext,
|
|
471
|
+
) -> tuple[PredicateResult, ...]:
|
|
472
|
+
from openworkproof.models import TestsPassedPredicateInput # noqa: PLC0415
|
|
473
|
+
from openworkproof.predicates import EvaluationContext, evaluate_required_predicates # noqa: PLC0415
|
|
474
|
+
|
|
475
|
+
inputs: dict[str, dict] = {}
|
|
476
|
+
results = context.replay_checkpoint.verified_test_results
|
|
477
|
+
for spec in context.work_order.postconditions:
|
|
478
|
+
if spec.name != "tests_passed":
|
|
479
|
+
continue
|
|
480
|
+
verifier_results = tuple(
|
|
481
|
+
result for result in results if result.test_mode == "verifier"
|
|
482
|
+
)
|
|
483
|
+
latest = verifier_results[-1] if verifier_results else None
|
|
484
|
+
arguments = spec.arguments
|
|
485
|
+
evidence_digest = None
|
|
486
|
+
if latest is not None:
|
|
487
|
+
evidence_digest = hashlib.sha256(
|
|
488
|
+
rfc8785.dumps(latest.model_dump(mode="json"))
|
|
489
|
+
).hexdigest()
|
|
490
|
+
inputs[spec.predicate_id] = TestsPassedPredicateInput(
|
|
491
|
+
test_mode=arguments["test_mode"],
|
|
492
|
+
command_digest=arguments["command_digest"],
|
|
493
|
+
expected_exit_code=arguments["expected_exit_code"],
|
|
494
|
+
actual_exit_code=(
|
|
495
|
+
latest.actual_exit_code if latest is not None else None
|
|
496
|
+
),
|
|
497
|
+
test_evidence_digest=evidence_digest,
|
|
498
|
+
source_commit=(
|
|
499
|
+
latest.source_commit
|
|
500
|
+
if latest is not None
|
|
501
|
+
else context.work_order.source_commit
|
|
502
|
+
),
|
|
503
|
+
candidate_commit=(
|
|
504
|
+
latest.candidate_commit
|
|
505
|
+
if latest is not None
|
|
506
|
+
else context.replay_checkpoint.head_commit
|
|
507
|
+
),
|
|
508
|
+
workspace_manifest_digest=(
|
|
509
|
+
latest.workspace_manifest_digest
|
|
510
|
+
if latest is not None
|
|
511
|
+
else context.replay_checkpoint.workspace_manifest_digest
|
|
512
|
+
),
|
|
513
|
+
container_image_digest=(
|
|
514
|
+
latest.container_image_digest
|
|
515
|
+
if latest is not None
|
|
516
|
+
else context.work_order.test_profiles[-1].container_image_digest
|
|
517
|
+
),
|
|
518
|
+
fixed_test_source_digest=arguments["fixed_test_source_digest"],
|
|
519
|
+
).model_dump(mode="json")
|
|
520
|
+
evaluation = EvaluationContext(
|
|
521
|
+
inputs=inputs,
|
|
522
|
+
authoritative_inputs=inputs,
|
|
523
|
+
)
|
|
524
|
+
return evaluate_required_predicates(
|
|
525
|
+
context.work_order.postconditions,
|
|
526
|
+
evaluation,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _compose_causal_parents(
|
|
531
|
+
context: AuthorizationContext,
|
|
532
|
+
prefix: tuple[ActionReceiptEnvelope, ...],
|
|
533
|
+
grant_id: str,
|
|
534
|
+
) -> tuple[str, ...]:
|
|
535
|
+
by_id = {receipt.receipt_id: receipt for receipt in prefix}
|
|
536
|
+
parents: dict[str, ActionReceiptEnvelope] = {}
|
|
537
|
+
issuance = next(
|
|
538
|
+
(
|
|
539
|
+
receipt
|
|
540
|
+
for receipt in prefix
|
|
541
|
+
if isinstance(receipt, GrantIssuedReceipt)
|
|
542
|
+
and receipt.policy_decision == "allow"
|
|
543
|
+
and receipt.issued_grant_id == grant_id
|
|
544
|
+
),
|
|
545
|
+
None,
|
|
546
|
+
)
|
|
547
|
+
if issuance is None:
|
|
548
|
+
raise AcceptanceTransactionError(
|
|
549
|
+
"compose grant issuance is unavailable"
|
|
550
|
+
)
|
|
551
|
+
parents[issuance.receipt_id] = issuance
|
|
552
|
+
if context.current_state == "evidence_incomplete":
|
|
553
|
+
trigger = by_id.get(
|
|
554
|
+
context.causal_state.latest_composition_trigger_id
|
|
555
|
+
)
|
|
556
|
+
independent = by_id.get(
|
|
557
|
+
context.causal_state.independent_result_receipt_id
|
|
558
|
+
)
|
|
559
|
+
if (
|
|
560
|
+
trigger is None
|
|
561
|
+
or independent is None
|
|
562
|
+
or not isinstance(trigger, SystemEventReceipt)
|
|
563
|
+
or trigger.system_event_name != "proof_composed"
|
|
564
|
+
):
|
|
565
|
+
raise AcceptanceTransactionError(
|
|
566
|
+
"recomposition causal inputs are unavailable"
|
|
567
|
+
)
|
|
568
|
+
parents[trigger.receipt_id] = trigger
|
|
569
|
+
parents[independent.receipt_id] = independent
|
|
570
|
+
else:
|
|
571
|
+
active_patch_id = context.active_patch_receipt_id
|
|
572
|
+
if active_patch_id is not None and active_patch_id in by_id:
|
|
573
|
+
parents[active_patch_id] = by_id[active_patch_id]
|
|
574
|
+
passing = [
|
|
575
|
+
receipt
|
|
576
|
+
for receipt in prefix
|
|
577
|
+
if isinstance(receipt, ToolCallReceipt)
|
|
578
|
+
and receipt.tool_name == "owp.run_tests"
|
|
579
|
+
and receipt.policy_decision == "allow"
|
|
580
|
+
and receipt.execution_status == "succeeded"
|
|
581
|
+
]
|
|
582
|
+
if passing:
|
|
583
|
+
latest = passing[-1]
|
|
584
|
+
parents[latest.receipt_id] = latest
|
|
585
|
+
return tuple(
|
|
586
|
+
receipt.receipt_id
|
|
587
|
+
for receipt in sorted(
|
|
588
|
+
parents.values(),
|
|
589
|
+
key=lambda item: item.sequence,
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _build_compose_receipt(
|
|
595
|
+
context: AuthorizationContext,
|
|
596
|
+
request: AgentRequest,
|
|
597
|
+
arguments: ComposeProofArguments,
|
|
598
|
+
sidecar_private_key: Ed25519PrivateKey,
|
|
599
|
+
*,
|
|
600
|
+
sequence: int,
|
|
601
|
+
previous_digest: str,
|
|
602
|
+
) -> ToolCallReceipt:
|
|
603
|
+
from openworkproof.policy import authorize_tool_call # noqa: PLC0415
|
|
604
|
+
|
|
605
|
+
decision = authorize_tool_call(
|
|
606
|
+
context,
|
|
607
|
+
request,
|
|
608
|
+
arguments,
|
|
609
|
+
)
|
|
610
|
+
if not decision.allowed:
|
|
611
|
+
raise AcceptanceTransactionError(
|
|
612
|
+
decision.error_code or "compose proof was denied"
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
sidecar_key_id = key_id(sidecar_private_key.public_key())
|
|
616
|
+
remaining_before = _remaining_tool_calls(context, request.grant_id)
|
|
617
|
+
receipt_id = hashlib.sha256(
|
|
618
|
+
rfc8785.dumps(
|
|
619
|
+
{
|
|
620
|
+
"domain": "openworkproof/receipt-id/v0.1",
|
|
621
|
+
"request_digest": request.digest,
|
|
622
|
+
"entropy": secrets.token_hex(32),
|
|
623
|
+
}
|
|
624
|
+
)
|
|
625
|
+
).hexdigest()
|
|
626
|
+
raw = {
|
|
627
|
+
"protocol_version": "0.1",
|
|
628
|
+
"receipt_id": receipt_id,
|
|
629
|
+
"work_order_digest": context.work_order.digest,
|
|
630
|
+
"actor_type": "agent",
|
|
631
|
+
"actor_id": request.actor_id,
|
|
632
|
+
"actor_key_id": request.actor_key_id,
|
|
633
|
+
"nested_claim_type": "agent-request",
|
|
634
|
+
"nested_claim_digest": request.digest,
|
|
635
|
+
"nested_claim": request.model_dump(mode="json"),
|
|
636
|
+
"gateway_signer_key_id": sidecar_key_id,
|
|
637
|
+
"event_type": "tool_call",
|
|
638
|
+
"policy_decision": "allow",
|
|
639
|
+
"policy_error_code": None,
|
|
640
|
+
"execution_status": "succeeded",
|
|
641
|
+
"execution_error_code": None,
|
|
642
|
+
"quota_charge": {
|
|
643
|
+
"grant_id": request.grant_id,
|
|
644
|
+
"metric": "tool_calls",
|
|
645
|
+
"amount": 1,
|
|
646
|
+
"remaining_after": remaining_before - 1,
|
|
647
|
+
},
|
|
648
|
+
"state_before": context.current_state,
|
|
649
|
+
"state_after": context.current_state,
|
|
650
|
+
"parent_receipt_ids": list(
|
|
651
|
+
_compose_causal_parents(
|
|
652
|
+
context,
|
|
653
|
+
context.ledger_prefix.receipts,
|
|
654
|
+
request.grant_id,
|
|
655
|
+
)
|
|
656
|
+
),
|
|
657
|
+
"correlation_factors": {
|
|
658
|
+
"model_id": request.model_id,
|
|
659
|
+
"model_version": request.model_version,
|
|
660
|
+
"prompt_template_digest": request.prompt_template_digest,
|
|
661
|
+
"context_source_digest": request.context_source_digest,
|
|
662
|
+
"toolchain_id": None,
|
|
663
|
+
"execution_context_id": None,
|
|
664
|
+
"container_instance_id_digest": None,
|
|
665
|
+
"controller_id": sidecar_key_id,
|
|
666
|
+
"fixed_test_source_digest": None,
|
|
667
|
+
},
|
|
668
|
+
"evidence_refs": [],
|
|
669
|
+
"occurred_at": context.transaction_time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
670
|
+
"sequence": sequence,
|
|
671
|
+
"nonce": request.nonce,
|
|
672
|
+
"previous_receipt_digest": previous_digest,
|
|
673
|
+
"grant_id": request.grant_id,
|
|
674
|
+
"tool_name": "owp.compose_proof",
|
|
675
|
+
"tool_version": "0.1",
|
|
676
|
+
"request_arguments": arguments.model_dump(mode="json"),
|
|
677
|
+
"arguments_digest": request.arguments_digest,
|
|
678
|
+
"output_digest": hashlib.sha256(
|
|
679
|
+
rfc8785.dumps({"status": "composition_request_accepted"})
|
|
680
|
+
).hexdigest(),
|
|
681
|
+
"predicate_results": [],
|
|
682
|
+
}
|
|
683
|
+
return evidence.ACTION_RECEIPT_ADAPTER.validate_python(
|
|
684
|
+
sign_payload("action-receipt", raw, sidecar_private_key)
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _remaining_tool_calls(context: AuthorizationContext, grant_id: str) -> int:
|
|
689
|
+
for candidate_grant_id, metric, remaining in context.replay_state.balances:
|
|
690
|
+
if candidate_grant_id == grant_id and metric == "tool_calls":
|
|
691
|
+
return remaining
|
|
692
|
+
raise AcceptanceTransactionError("grant quota is unavailable")
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _build_proof_composed_receipt(
|
|
696
|
+
context: AuthorizationContext,
|
|
697
|
+
initiator: ToolCallReceipt,
|
|
698
|
+
report: CompositionReport,
|
|
699
|
+
sidecar_private_key: Ed25519PrivateKey,
|
|
700
|
+
*,
|
|
701
|
+
sequence: int,
|
|
702
|
+
previous_digest: str,
|
|
703
|
+
) -> SystemEventReceipt:
|
|
704
|
+
sidecar_key_id = key_id(sidecar_private_key.public_key())
|
|
705
|
+
arguments = initiator.request_arguments
|
|
706
|
+
if not isinstance(arguments, ComposeProofArguments):
|
|
707
|
+
raise AcceptanceTransactionError(
|
|
708
|
+
"compose initiator arguments are unavailable"
|
|
709
|
+
)
|
|
710
|
+
cause = {
|
|
711
|
+
"initiator_receipt_digest": initiator.digest,
|
|
712
|
+
"composition_report_digest": composition_report_digest(report),
|
|
713
|
+
"state_version_before": arguments.expected_state_version,
|
|
714
|
+
}
|
|
715
|
+
input_digest = hashlib.sha256(
|
|
716
|
+
rfc8785.dumps(
|
|
717
|
+
{
|
|
718
|
+
"domain": "openworkproof/system-event-input/v0.1",
|
|
719
|
+
"event_name": "proof_composed",
|
|
720
|
+
"work_order_digest": context.work_order.digest,
|
|
721
|
+
"cause": cause,
|
|
722
|
+
}
|
|
723
|
+
)
|
|
724
|
+
).hexdigest()
|
|
725
|
+
receipt_id = hashlib.sha256(
|
|
726
|
+
rfc8785.dumps(
|
|
727
|
+
{
|
|
728
|
+
"domain": "openworkproof/receipt-id/v0.1",
|
|
729
|
+
"request_digest": initiator.digest,
|
|
730
|
+
"entropy": secrets.token_hex(32),
|
|
731
|
+
}
|
|
732
|
+
)
|
|
733
|
+
).hexdigest()
|
|
734
|
+
raw = {
|
|
735
|
+
"protocol_version": "0.1",
|
|
736
|
+
"receipt_id": receipt_id,
|
|
737
|
+
"work_order_digest": context.work_order.digest,
|
|
738
|
+
"gateway_signer_key_id": sidecar_key_id,
|
|
739
|
+
"event_type": "system_event",
|
|
740
|
+
"policy_decision": "not_applicable",
|
|
741
|
+
"policy_error_code": None,
|
|
742
|
+
"execution_status": "succeeded",
|
|
743
|
+
"execution_error_code": None,
|
|
744
|
+
"state_before": initiator.state_after,
|
|
745
|
+
"state_after": (
|
|
746
|
+
"proof_ready"
|
|
747
|
+
if report.verifier_conclusion == "proof_ready"
|
|
748
|
+
else "evidence_incomplete"
|
|
749
|
+
),
|
|
750
|
+
"parent_receipt_ids": [initiator.receipt_id],
|
|
751
|
+
"correlation_factors": None,
|
|
752
|
+
"evidence_refs": [],
|
|
753
|
+
"occurred_at": context.transaction_time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
754
|
+
"sequence": sequence,
|
|
755
|
+
"nonce": hashlib.sha256(
|
|
756
|
+
rfc8785.dumps(
|
|
757
|
+
{
|
|
758
|
+
"domain": "openworkproof/system-event-nonce/v0.1",
|
|
759
|
+
"receipt_id": receipt_id,
|
|
760
|
+
}
|
|
761
|
+
)
|
|
762
|
+
).hexdigest(),
|
|
763
|
+
"previous_receipt_digest": previous_digest,
|
|
764
|
+
"actor_type": "sidecar",
|
|
765
|
+
"actor_id": "sidecar",
|
|
766
|
+
"actor_key_id": sidecar_key_id,
|
|
767
|
+
"nested_claim_type": "sidecar-event",
|
|
768
|
+
"nested_claim_digest": input_digest,
|
|
769
|
+
"nested_claim": {
|
|
770
|
+
"claim_type": "sidecar-event",
|
|
771
|
+
"work_order_digest": context.work_order.digest,
|
|
772
|
+
"event_name": "proof_composed",
|
|
773
|
+
"cause": cause,
|
|
774
|
+
"input_digest": input_digest,
|
|
775
|
+
"occurred_at": context.transaction_time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
776
|
+
},
|
|
777
|
+
"system_event_name": "proof_composed",
|
|
778
|
+
"cause": cause,
|
|
779
|
+
"input_digest": input_digest,
|
|
780
|
+
"error_code": None,
|
|
781
|
+
"quota_charge": None,
|
|
782
|
+
}
|
|
783
|
+
return evidence.ACTION_RECEIPT_ADAPTER.validate_python(
|
|
784
|
+
sign_payload("action-receipt", raw, sidecar_private_key)
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _insert_compose_rows(
|
|
789
|
+
connection: sqlite3.Connection,
|
|
790
|
+
*,
|
|
791
|
+
work_order,
|
|
792
|
+
initiator: ToolCallReceipt,
|
|
793
|
+
trigger: SystemEventReceipt,
|
|
794
|
+
report: CompositionReport,
|
|
795
|
+
current_state: str,
|
|
796
|
+
current_version: int,
|
|
797
|
+
) -> None:
|
|
798
|
+
for receipt in (initiator, trigger):
|
|
799
|
+
connection.execute(
|
|
800
|
+
"""
|
|
801
|
+
INSERT INTO receipts (
|
|
802
|
+
receipt_id, work_order_digest, nonce, sequence,
|
|
803
|
+
previous_digest, receipt_json
|
|
804
|
+
)
|
|
805
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
806
|
+
""",
|
|
807
|
+
(
|
|
808
|
+
receipt.receipt_id,
|
|
809
|
+
work_order.digest,
|
|
810
|
+
receipt.nonce,
|
|
811
|
+
receipt.sequence,
|
|
812
|
+
receipt.previous_receipt_digest,
|
|
813
|
+
evidence._canonical_json(receipt.model_dump(mode="json")),
|
|
814
|
+
),
|
|
815
|
+
)
|
|
816
|
+
for parent_id in receipt.parent_receipt_ids:
|
|
817
|
+
connection.execute(
|
|
818
|
+
"""
|
|
819
|
+
INSERT INTO receipt_parents (child_receipt_id, parent_receipt_id)
|
|
820
|
+
VALUES (?, ?)
|
|
821
|
+
""",
|
|
822
|
+
(receipt.receipt_id, parent_id),
|
|
823
|
+
)
|
|
824
|
+
charge = initiator.quota_charge
|
|
825
|
+
if charge is None:
|
|
826
|
+
raise AcceptanceTransactionError("compose initiator must charge quota")
|
|
827
|
+
connection.execute(
|
|
828
|
+
"""
|
|
829
|
+
INSERT INTO grant_events (
|
|
830
|
+
event_id, receipt_id, grant_id, event_type, metric, amount
|
|
831
|
+
)
|
|
832
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
833
|
+
""",
|
|
834
|
+
(
|
|
835
|
+
hashlib.sha256(
|
|
836
|
+
f"grant-event:{initiator.receipt_id}".encode("ascii")
|
|
837
|
+
).hexdigest(),
|
|
838
|
+
initiator.receipt_id,
|
|
839
|
+
charge.grant_id,
|
|
840
|
+
initiator.event_type,
|
|
841
|
+
charge.metric,
|
|
842
|
+
charge.amount,
|
|
843
|
+
),
|
|
844
|
+
)
|
|
845
|
+
connection.execute(
|
|
846
|
+
"""
|
|
847
|
+
INSERT INTO composition_reports (
|
|
848
|
+
report_digest, work_order_digest, initiator_receipt_id,
|
|
849
|
+
initiator_receipt_digest, source_state_version, report_json
|
|
850
|
+
)
|
|
851
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
852
|
+
""",
|
|
853
|
+
(
|
|
854
|
+
composition_report_digest(report),
|
|
855
|
+
work_order.digest,
|
|
856
|
+
initiator.receipt_id,
|
|
857
|
+
initiator.digest,
|
|
858
|
+
current_version,
|
|
859
|
+
evidence._canonical_json(report.model_dump(mode="json")),
|
|
860
|
+
),
|
|
861
|
+
)
|
|
862
|
+
state_update = connection.execute(
|
|
863
|
+
"""
|
|
864
|
+
UPDATE work_order_state
|
|
865
|
+
SET current_state = ?, version = version + 1
|
|
866
|
+
WHERE singleton = 1
|
|
867
|
+
AND work_order_digest = ?
|
|
868
|
+
AND current_state = ?
|
|
869
|
+
AND version = ?
|
|
870
|
+
""",
|
|
871
|
+
(
|
|
872
|
+
trigger.state_after,
|
|
873
|
+
work_order.digest,
|
|
874
|
+
current_state,
|
|
875
|
+
current_version,
|
|
876
|
+
),
|
|
877
|
+
)
|
|
878
|
+
sequence_update = connection.execute(
|
|
879
|
+
"""
|
|
880
|
+
UPDATE sequence_counter
|
|
881
|
+
SET next_sequence = next_sequence + 1
|
|
882
|
+
WHERE singleton = 1 AND next_sequence = ?
|
|
883
|
+
""",
|
|
884
|
+
(initiator.sequence,),
|
|
885
|
+
)
|
|
886
|
+
trigger_sequence_update = connection.execute(
|
|
887
|
+
"""
|
|
888
|
+
UPDATE sequence_counter
|
|
889
|
+
SET next_sequence = next_sequence + 1
|
|
890
|
+
WHERE singleton = 1 AND next_sequence = ?
|
|
891
|
+
""",
|
|
892
|
+
(trigger.sequence,),
|
|
893
|
+
)
|
|
894
|
+
if (
|
|
895
|
+
state_update.rowcount != 1
|
|
896
|
+
or sequence_update.rowcount != 1
|
|
897
|
+
or trigger_sequence_update.rowcount != 1
|
|
898
|
+
):
|
|
899
|
+
raise AcceptanceTransactionError(
|
|
900
|
+
"composition counters could not be advanced"
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def compose_proof_transaction(
|
|
905
|
+
ledger_path: Path,
|
|
906
|
+
*,
|
|
907
|
+
evidence_root: Path,
|
|
908
|
+
context: AuthorizationContext,
|
|
909
|
+
request: AgentRequest,
|
|
910
|
+
sidecar_private_key: Ed25519PrivateKey,
|
|
911
|
+
clock: Callable[[], datetime],
|
|
912
|
+
) -> CompositionTransactionResult:
|
|
913
|
+
"""Atomically commit one Manager compose proof and its report."""
|
|
914
|
+
from openworkproof.models import request_arguments_digest # noqa: PLC0415
|
|
915
|
+
|
|
916
|
+
path = Path(ledger_path)
|
|
917
|
+
if not path.is_file():
|
|
918
|
+
raise AcceptanceTransactionError("composition ledger is unavailable")
|
|
919
|
+
lock_descriptor: int | None = None
|
|
920
|
+
owns_lock = False
|
|
921
|
+
connection: sqlite3.Connection | None = None
|
|
922
|
+
committed_result: CompositionTransactionResult | None = None
|
|
923
|
+
try:
|
|
924
|
+
lock_descriptor, owns_lock = evidence._borrow_or_acquire_target_lock(
|
|
925
|
+
path, None
|
|
926
|
+
)
|
|
927
|
+
now = _freeze_second(clock())
|
|
928
|
+
if context.transaction_time != now:
|
|
929
|
+
raise AcceptanceTransactionError(
|
|
930
|
+
"authorization context time is stale"
|
|
931
|
+
)
|
|
932
|
+
require_current_context(
|
|
933
|
+
path,
|
|
934
|
+
evidence_root,
|
|
935
|
+
context,
|
|
936
|
+
now,
|
|
937
|
+
lock_descriptor,
|
|
938
|
+
)
|
|
939
|
+
if request.tool_name != "owp.compose_proof":
|
|
940
|
+
raise AcceptanceTransactionError("compose request tool is invalid")
|
|
941
|
+
if context.current_state == "locally_verified":
|
|
942
|
+
expected_previous_report_digest = None
|
|
943
|
+
elif context.current_state == "evidence_incomplete":
|
|
944
|
+
current_report = _current_report(path, context.work_order)
|
|
945
|
+
trigger = _latest_proof_composed_trigger(context)
|
|
946
|
+
expected_previous_report_digest = composition_report_digest(
|
|
947
|
+
current_report
|
|
948
|
+
)
|
|
949
|
+
if (
|
|
950
|
+
getattr(trigger.cause, "composition_report_digest", None)
|
|
951
|
+
!= expected_previous_report_digest
|
|
952
|
+
):
|
|
953
|
+
raise AcceptanceTransactionError(
|
|
954
|
+
"current report does not match its proof-composed trigger"
|
|
955
|
+
)
|
|
956
|
+
else:
|
|
957
|
+
raise AcceptanceTransactionError(
|
|
958
|
+
"composition state is invalid"
|
|
959
|
+
)
|
|
960
|
+
expected = ComposeProofArguments(
|
|
961
|
+
expected_state_version=evidence._derive_protocol_transaction_version(
|
|
962
|
+
action_receipts=context.ledger_prefix.receipts,
|
|
963
|
+
acceptance_receipts=(),
|
|
964
|
+
),
|
|
965
|
+
previous_report_digest=expected_previous_report_digest,
|
|
966
|
+
)
|
|
967
|
+
if request.arguments_digest != request_arguments_digest(
|
|
968
|
+
"owp.compose_proof", expected
|
|
969
|
+
):
|
|
970
|
+
raise AcceptanceTransactionError(
|
|
971
|
+
"compose request arguments do not match current context"
|
|
972
|
+
)
|
|
973
|
+
prefix_len = len(context.ledger_prefix.receipts)
|
|
974
|
+
initiator = _build_compose_receipt(
|
|
975
|
+
context,
|
|
976
|
+
request,
|
|
977
|
+
expected,
|
|
978
|
+
sidecar_private_key,
|
|
979
|
+
sequence=prefix_len + 1,
|
|
980
|
+
previous_digest=(
|
|
981
|
+
context.ledger_prefix.receipts[-1].digest
|
|
982
|
+
if context.ledger_prefix.receipts
|
|
983
|
+
else None
|
|
984
|
+
),
|
|
985
|
+
)
|
|
986
|
+
prefix = tuple(context.ledger_prefix.receipts) + (initiator,)
|
|
987
|
+
report = _derive_composition_report(context, initiator, prefix, now)
|
|
988
|
+
if (
|
|
989
|
+
context.current_state == "evidence_incomplete"
|
|
990
|
+
and report.verifier_conclusion != "proof_ready"
|
|
991
|
+
):
|
|
992
|
+
raise AcceptanceTransactionError(
|
|
993
|
+
"recomposition remains evidence-incomplete"
|
|
994
|
+
)
|
|
995
|
+
trigger = _build_proof_composed_receipt(
|
|
996
|
+
context,
|
|
997
|
+
initiator,
|
|
998
|
+
report,
|
|
999
|
+
sidecar_private_key,
|
|
1000
|
+
sequence=prefix_len + 2,
|
|
1001
|
+
previous_digest=initiator.digest,
|
|
1002
|
+
)
|
|
1003
|
+
result = CompositionTransactionResult(
|
|
1004
|
+
initiator_receipt=initiator,
|
|
1005
|
+
report=report,
|
|
1006
|
+
trigger_receipt=trigger,
|
|
1007
|
+
)
|
|
1008
|
+
connection = evidence.connect_ledger(path)
|
|
1009
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
1010
|
+
work_order, receipts, _, _ = (
|
|
1011
|
+
evidence._replay_receipt_publication_ledger(connection)
|
|
1012
|
+
)
|
|
1013
|
+
state_row = connection.execute(
|
|
1014
|
+
"SELECT current_state, version FROM work_order_state WHERE singleton = 1"
|
|
1015
|
+
).fetchone()
|
|
1016
|
+
expected_version = evidence._derive_protocol_transaction_version(
|
|
1017
|
+
action_receipts=receipts,
|
|
1018
|
+
acceptance_receipts=(),
|
|
1019
|
+
)
|
|
1020
|
+
if (
|
|
1021
|
+
work_order != context.work_order
|
|
1022
|
+
or len(receipts) != prefix_len
|
|
1023
|
+
or state_row != (context.current_state, expected_version)
|
|
1024
|
+
):
|
|
1025
|
+
raise AcceptanceTransactionError(
|
|
1026
|
+
"composition ledger changed under the target lock"
|
|
1027
|
+
)
|
|
1028
|
+
_insert_compose_rows(
|
|
1029
|
+
connection,
|
|
1030
|
+
work_order=work_order,
|
|
1031
|
+
initiator=initiator,
|
|
1032
|
+
trigger=trigger,
|
|
1033
|
+
report=report,
|
|
1034
|
+
current_state=context.current_state,
|
|
1035
|
+
current_version=state_row[1],
|
|
1036
|
+
)
|
|
1037
|
+
try:
|
|
1038
|
+
connection.execute("COMMIT")
|
|
1039
|
+
except Exception as error:
|
|
1040
|
+
if _readback_compose_committed(
|
|
1041
|
+
path,
|
|
1042
|
+
work_order=work_order,
|
|
1043
|
+
initiator=initiator,
|
|
1044
|
+
trigger=trigger,
|
|
1045
|
+
report=report,
|
|
1046
|
+
expected_state=trigger.state_after,
|
|
1047
|
+
expected_version=state_row[1] + 1,
|
|
1048
|
+
):
|
|
1049
|
+
committed_result = result
|
|
1050
|
+
raise AcceptanceCommittedError(
|
|
1051
|
+
"composition committed but its acknowledgement was lost",
|
|
1052
|
+
result,
|
|
1053
|
+
) from error
|
|
1054
|
+
raise AcceptanceCommitIndeterminateError(
|
|
1055
|
+
"composition commit outcome is indeterminate"
|
|
1056
|
+
) from error
|
|
1057
|
+
if not _readback_compose_committed(
|
|
1058
|
+
path,
|
|
1059
|
+
work_order=work_order,
|
|
1060
|
+
initiator=initiator,
|
|
1061
|
+
trigger=trigger,
|
|
1062
|
+
report=report,
|
|
1063
|
+
expected_state=trigger.state_after,
|
|
1064
|
+
expected_version=state_row[1] + 1,
|
|
1065
|
+
):
|
|
1066
|
+
raise AcceptanceCommitIndeterminateError(
|
|
1067
|
+
"composition readback could not confirm the exact commit"
|
|
1068
|
+
)
|
|
1069
|
+
committed_result = result
|
|
1070
|
+
return result
|
|
1071
|
+
except (RuntimeContextError, AcceptanceTransactionError) as error:
|
|
1072
|
+
rollback_error = evidence._best_effort_rollback(connection)
|
|
1073
|
+
if rollback_error is not None:
|
|
1074
|
+
raise AcceptanceTransactionError(
|
|
1075
|
+
"composition rollback failed"
|
|
1076
|
+
) from rollback_error
|
|
1077
|
+
raise
|
|
1078
|
+
except Exception as error:
|
|
1079
|
+
rollback_error = evidence._best_effort_rollback(connection)
|
|
1080
|
+
if rollback_error is not None:
|
|
1081
|
+
raise AcceptanceCommitIndeterminateError(
|
|
1082
|
+
"composition rollback failed"
|
|
1083
|
+
) from rollback_error
|
|
1084
|
+
raise
|
|
1085
|
+
finally:
|
|
1086
|
+
cleanup_errors: list[Exception] = []
|
|
1087
|
+
close_error = evidence._best_effort_close(connection)
|
|
1088
|
+
if close_error is not None:
|
|
1089
|
+
cleanup_errors.append(close_error)
|
|
1090
|
+
if owns_lock:
|
|
1091
|
+
_, release_errors = evidence._release_target_lock(lock_descriptor)
|
|
1092
|
+
cleanup_errors.extend(release_errors)
|
|
1093
|
+
if cleanup_errors:
|
|
1094
|
+
if committed_result is not None:
|
|
1095
|
+
raise AcceptanceCommittedError(
|
|
1096
|
+
"composition committed but cleanup failed",
|
|
1097
|
+
committed_result,
|
|
1098
|
+
) from cleanup_errors[0]
|
|
1099
|
+
else:
|
|
1100
|
+
raise AcceptanceCommitIndeterminateError(
|
|
1101
|
+
"composition cleanup failed"
|
|
1102
|
+
) from cleanup_errors[0]
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _current_report(
|
|
1106
|
+
ledger_path: Path,
|
|
1107
|
+
work_order,
|
|
1108
|
+
) -> CompositionReport:
|
|
1109
|
+
connection = evidence.connect_ledger(ledger_path)
|
|
1110
|
+
try:
|
|
1111
|
+
reports = evidence._validated_composition_reports(
|
|
1112
|
+
connection,
|
|
1113
|
+
work_order,
|
|
1114
|
+
)
|
|
1115
|
+
finally:
|
|
1116
|
+
connection.close()
|
|
1117
|
+
if not reports:
|
|
1118
|
+
raise AcceptanceTransactionError(
|
|
1119
|
+
"final acceptance requires a current composition report"
|
|
1120
|
+
)
|
|
1121
|
+
return reports[-1]
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _build_acceptance_request_receipt(
|
|
1125
|
+
context: AuthorizationContext,
|
|
1126
|
+
request: AgentRequest,
|
|
1127
|
+
report: CompositionReport,
|
|
1128
|
+
*,
|
|
1129
|
+
expires_at: datetime,
|
|
1130
|
+
sidecar_private_key: Ed25519PrivateKey,
|
|
1131
|
+
sequence: int,
|
|
1132
|
+
previous_digest: str,
|
|
1133
|
+
) -> ApprovalRequestedReceipt:
|
|
1134
|
+
scope = {
|
|
1135
|
+
"work_order_digest": context.work_order.digest,
|
|
1136
|
+
"operation": "submit_final_acceptance",
|
|
1137
|
+
"composition_report_digest": composition_report_digest(report),
|
|
1138
|
+
}
|
|
1139
|
+
target_action_digest = hashlib.sha256(
|
|
1140
|
+
rfc8785.dumps(
|
|
1141
|
+
{
|
|
1142
|
+
"domain": "openworkproof/final-acceptance-action/v0.1",
|
|
1143
|
+
"requested_scope": scope,
|
|
1144
|
+
}
|
|
1145
|
+
)
|
|
1146
|
+
).hexdigest()
|
|
1147
|
+
arguments = {
|
|
1148
|
+
"request_kind": "final_acceptance",
|
|
1149
|
+
"target_action_digest": target_action_digest,
|
|
1150
|
+
"required_role": "Acceptor",
|
|
1151
|
+
"requested_scope": scope,
|
|
1152
|
+
"expires_at": expires_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
1153
|
+
}
|
|
1154
|
+
if request.arguments_digest != request_arguments_digest(
|
|
1155
|
+
"owp.request_acceptance", arguments
|
|
1156
|
+
):
|
|
1157
|
+
raise AcceptanceTransactionError(
|
|
1158
|
+
"acceptance request arguments do not match current report"
|
|
1159
|
+
)
|
|
1160
|
+
remaining_before = _remaining_tool_calls(context, request.grant_id)
|
|
1161
|
+
sidecar_key_id = key_id(sidecar_private_key.public_key())
|
|
1162
|
+
receipt_id = hashlib.sha256(
|
|
1163
|
+
rfc8785.dumps(
|
|
1164
|
+
{
|
|
1165
|
+
"domain": "openworkproof/receipt-id/v0.1",
|
|
1166
|
+
"request_digest": request.digest,
|
|
1167
|
+
"entropy": secrets.token_hex(32),
|
|
1168
|
+
}
|
|
1169
|
+
)
|
|
1170
|
+
).hexdigest()
|
|
1171
|
+
raw = {
|
|
1172
|
+
"protocol_version": "0.1",
|
|
1173
|
+
"receipt_id": receipt_id,
|
|
1174
|
+
"work_order_digest": context.work_order.digest,
|
|
1175
|
+
"actor_type": "agent",
|
|
1176
|
+
"actor_id": request.actor_id,
|
|
1177
|
+
"actor_key_id": request.actor_key_id,
|
|
1178
|
+
"nested_claim_type": "agent-request",
|
|
1179
|
+
"nested_claim_digest": request.digest,
|
|
1180
|
+
"nested_claim": request.model_dump(mode="json"),
|
|
1181
|
+
"gateway_signer_key_id": sidecar_key_id,
|
|
1182
|
+
"event_type": "approval_requested",
|
|
1183
|
+
"policy_decision": "allow",
|
|
1184
|
+
"policy_error_code": None,
|
|
1185
|
+
"execution_status": "succeeded",
|
|
1186
|
+
"execution_error_code": None,
|
|
1187
|
+
"quota_charge": {
|
|
1188
|
+
"grant_id": request.grant_id,
|
|
1189
|
+
"metric": "tool_calls",
|
|
1190
|
+
"amount": 1,
|
|
1191
|
+
"remaining_after": remaining_before - 1,
|
|
1192
|
+
},
|
|
1193
|
+
"state_before": context.current_state,
|
|
1194
|
+
"state_after": "awaiting_human",
|
|
1195
|
+
"parent_receipt_ids": list(
|
|
1196
|
+
_request_parents(context, report, request.grant_id)
|
|
1197
|
+
),
|
|
1198
|
+
"correlation_factors": None,
|
|
1199
|
+
"evidence_refs": [],
|
|
1200
|
+
"occurred_at": context.transaction_time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
1201
|
+
"sequence": sequence,
|
|
1202
|
+
"nonce": request.nonce,
|
|
1203
|
+
"previous_receipt_digest": previous_digest,
|
|
1204
|
+
"grant_id": request.grant_id,
|
|
1205
|
+
"request_kind": "final_acceptance",
|
|
1206
|
+
"target_action_digest": target_action_digest,
|
|
1207
|
+
"required_role": "Acceptor",
|
|
1208
|
+
"requested_scope": scope,
|
|
1209
|
+
"expires_at": expires_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
1210
|
+
}
|
|
1211
|
+
return evidence.ACTION_RECEIPT_ADAPTER.validate_python(
|
|
1212
|
+
sign_payload("action-receipt", raw, sidecar_private_key)
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def _request_parents(
|
|
1217
|
+
context: AuthorizationContext,
|
|
1218
|
+
report: CompositionReport,
|
|
1219
|
+
grant_id: str,
|
|
1220
|
+
) -> tuple[ActionReceiptEnvelope, ...]:
|
|
1221
|
+
"""Final-request causal parents: grant issuance + proof_composed trigger."""
|
|
1222
|
+
receipts = context.ledger_prefix.receipts
|
|
1223
|
+
parents: dict[str, ActionReceiptEnvelope] = {}
|
|
1224
|
+
issuance = next(
|
|
1225
|
+
(
|
|
1226
|
+
receipt
|
|
1227
|
+
for receipt in receipts
|
|
1228
|
+
if isinstance(receipt, GrantIssuedReceipt)
|
|
1229
|
+
and receipt.policy_decision == "allow"
|
|
1230
|
+
and receipt.issued_grant_id == grant_id
|
|
1231
|
+
),
|
|
1232
|
+
None,
|
|
1233
|
+
)
|
|
1234
|
+
if issuance is None:
|
|
1235
|
+
raise AcceptanceTransactionError(
|
|
1236
|
+
"acceptance request grant issuance is unavailable"
|
|
1237
|
+
)
|
|
1238
|
+
parents[issuance.receipt_id] = issuance
|
|
1239
|
+
# The trigger is the proof_composed SystemEventReceipt that bound the
|
|
1240
|
+
# current report digest.
|
|
1241
|
+
trigger = next(
|
|
1242
|
+
(
|
|
1243
|
+
receipt
|
|
1244
|
+
for receipt in receipts
|
|
1245
|
+
if isinstance(receipt, SystemEventReceipt)
|
|
1246
|
+
and receipt.system_event_name == "proof_composed"
|
|
1247
|
+
and getattr(receipt.cause, "composition_report_digest", None)
|
|
1248
|
+
== composition_report_digest(report)
|
|
1249
|
+
),
|
|
1250
|
+
None,
|
|
1251
|
+
)
|
|
1252
|
+
if trigger is not None:
|
|
1253
|
+
parents[trigger.receipt_id] = trigger
|
|
1254
|
+
return tuple(
|
|
1255
|
+
receipt.receipt_id
|
|
1256
|
+
for receipt in sorted(
|
|
1257
|
+
parents.values(),
|
|
1258
|
+
key=lambda item: item.sequence,
|
|
1259
|
+
)
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def _authorize_acceptance_request(
|
|
1264
|
+
context: AuthorizationContext,
|
|
1265
|
+
request: AgentRequest,
|
|
1266
|
+
) -> None:
|
|
1267
|
+
"""Prospective authorization for the final-acceptance request.
|
|
1268
|
+
|
|
1269
|
+
Mirrors the tool-call gate: verified signature, freshness, Manager
|
|
1270
|
+
role, Grant subject binding, capability, revocation, validity and
|
|
1271
|
+
quota must all hold before any allow receipt is constructed.
|
|
1272
|
+
"""
|
|
1273
|
+
from openworkproof.signing import (
|
|
1274
|
+
decode_and_verify_key_binding,
|
|
1275
|
+
verify_payload,
|
|
1276
|
+
) # noqa: PLC0415
|
|
1277
|
+
|
|
1278
|
+
work_order = context.work_order
|
|
1279
|
+
if (
|
|
1280
|
+
request.work_order_digest != work_order.digest
|
|
1281
|
+
or request.tool_name != "owp.request_acceptance"
|
|
1282
|
+
):
|
|
1283
|
+
raise AcceptanceTransactionError(
|
|
1284
|
+
"acceptance request work_order binding is invalid"
|
|
1285
|
+
)
|
|
1286
|
+
grants = {
|
|
1287
|
+
grant.grant_id: grant
|
|
1288
|
+
for grant in context.ledger_prefix.effective_grants
|
|
1289
|
+
}
|
|
1290
|
+
grant = grants.get(request.grant_id)
|
|
1291
|
+
bindings = {
|
|
1292
|
+
binding.key_id: binding
|
|
1293
|
+
for binding in work_order.key_bindings
|
|
1294
|
+
}
|
|
1295
|
+
binding = bindings.get(request.actor_key_id)
|
|
1296
|
+
if (
|
|
1297
|
+
grant is None
|
|
1298
|
+
or binding is None
|
|
1299
|
+
or request.actor_id != binding.subject_id
|
|
1300
|
+
or grant.subject_key_id != binding.key_id
|
|
1301
|
+
or grant.subject_agent_id != binding.subject_id
|
|
1302
|
+
):
|
|
1303
|
+
raise AcceptanceTransactionError(
|
|
1304
|
+
"acceptance request Grant or actor binding is invalid"
|
|
1305
|
+
)
|
|
1306
|
+
if binding.role != "Manager":
|
|
1307
|
+
raise AcceptanceTransactionError(
|
|
1308
|
+
"final acceptance must be requested by the Manager"
|
|
1309
|
+
)
|
|
1310
|
+
try:
|
|
1311
|
+
public_key = decode_and_verify_key_binding(binding)
|
|
1312
|
+
except Exception as error:
|
|
1313
|
+
raise AcceptanceTransactionError(
|
|
1314
|
+
"acceptance request key binding is invalid"
|
|
1315
|
+
) from error
|
|
1316
|
+
if not verify_payload(
|
|
1317
|
+
"agent-request",
|
|
1318
|
+
request.model_dump(mode="json"),
|
|
1319
|
+
public_key,
|
|
1320
|
+
):
|
|
1321
|
+
raise AcceptanceTransactionError(
|
|
1322
|
+
"acceptance request signature is invalid"
|
|
1323
|
+
)
|
|
1324
|
+
request_age = context.transaction_time - request.requested_at
|
|
1325
|
+
if request_age < timedelta(0) or request_age > timedelta(seconds=300):
|
|
1326
|
+
raise AcceptanceTransactionError(
|
|
1327
|
+
"acceptance request is outside the freshness window"
|
|
1328
|
+
)
|
|
1329
|
+
if context.transaction_time > work_order.deadline:
|
|
1330
|
+
raise AcceptanceTransactionError(
|
|
1331
|
+
"contract expired before the acceptance request"
|
|
1332
|
+
)
|
|
1333
|
+
if request.grant_id in context.replay_state.revoked_grant_ids:
|
|
1334
|
+
raise AcceptanceTransactionError(
|
|
1335
|
+
"acceptance request Grant is revoked"
|
|
1336
|
+
)
|
|
1337
|
+
if (
|
|
1338
|
+
request.tool_name not in work_order.allowed_tools
|
|
1339
|
+
or request.tool_name not in grant.allowed_tools
|
|
1340
|
+
):
|
|
1341
|
+
raise AcceptanceTransactionError(
|
|
1342
|
+
"acceptance request capability is denied"
|
|
1343
|
+
)
|
|
1344
|
+
if not (
|
|
1345
|
+
grant.valid_from
|
|
1346
|
+
<= context.transaction_time
|
|
1347
|
+
<= min(grant.expires_at, work_order.deadline)
|
|
1348
|
+
):
|
|
1349
|
+
raise AcceptanceTransactionError(
|
|
1350
|
+
"acceptance request Grant is outside its validity"
|
|
1351
|
+
)
|
|
1352
|
+
if _remaining_tool_calls(context, request.grant_id) <= 0:
|
|
1353
|
+
raise AcceptanceTransactionError(
|
|
1354
|
+
"acceptance request Grant quota is exhausted"
|
|
1355
|
+
)
|
|
1356
|
+
if any(
|
|
1357
|
+
receipt.nonce == request.nonce
|
|
1358
|
+
for receipt in context.ledger_prefix.receipts
|
|
1359
|
+
):
|
|
1360
|
+
raise AcceptanceTransactionError(
|
|
1361
|
+
"acceptance request nonce is already used"
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def _latest_proof_composed_trigger(
|
|
1366
|
+
context: AuthorizationContext,
|
|
1367
|
+
) -> SystemEventReceipt:
|
|
1368
|
+
trigger_id = context.causal_state.latest_composition_trigger_id
|
|
1369
|
+
matches = tuple(
|
|
1370
|
+
receipt
|
|
1371
|
+
for receipt in context.ledger_prefix.receipts
|
|
1372
|
+
if isinstance(receipt, SystemEventReceipt)
|
|
1373
|
+
and receipt.receipt_id == trigger_id
|
|
1374
|
+
and receipt.system_event_name == "proof_composed"
|
|
1375
|
+
)
|
|
1376
|
+
if len(matches) != 1:
|
|
1377
|
+
raise AcceptanceTransactionError(
|
|
1378
|
+
"current composition trigger is unavailable"
|
|
1379
|
+
)
|
|
1380
|
+
return matches[0]
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def request_acceptance_transaction(
|
|
1384
|
+
ledger_path: Path,
|
|
1385
|
+
*,
|
|
1386
|
+
evidence_root: Path,
|
|
1387
|
+
context: AuthorizationContext,
|
|
1388
|
+
request: AgentRequest,
|
|
1389
|
+
sidecar_private_key: Ed25519PrivateKey,
|
|
1390
|
+
expires_at: datetime,
|
|
1391
|
+
clock: Callable[[], datetime],
|
|
1392
|
+
) -> ApprovalRequestedReceipt:
|
|
1393
|
+
"""Atomically request final acceptance from proof_ready."""
|
|
1394
|
+
path = Path(ledger_path)
|
|
1395
|
+
if not path.is_file():
|
|
1396
|
+
raise AcceptanceTransactionError("acceptance request ledger is unavailable")
|
|
1397
|
+
lock_descriptor: int | None = None
|
|
1398
|
+
owns_lock = False
|
|
1399
|
+
connection: sqlite3.Connection | None = None
|
|
1400
|
+
committed_result: ApprovalRequestedReceipt | None = None
|
|
1401
|
+
try:
|
|
1402
|
+
lock_descriptor, owns_lock = evidence._borrow_or_acquire_target_lock(
|
|
1403
|
+
path, None
|
|
1404
|
+
)
|
|
1405
|
+
now = _freeze_second(clock())
|
|
1406
|
+
if context.transaction_time != now:
|
|
1407
|
+
raise AcceptanceTransactionError(
|
|
1408
|
+
"authorization context time is stale"
|
|
1409
|
+
)
|
|
1410
|
+
require_current_context(
|
|
1411
|
+
path,
|
|
1412
|
+
evidence_root,
|
|
1413
|
+
context,
|
|
1414
|
+
now,
|
|
1415
|
+
lock_descriptor,
|
|
1416
|
+
)
|
|
1417
|
+
if context.current_state != "proof_ready":
|
|
1418
|
+
raise AcceptanceTransactionError(
|
|
1419
|
+
"final acceptance requires proof_ready"
|
|
1420
|
+
)
|
|
1421
|
+
if request.tool_name != "owp.request_acceptance":
|
|
1422
|
+
raise AcceptanceTransactionError(
|
|
1423
|
+
"acceptance request tool is invalid"
|
|
1424
|
+
)
|
|
1425
|
+
_authorize_acceptance_request(context, request)
|
|
1426
|
+
if expires_at.tzinfo is None or expires_at.utcoffset() is None:
|
|
1427
|
+
raise AcceptanceTransactionError("request expiry must be UTC")
|
|
1428
|
+
expiry = expires_at.astimezone(timezone.utc)
|
|
1429
|
+
validity = (expiry - now).total_seconds()
|
|
1430
|
+
if not 0 <= validity <= 3600:
|
|
1431
|
+
raise AcceptanceTransactionError(
|
|
1432
|
+
"request expiry must be within one hour"
|
|
1433
|
+
)
|
|
1434
|
+
if expiry > context.work_order.deadline:
|
|
1435
|
+
raise AcceptanceTransactionError(
|
|
1436
|
+
"request expiry exceeds the WorkOrder deadline"
|
|
1437
|
+
)
|
|
1438
|
+
work_order, receipts, _, _ = evidence._replay_receipt_publication_ledger(
|
|
1439
|
+
evidence.connect_ledger(path)
|
|
1440
|
+
)
|
|
1441
|
+
report = _current_report(path, work_order)
|
|
1442
|
+
prefix_len = len(context.ledger_prefix.receipts)
|
|
1443
|
+
request_receipt = _build_acceptance_request_receipt(
|
|
1444
|
+
context,
|
|
1445
|
+
request,
|
|
1446
|
+
report,
|
|
1447
|
+
expires_at=expiry,
|
|
1448
|
+
sidecar_private_key=sidecar_private_key,
|
|
1449
|
+
sequence=prefix_len + 1,
|
|
1450
|
+
previous_digest=(
|
|
1451
|
+
context.ledger_prefix.receipts[-1].digest
|
|
1452
|
+
if context.ledger_prefix.receipts
|
|
1453
|
+
else None
|
|
1454
|
+
),
|
|
1455
|
+
)
|
|
1456
|
+
connection = evidence.connect_ledger(path)
|
|
1457
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
1458
|
+
work_order, receipts, _, _ = (
|
|
1459
|
+
evidence._replay_receipt_publication_ledger(connection)
|
|
1460
|
+
)
|
|
1461
|
+
state_row = connection.execute(
|
|
1462
|
+
"SELECT current_state, version FROM work_order_state WHERE singleton = 1"
|
|
1463
|
+
).fetchone()
|
|
1464
|
+
expected_version = evidence._derive_protocol_transaction_version(
|
|
1465
|
+
action_receipts=receipts,
|
|
1466
|
+
acceptance_receipts=(),
|
|
1467
|
+
)
|
|
1468
|
+
if (
|
|
1469
|
+
work_order != context.work_order
|
|
1470
|
+
or len(receipts) != prefix_len
|
|
1471
|
+
or state_row != (context.current_state, expected_version)
|
|
1472
|
+
):
|
|
1473
|
+
raise AcceptanceTransactionError(
|
|
1474
|
+
"acceptance request ledger changed under the target lock"
|
|
1475
|
+
)
|
|
1476
|
+
_insert_request_rows(
|
|
1477
|
+
connection,
|
|
1478
|
+
work_order=work_order,
|
|
1479
|
+
request_receipt=request_receipt,
|
|
1480
|
+
current_state=context.current_state,
|
|
1481
|
+
current_version=state_row[1],
|
|
1482
|
+
)
|
|
1483
|
+
try:
|
|
1484
|
+
connection.execute("COMMIT")
|
|
1485
|
+
except Exception as error:
|
|
1486
|
+
if _readback_request_committed(
|
|
1487
|
+
path,
|
|
1488
|
+
work_order=work_order,
|
|
1489
|
+
request_receipt=request_receipt,
|
|
1490
|
+
):
|
|
1491
|
+
committed_result = request_receipt
|
|
1492
|
+
raise AcceptanceCommittedError(
|
|
1493
|
+
"acceptance request committed but its acknowledgement "
|
|
1494
|
+
"was lost",
|
|
1495
|
+
request_receipt,
|
|
1496
|
+
) from error
|
|
1497
|
+
raise AcceptanceCommitIndeterminateError(
|
|
1498
|
+
"acceptance request commit outcome is indeterminate"
|
|
1499
|
+
) from error
|
|
1500
|
+
if not _readback_request_committed(
|
|
1501
|
+
path,
|
|
1502
|
+
work_order=work_order,
|
|
1503
|
+
request_receipt=request_receipt,
|
|
1504
|
+
):
|
|
1505
|
+
raise AcceptanceCommitIndeterminateError(
|
|
1506
|
+
"acceptance request readback could not confirm the exact commit"
|
|
1507
|
+
)
|
|
1508
|
+
committed_result = request_receipt
|
|
1509
|
+
return request_receipt
|
|
1510
|
+
except (RuntimeContextError, AcceptanceTransactionError):
|
|
1511
|
+
rollback_error = evidence._best_effort_rollback(connection)
|
|
1512
|
+
if rollback_error is not None:
|
|
1513
|
+
raise AcceptanceTransactionError(
|
|
1514
|
+
"acceptance request rollback failed"
|
|
1515
|
+
) from rollback_error
|
|
1516
|
+
raise
|
|
1517
|
+
finally:
|
|
1518
|
+
cleanup_errors: list[Exception] = []
|
|
1519
|
+
close_error = evidence._best_effort_close(connection)
|
|
1520
|
+
if close_error is not None:
|
|
1521
|
+
cleanup_errors.append(close_error)
|
|
1522
|
+
if owns_lock:
|
|
1523
|
+
_, release_errors = evidence._release_target_lock(lock_descriptor)
|
|
1524
|
+
cleanup_errors.extend(release_errors)
|
|
1525
|
+
if cleanup_errors:
|
|
1526
|
+
if committed_result is not None:
|
|
1527
|
+
raise AcceptanceCommittedError(
|
|
1528
|
+
"acceptance request committed but cleanup failed",
|
|
1529
|
+
committed_result,
|
|
1530
|
+
) from cleanup_errors[0]
|
|
1531
|
+
else:
|
|
1532
|
+
raise AcceptanceCommitIndeterminateError(
|
|
1533
|
+
"acceptance request cleanup failed"
|
|
1534
|
+
) from cleanup_errors[0]
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
def _insert_request_rows(
|
|
1538
|
+
connection: sqlite3.Connection,
|
|
1539
|
+
*,
|
|
1540
|
+
work_order,
|
|
1541
|
+
request_receipt: ApprovalRequestedReceipt,
|
|
1542
|
+
current_state: str,
|
|
1543
|
+
current_version: int,
|
|
1544
|
+
) -> None:
|
|
1545
|
+
connection.execute(
|
|
1546
|
+
"""
|
|
1547
|
+
INSERT INTO receipts (
|
|
1548
|
+
receipt_id, work_order_digest, nonce, sequence,
|
|
1549
|
+
previous_digest, receipt_json
|
|
1550
|
+
)
|
|
1551
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1552
|
+
""",
|
|
1553
|
+
(
|
|
1554
|
+
request_receipt.receipt_id,
|
|
1555
|
+
work_order.digest,
|
|
1556
|
+
request_receipt.nonce,
|
|
1557
|
+
request_receipt.sequence,
|
|
1558
|
+
request_receipt.previous_receipt_digest,
|
|
1559
|
+
evidence._canonical_json(request_receipt.model_dump(mode="json")),
|
|
1560
|
+
),
|
|
1561
|
+
)
|
|
1562
|
+
for parent_id in request_receipt.parent_receipt_ids:
|
|
1563
|
+
connection.execute(
|
|
1564
|
+
"""
|
|
1565
|
+
INSERT INTO receipt_parents (child_receipt_id, parent_receipt_id)
|
|
1566
|
+
VALUES (?, ?)
|
|
1567
|
+
""",
|
|
1568
|
+
(request_receipt.receipt_id, parent_id),
|
|
1569
|
+
)
|
|
1570
|
+
charge = request_receipt.quota_charge
|
|
1571
|
+
if charge is None:
|
|
1572
|
+
raise AcceptanceTransactionError(
|
|
1573
|
+
"acceptance request must charge quota"
|
|
1574
|
+
)
|
|
1575
|
+
connection.execute(
|
|
1576
|
+
"""
|
|
1577
|
+
INSERT INTO grant_events (
|
|
1578
|
+
event_id, receipt_id, grant_id, event_type, metric, amount
|
|
1579
|
+
)
|
|
1580
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1581
|
+
""",
|
|
1582
|
+
(
|
|
1583
|
+
hashlib.sha256(
|
|
1584
|
+
f"grant-event:{request_receipt.receipt_id}".encode("ascii")
|
|
1585
|
+
).hexdigest(),
|
|
1586
|
+
request_receipt.receipt_id,
|
|
1587
|
+
charge.grant_id,
|
|
1588
|
+
request_receipt.event_type,
|
|
1589
|
+
charge.metric,
|
|
1590
|
+
charge.amount,
|
|
1591
|
+
),
|
|
1592
|
+
)
|
|
1593
|
+
state_update = connection.execute(
|
|
1594
|
+
"""
|
|
1595
|
+
UPDATE work_order_state
|
|
1596
|
+
SET current_state = ?, version = version + 1
|
|
1597
|
+
WHERE singleton = 1
|
|
1598
|
+
AND work_order_digest = ?
|
|
1599
|
+
AND current_state = ?
|
|
1600
|
+
AND version = ?
|
|
1601
|
+
""",
|
|
1602
|
+
(
|
|
1603
|
+
request_receipt.state_after,
|
|
1604
|
+
work_order.digest,
|
|
1605
|
+
current_state,
|
|
1606
|
+
current_version,
|
|
1607
|
+
),
|
|
1608
|
+
)
|
|
1609
|
+
sequence_update = connection.execute(
|
|
1610
|
+
"""
|
|
1611
|
+
UPDATE sequence_counter
|
|
1612
|
+
SET next_sequence = next_sequence + 1
|
|
1613
|
+
WHERE singleton = 1 AND next_sequence = ?
|
|
1614
|
+
""",
|
|
1615
|
+
(request_receipt.sequence,),
|
|
1616
|
+
)
|
|
1617
|
+
if state_update.rowcount != 1 or sequence_update.rowcount != 1:
|
|
1618
|
+
raise AcceptanceTransactionError(
|
|
1619
|
+
"acceptance request counters could not be advanced"
|
|
1620
|
+
)
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
def _current_acceptance_request(
|
|
1624
|
+
ledger_path: Path,
|
|
1625
|
+
work_order,
|
|
1626
|
+
) -> ApprovalRequestedReceipt:
|
|
1627
|
+
connection = evidence.connect_ledger(ledger_path)
|
|
1628
|
+
try:
|
|
1629
|
+
_, receipts, _, _ = evidence._replay_receipt_publication_ledger(connection)
|
|
1630
|
+
finally:
|
|
1631
|
+
connection.close()
|
|
1632
|
+
requests = [
|
|
1633
|
+
receipt
|
|
1634
|
+
for receipt in receipts
|
|
1635
|
+
if isinstance(receipt, ApprovalRequestedReceipt)
|
|
1636
|
+
and receipt.request_kind == "final_acceptance"
|
|
1637
|
+
and receipt.policy_decision == "allow"
|
|
1638
|
+
and receipt.execution_status == "succeeded"
|
|
1639
|
+
]
|
|
1640
|
+
if not requests:
|
|
1641
|
+
raise AcceptanceTransactionError(
|
|
1642
|
+
"acceptance requires a current final-acceptance request"
|
|
1643
|
+
)
|
|
1644
|
+
return requests[-1]
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def prepare_acceptance(
|
|
1648
|
+
ledger_path: Path,
|
|
1649
|
+
*,
|
|
1650
|
+
evidence_root: Path,
|
|
1651
|
+
context: AuthorizationContext,
|
|
1652
|
+
clock: Callable[[], datetime],
|
|
1653
|
+
) -> AcceptanceSigningDraft:
|
|
1654
|
+
"""Return the exact externally signable AcceptanceReceipt draft."""
|
|
1655
|
+
path = Path(ledger_path)
|
|
1656
|
+
if not path.is_file():
|
|
1657
|
+
raise AcceptanceTransactionError("acceptance ledger is unavailable")
|
|
1658
|
+
lock_descriptor: int | None = None
|
|
1659
|
+
owns_lock = False
|
|
1660
|
+
try:
|
|
1661
|
+
lock_descriptor, owns_lock = evidence._borrow_or_acquire_target_lock(
|
|
1662
|
+
path, None
|
|
1663
|
+
)
|
|
1664
|
+
now = _freeze_second(clock())
|
|
1665
|
+
if context.transaction_time != now:
|
|
1666
|
+
raise AcceptanceTransactionError(
|
|
1667
|
+
"authorization context time is stale"
|
|
1668
|
+
)
|
|
1669
|
+
require_current_context(
|
|
1670
|
+
path,
|
|
1671
|
+
evidence_root,
|
|
1672
|
+
context,
|
|
1673
|
+
now,
|
|
1674
|
+
lock_descriptor,
|
|
1675
|
+
)
|
|
1676
|
+
if context.current_state != "awaiting_human":
|
|
1677
|
+
raise AcceptanceTransactionError(
|
|
1678
|
+
"acceptance preparation requires awaiting_human"
|
|
1679
|
+
)
|
|
1680
|
+
work_order, receipts, _, _ = evidence._replay_receipt_publication_ledger(
|
|
1681
|
+
evidence.connect_ledger(path)
|
|
1682
|
+
)
|
|
1683
|
+
report = _current_report(path, work_order)
|
|
1684
|
+
request = _current_acceptance_request(path, work_order)
|
|
1685
|
+
if request.expires_at < now:
|
|
1686
|
+
raise AcceptanceTransactionError(
|
|
1687
|
+
"acceptance request has expired"
|
|
1688
|
+
)
|
|
1689
|
+
if context.ledger_prefix.receipts[-1].digest != request.digest:
|
|
1690
|
+
raise AcceptanceTransactionError(
|
|
1691
|
+
"acceptance request is not the current ledger tip"
|
|
1692
|
+
)
|
|
1693
|
+
prefix = context.ledger_prefix.receipts
|
|
1694
|
+
refs = _sorted_unique_evidence_refs(prefix)
|
|
1695
|
+
snapshot = evidence_snapshot_digest(refs)
|
|
1696
|
+
acceptance_id_value = acceptance_id(
|
|
1697
|
+
work_order_digest=work_order.digest,
|
|
1698
|
+
request_receipt_id=request.receipt_id,
|
|
1699
|
+
request_receipt_digest=request.digest,
|
|
1700
|
+
report_digest=composition_report_digest(report),
|
|
1701
|
+
evidence_snapshot=snapshot,
|
|
1702
|
+
)
|
|
1703
|
+
payload = {
|
|
1704
|
+
"protocol_version": "0.1",
|
|
1705
|
+
"acceptance_id": acceptance_id_value,
|
|
1706
|
+
"work_order_digest": work_order.digest,
|
|
1707
|
+
"acceptance_request_receipt_id": request.receipt_id,
|
|
1708
|
+
"acceptance_request_receipt_digest": request.digest,
|
|
1709
|
+
"composition_report_digest": composition_report_digest(report),
|
|
1710
|
+
"final_artifact": report.final_artifact.model_dump(mode="json"),
|
|
1711
|
+
"artifact_digests": [
|
|
1712
|
+
item.model_dump(mode="json") for item in report.artifact_digests
|
|
1713
|
+
],
|
|
1714
|
+
"evidence_snapshot_digest": snapshot,
|
|
1715
|
+
"receipt_digests": [item.digest for item in prefix],
|
|
1716
|
+
"causal_graph_root": report.causal_graph_root,
|
|
1717
|
+
"causal_complete": True,
|
|
1718
|
+
"evidence_coverage": dict(report.evidence_coverage),
|
|
1719
|
+
"independence_assessment": (
|
|
1720
|
+
report.independence_assessment.model_dump(mode="json")
|
|
1721
|
+
),
|
|
1722
|
+
"test_evidence_refs": [
|
|
1723
|
+
item.model_dump(mode="json") for item in report.test_evidence_refs
|
|
1724
|
+
],
|
|
1725
|
+
"decision": "accepted",
|
|
1726
|
+
"unresolved_failures": [],
|
|
1727
|
+
"warnings": [item.model_dump(mode="json") for item in report.warnings],
|
|
1728
|
+
"global_postconditions": [
|
|
1729
|
+
item.model_dump(mode="json")
|
|
1730
|
+
for item in report.global_postconditions
|
|
1731
|
+
],
|
|
1732
|
+
"global_postconditions_satisfied": True,
|
|
1733
|
+
"verifier_conclusion": "proof_ready",
|
|
1734
|
+
"accepted_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
1735
|
+
}
|
|
1736
|
+
canonical = rfc8785.dumps(payload)
|
|
1737
|
+
return AcceptanceSigningDraft(
|
|
1738
|
+
signing_domain="acceptance-receipt",
|
|
1739
|
+
acceptance_id=acceptance_id_value,
|
|
1740
|
+
payload=FrozenDict(payload),
|
|
1741
|
+
canonical_payload=canonical,
|
|
1742
|
+
)
|
|
1743
|
+
except (RuntimeContextError, AcceptanceTransactionError):
|
|
1744
|
+
raise
|
|
1745
|
+
finally:
|
|
1746
|
+
if owns_lock:
|
|
1747
|
+
_, release_errors = evidence._release_target_lock(lock_descriptor)
|
|
1748
|
+
if release_errors:
|
|
1749
|
+
raise AcceptanceCommitIndeterminateError(
|
|
1750
|
+
"acceptance preparation target lock release failed"
|
|
1751
|
+
) from release_errors[0]
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
def commit_acceptance(
|
|
1755
|
+
ledger_path: Path,
|
|
1756
|
+
*,
|
|
1757
|
+
evidence_root: Path,
|
|
1758
|
+
context: AuthorizationContext,
|
|
1759
|
+
acceptance: AcceptanceReceipt,
|
|
1760
|
+
public_keys,
|
|
1761
|
+
clock: Callable[[], datetime],
|
|
1762
|
+
) -> AcceptanceReceipt:
|
|
1763
|
+
"""Atomically commit the Acceptor-signed AcceptanceReceipt."""
|
|
1764
|
+
from openworkproof.signing import decode_and_verify_key_binding, verify_payload # noqa: PLC0415
|
|
1765
|
+
|
|
1766
|
+
path = Path(ledger_path)
|
|
1767
|
+
if not path.is_file():
|
|
1768
|
+
raise AcceptanceTransactionError("acceptance ledger is unavailable")
|
|
1769
|
+
lock_descriptor: int | None = None
|
|
1770
|
+
owns_lock = False
|
|
1771
|
+
connection: sqlite3.Connection | None = None
|
|
1772
|
+
committed_result: AcceptanceReceipt | None = None
|
|
1773
|
+
try:
|
|
1774
|
+
lock_descriptor, owns_lock = evidence._borrow_or_acquire_target_lock(
|
|
1775
|
+
path, None
|
|
1776
|
+
)
|
|
1777
|
+
now = _freeze_second(clock())
|
|
1778
|
+
if context.transaction_time != now:
|
|
1779
|
+
raise AcceptanceTransactionError(
|
|
1780
|
+
"authorization context time is stale"
|
|
1781
|
+
)
|
|
1782
|
+
try:
|
|
1783
|
+
require_current_context(
|
|
1784
|
+
path,
|
|
1785
|
+
evidence_root,
|
|
1786
|
+
context,
|
|
1787
|
+
now,
|
|
1788
|
+
lock_descriptor,
|
|
1789
|
+
)
|
|
1790
|
+
except RuntimeContextError as error:
|
|
1791
|
+
if _readback_acceptance_committed(
|
|
1792
|
+
path,
|
|
1793
|
+
work_order=context.work_order,
|
|
1794
|
+
acceptance=acceptance,
|
|
1795
|
+
):
|
|
1796
|
+
committed_result = acceptance
|
|
1797
|
+
raise AcceptanceCommittedError(
|
|
1798
|
+
"the exact acceptance is already committed",
|
|
1799
|
+
acceptance,
|
|
1800
|
+
) from error
|
|
1801
|
+
raise
|
|
1802
|
+
if context.current_state != "awaiting_human":
|
|
1803
|
+
raise AcceptanceTransactionError(
|
|
1804
|
+
"acceptance requires awaiting_human"
|
|
1805
|
+
)
|
|
1806
|
+
work_order, receipts, _, _ = evidence._replay_receipt_publication_ledger(
|
|
1807
|
+
evidence.connect_ledger(path)
|
|
1808
|
+
)
|
|
1809
|
+
report = _current_report(path, work_order)
|
|
1810
|
+
request = _current_acceptance_request(path, work_order)
|
|
1811
|
+
if request.expires_at < now:
|
|
1812
|
+
raise AcceptanceTransactionError(
|
|
1813
|
+
"acceptance request has expired"
|
|
1814
|
+
)
|
|
1815
|
+
if context.ledger_prefix.receipts[-1].digest != request.digest:
|
|
1816
|
+
raise AcceptanceTransactionError(
|
|
1817
|
+
"acceptance request is not the current ledger tip"
|
|
1818
|
+
)
|
|
1819
|
+
acceptor = work_order.key_bindings[5]
|
|
1820
|
+
if acceptance.signer_key_id != acceptor.key_id:
|
|
1821
|
+
raise AcceptanceTransactionError(
|
|
1822
|
+
"acceptance must be signed by the bound Acceptor"
|
|
1823
|
+
)
|
|
1824
|
+
parsed = AcceptanceReceipt.model_validate(
|
|
1825
|
+
acceptance.model_dump(mode="json")
|
|
1826
|
+
)
|
|
1827
|
+
parsed.validate_against_work_order(work_order)
|
|
1828
|
+
accepted_at = parsed.accepted_at
|
|
1829
|
+
if accepted_at < request.occurred_at:
|
|
1830
|
+
raise AcceptanceTransactionError(
|
|
1831
|
+
"accepted_at precedes the acceptance request"
|
|
1832
|
+
)
|
|
1833
|
+
if accepted_at > now:
|
|
1834
|
+
raise AcceptanceTransactionError(
|
|
1835
|
+
"accepted_at is in the future"
|
|
1836
|
+
)
|
|
1837
|
+
if (now - accepted_at).total_seconds() > 300:
|
|
1838
|
+
raise AcceptanceTransactionError(
|
|
1839
|
+
"acceptance signature is stale"
|
|
1840
|
+
)
|
|
1841
|
+
if accepted_at > request.expires_at:
|
|
1842
|
+
raise AcceptanceTransactionError(
|
|
1843
|
+
"acceptance signature exceeds request expiry"
|
|
1844
|
+
)
|
|
1845
|
+
if accepted_at > work_order.deadline:
|
|
1846
|
+
raise AcceptanceTransactionError(
|
|
1847
|
+
"acceptance signature exceeds the WorkOrder deadline"
|
|
1848
|
+
)
|
|
1849
|
+
prefix = context.ledger_prefix.receipts
|
|
1850
|
+
refs = _sorted_unique_evidence_refs(prefix)
|
|
1851
|
+
snapshot = evidence_snapshot_digest(refs)
|
|
1852
|
+
expected_id = acceptance_id(
|
|
1853
|
+
work_order_digest=work_order.digest,
|
|
1854
|
+
request_receipt_id=request.receipt_id,
|
|
1855
|
+
request_receipt_digest=request.digest,
|
|
1856
|
+
report_digest=composition_report_digest(report),
|
|
1857
|
+
evidence_snapshot=snapshot,
|
|
1858
|
+
)
|
|
1859
|
+
if parsed.acceptance_id != expected_id:
|
|
1860
|
+
raise AcceptanceTransactionError(
|
|
1861
|
+
"acceptance ID does not match the authoritative snapshot"
|
|
1862
|
+
)
|
|
1863
|
+
expected_payload = _expected_acceptance_payload(
|
|
1864
|
+
work_order,
|
|
1865
|
+
report,
|
|
1866
|
+
request,
|
|
1867
|
+
snapshot,
|
|
1868
|
+
expected_id,
|
|
1869
|
+
accepted_at,
|
|
1870
|
+
prefix,
|
|
1871
|
+
)
|
|
1872
|
+
actual_payload = {
|
|
1873
|
+
key: value
|
|
1874
|
+
for key, value in parsed.model_dump(mode="json").items()
|
|
1875
|
+
if key not in {"digest", "signature_alg", "signer_key_id", "signature"}
|
|
1876
|
+
}
|
|
1877
|
+
if actual_payload != expected_payload:
|
|
1878
|
+
raise AcceptanceTransactionError(
|
|
1879
|
+
"acceptance payload does not match the authoritative snapshot"
|
|
1880
|
+
)
|
|
1881
|
+
acceptor_key = decode_and_verify_key_binding(acceptor)
|
|
1882
|
+
if not verify_payload(
|
|
1883
|
+
"acceptance-receipt",
|
|
1884
|
+
parsed.model_dump(mode="json"),
|
|
1885
|
+
acceptor_key,
|
|
1886
|
+
):
|
|
1887
|
+
raise AcceptanceTransactionError(
|
|
1888
|
+
"acceptance signature verification failed"
|
|
1889
|
+
)
|
|
1890
|
+
connection = evidence.connect_ledger(path)
|
|
1891
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
1892
|
+
existing = connection.execute(
|
|
1893
|
+
"SELECT COUNT(*) FROM acceptance_receipts"
|
|
1894
|
+
).fetchone()
|
|
1895
|
+
if existing is not None and existing[0] != 0:
|
|
1896
|
+
raise AcceptanceTransactionError(
|
|
1897
|
+
"WorkOrder already has an acceptance"
|
|
1898
|
+
)
|
|
1899
|
+
state_row = connection.execute(
|
|
1900
|
+
"SELECT current_state, version FROM work_order_state WHERE singleton = 1"
|
|
1901
|
+
).fetchone()
|
|
1902
|
+
if state_row != ("awaiting_human", _derive_version(connection, work_order)):
|
|
1903
|
+
raise AcceptanceTransactionError(
|
|
1904
|
+
"acceptance ledger changed under the target lock"
|
|
1905
|
+
)
|
|
1906
|
+
_insert_acceptance_rows(
|
|
1907
|
+
connection,
|
|
1908
|
+
work_order=work_order,
|
|
1909
|
+
acceptance=parsed,
|
|
1910
|
+
current_state=state_row[0],
|
|
1911
|
+
current_version=state_row[1],
|
|
1912
|
+
)
|
|
1913
|
+
try:
|
|
1914
|
+
connection.execute("COMMIT")
|
|
1915
|
+
except Exception as error:
|
|
1916
|
+
if _readback_acceptance_committed(
|
|
1917
|
+
path,
|
|
1918
|
+
work_order=work_order,
|
|
1919
|
+
acceptance=parsed,
|
|
1920
|
+
):
|
|
1921
|
+
committed_result = parsed
|
|
1922
|
+
raise AcceptanceCommittedError(
|
|
1923
|
+
"acceptance committed but its acknowledgement was lost",
|
|
1924
|
+
parsed,
|
|
1925
|
+
) from error
|
|
1926
|
+
raise AcceptanceCommitIndeterminateError(
|
|
1927
|
+
"acceptance commit outcome is indeterminate"
|
|
1928
|
+
) from error
|
|
1929
|
+
if not _readback_acceptance_committed(
|
|
1930
|
+
path,
|
|
1931
|
+
work_order=work_order,
|
|
1932
|
+
acceptance=parsed,
|
|
1933
|
+
):
|
|
1934
|
+
raise AcceptanceCommitIndeterminateError(
|
|
1935
|
+
"acceptance readback could not confirm the exact commit"
|
|
1936
|
+
)
|
|
1937
|
+
committed_result = parsed
|
|
1938
|
+
return parsed
|
|
1939
|
+
except (RuntimeContextError, AcceptanceTransactionError):
|
|
1940
|
+
rollback_error = evidence._best_effort_rollback(connection)
|
|
1941
|
+
if rollback_error is not None:
|
|
1942
|
+
raise AcceptanceTransactionError(
|
|
1943
|
+
"acceptance rollback failed"
|
|
1944
|
+
) from rollback_error
|
|
1945
|
+
raise
|
|
1946
|
+
finally:
|
|
1947
|
+
cleanup_errors: list[Exception] = []
|
|
1948
|
+
close_error = evidence._best_effort_close(connection)
|
|
1949
|
+
if close_error is not None:
|
|
1950
|
+
cleanup_errors.append(close_error)
|
|
1951
|
+
if owns_lock:
|
|
1952
|
+
_, release_errors = evidence._release_target_lock(lock_descriptor)
|
|
1953
|
+
cleanup_errors.extend(release_errors)
|
|
1954
|
+
if cleanup_errors:
|
|
1955
|
+
if committed_result is not None:
|
|
1956
|
+
raise AcceptanceCommittedError(
|
|
1957
|
+
"acceptance committed but cleanup failed",
|
|
1958
|
+
committed_result,
|
|
1959
|
+
) from cleanup_errors[0]
|
|
1960
|
+
else:
|
|
1961
|
+
raise AcceptanceCommitIndeterminateError(
|
|
1962
|
+
"acceptance cleanup failed"
|
|
1963
|
+
) from cleanup_errors[0]
|
|
1964
|
+
|
|
1965
|
+
|
|
1966
|
+
def _derive_version(connection, work_order) -> int:
|
|
1967
|
+
_, receipts, _, _ = evidence._replay_receipt_publication_ledger(connection)
|
|
1968
|
+
return evidence._derive_protocol_transaction_version(
|
|
1969
|
+
action_receipts=receipts,
|
|
1970
|
+
acceptance_receipts=(),
|
|
1971
|
+
)
|
|
1972
|
+
|
|
1973
|
+
|
|
1974
|
+
def _expected_acceptance_payload(
|
|
1975
|
+
work_order,
|
|
1976
|
+
report: CompositionReport,
|
|
1977
|
+
request: ApprovalRequestedReceipt,
|
|
1978
|
+
snapshot: str,
|
|
1979
|
+
expected_id: str,
|
|
1980
|
+
accepted_at: datetime,
|
|
1981
|
+
prefix: tuple[ActionReceiptEnvelope, ...],
|
|
1982
|
+
) -> dict:
|
|
1983
|
+
return {
|
|
1984
|
+
"protocol_version": "0.1",
|
|
1985
|
+
"acceptance_id": expected_id,
|
|
1986
|
+
"work_order_digest": work_order.digest,
|
|
1987
|
+
"acceptance_request_receipt_id": request.receipt_id,
|
|
1988
|
+
"acceptance_request_receipt_digest": request.digest,
|
|
1989
|
+
"composition_report_digest": composition_report_digest(report),
|
|
1990
|
+
"final_artifact": report.final_artifact.model_dump(mode="json"),
|
|
1991
|
+
"artifact_digests": [
|
|
1992
|
+
item.model_dump(mode="json") for item in report.artifact_digests
|
|
1993
|
+
],
|
|
1994
|
+
"evidence_snapshot_digest": snapshot,
|
|
1995
|
+
"receipt_digests": [item.digest for item in prefix],
|
|
1996
|
+
"causal_graph_root": report.causal_graph_root,
|
|
1997
|
+
"causal_complete": True,
|
|
1998
|
+
"evidence_coverage": dict(report.evidence_coverage),
|
|
1999
|
+
"independence_assessment": (
|
|
2000
|
+
report.independence_assessment.model_dump(mode="json")
|
|
2001
|
+
),
|
|
2002
|
+
"test_evidence_refs": [
|
|
2003
|
+
item.model_dump(mode="json") for item in report.test_evidence_refs
|
|
2004
|
+
],
|
|
2005
|
+
"decision": "accepted",
|
|
2006
|
+
"unresolved_failures": [],
|
|
2007
|
+
"warnings": [item.model_dump(mode="json") for item in report.warnings],
|
|
2008
|
+
"global_postconditions": [
|
|
2009
|
+
item.model_dump(mode="json")
|
|
2010
|
+
for item in report.global_postconditions
|
|
2011
|
+
],
|
|
2012
|
+
"global_postconditions_satisfied": True,
|
|
2013
|
+
"verifier_conclusion": "proof_ready",
|
|
2014
|
+
"accepted_at": accepted_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
|
|
2018
|
+
def validate_acceptance_bindings(
|
|
2019
|
+
*,
|
|
2020
|
+
work_order,
|
|
2021
|
+
report: CompositionReport,
|
|
2022
|
+
receipts: tuple[ActionReceiptEnvelope, ...],
|
|
2023
|
+
acceptance_receipt: AcceptanceReceipt,
|
|
2024
|
+
) -> AcceptanceReceipt:
|
|
2025
|
+
"""Verify one AcceptanceReceipt against its exact signed history."""
|
|
2026
|
+
if (
|
|
2027
|
+
not isinstance(report, CompositionReport)
|
|
2028
|
+
or type(receipts) is not tuple
|
|
2029
|
+
or not receipts
|
|
2030
|
+
or any(
|
|
2031
|
+
not isinstance(receipt, ActionReceiptEnvelope)
|
|
2032
|
+
for receipt in receipts
|
|
2033
|
+
)
|
|
2034
|
+
or not isinstance(acceptance_receipt, AcceptanceReceipt)
|
|
2035
|
+
):
|
|
2036
|
+
raise AcceptanceTransactionError(
|
|
2037
|
+
"acceptance binding inputs are unavailable"
|
|
2038
|
+
)
|
|
2039
|
+
request = receipts[-1]
|
|
2040
|
+
if (
|
|
2041
|
+
not isinstance(request, ApprovalRequestedReceipt)
|
|
2042
|
+
or request.request_kind != "final_acceptance"
|
|
2043
|
+
or request.required_role != "Acceptor"
|
|
2044
|
+
or request.policy_decision != "allow"
|
|
2045
|
+
or request.execution_status != "succeeded"
|
|
2046
|
+
or request.state_after != "awaiting_human"
|
|
2047
|
+
):
|
|
2048
|
+
raise AcceptanceTransactionError(
|
|
2049
|
+
"acceptance has no current final-acceptance request"
|
|
2050
|
+
)
|
|
2051
|
+
report_digest = composition_report_digest(report)
|
|
2052
|
+
expected_scope = {
|
|
2053
|
+
"work_order_digest": work_order.digest,
|
|
2054
|
+
"operation": "submit_final_acceptance",
|
|
2055
|
+
"composition_report_digest": report_digest,
|
|
2056
|
+
}
|
|
2057
|
+
trigger = receipts[-2] if len(receipts) >= 2 else None
|
|
2058
|
+
if (
|
|
2059
|
+
report.work_order_digest != work_order.digest
|
|
2060
|
+
or report.verifier_conclusion != "proof_ready"
|
|
2061
|
+
or request.requested_scope != expected_scope
|
|
2062
|
+
or request.target_action_digest
|
|
2063
|
+
!= hashlib.sha256(
|
|
2064
|
+
rfc8785.dumps(
|
|
2065
|
+
{
|
|
2066
|
+
"domain": "openworkproof/final-acceptance-action/v0.1",
|
|
2067
|
+
"requested_scope": expected_scope,
|
|
2068
|
+
}
|
|
2069
|
+
)
|
|
2070
|
+
).hexdigest()
|
|
2071
|
+
or not isinstance(trigger, SystemEventReceipt)
|
|
2072
|
+
or trigger.system_event_name != "proof_composed"
|
|
2073
|
+
or getattr(trigger.cause, "composition_report_digest", None)
|
|
2074
|
+
!= report_digest
|
|
2075
|
+
):
|
|
2076
|
+
raise AcceptanceTransactionError(
|
|
2077
|
+
"acceptance report or request binding is invalid"
|
|
2078
|
+
)
|
|
2079
|
+
initiator_indexes = tuple(
|
|
2080
|
+
index
|
|
2081
|
+
for index, receipt in enumerate(receipts)
|
|
2082
|
+
if receipt.receipt_id == report.initiator_receipt_id
|
|
2083
|
+
and receipt.digest == report.initiator_receipt_digest
|
|
2084
|
+
)
|
|
2085
|
+
if len(initiator_indexes) != 1:
|
|
2086
|
+
raise AcceptanceTransactionError(
|
|
2087
|
+
"acceptance report initiator is not unique"
|
|
2088
|
+
)
|
|
2089
|
+
report_prefix = receipts[: initiator_indexes[0] + 1]
|
|
2090
|
+
report_refs = _sorted_unique_evidence_refs(report_prefix)
|
|
2091
|
+
if (
|
|
2092
|
+
report.receipt_digests
|
|
2093
|
+
!= tuple(receipt.digest for receipt in report_prefix)
|
|
2094
|
+
or report.causal_graph_root != causal_graph_root(report_prefix)
|
|
2095
|
+
or report.evidence_snapshot_digest
|
|
2096
|
+
!= evidence_snapshot_digest(report_refs)
|
|
2097
|
+
):
|
|
2098
|
+
raise AcceptanceTransactionError(
|
|
2099
|
+
"composition report does not match its authoritative prefix"
|
|
2100
|
+
)
|
|
2101
|
+
full_refs = _sorted_unique_evidence_refs(receipts)
|
|
2102
|
+
snapshot = evidence_snapshot_digest(full_refs)
|
|
2103
|
+
expected_id = acceptance_id(
|
|
2104
|
+
work_order_digest=work_order.digest,
|
|
2105
|
+
request_receipt_id=request.receipt_id,
|
|
2106
|
+
request_receipt_digest=request.digest,
|
|
2107
|
+
report_digest=report_digest,
|
|
2108
|
+
evidence_snapshot=snapshot,
|
|
2109
|
+
)
|
|
2110
|
+
accepted_at = acceptance_receipt.accepted_at
|
|
2111
|
+
if (
|
|
2112
|
+
accepted_at < request.occurred_at
|
|
2113
|
+
or accepted_at > request.expires_at
|
|
2114
|
+
or accepted_at > work_order.deadline
|
|
2115
|
+
or acceptance_receipt.acceptance_id != expected_id
|
|
2116
|
+
):
|
|
2117
|
+
raise AcceptanceTransactionError(
|
|
2118
|
+
"acceptance time or identifier binding is invalid"
|
|
2119
|
+
)
|
|
2120
|
+
expected_payload = _expected_acceptance_payload(
|
|
2121
|
+
work_order,
|
|
2122
|
+
report,
|
|
2123
|
+
request,
|
|
2124
|
+
snapshot,
|
|
2125
|
+
expected_id,
|
|
2126
|
+
accepted_at,
|
|
2127
|
+
receipts,
|
|
2128
|
+
)
|
|
2129
|
+
actual_payload = {
|
|
2130
|
+
key: value
|
|
2131
|
+
for key, value in acceptance_receipt.model_dump(mode="json").items()
|
|
2132
|
+
if key not in {"digest", "signature_alg", "signer_key_id", "signature"}
|
|
2133
|
+
}
|
|
2134
|
+
if actual_payload != expected_payload:
|
|
2135
|
+
raise AcceptanceTransactionError(
|
|
2136
|
+
"acceptance payload does not match its authoritative prefix"
|
|
2137
|
+
)
|
|
2138
|
+
from openworkproof.signing import ( # noqa: PLC0415
|
|
2139
|
+
decode_and_verify_key_binding,
|
|
2140
|
+
verify_payload,
|
|
2141
|
+
)
|
|
2142
|
+
|
|
2143
|
+
acceptance_receipt.validate_against_work_order(work_order)
|
|
2144
|
+
acceptor = work_order.key_bindings[5]
|
|
2145
|
+
if (
|
|
2146
|
+
acceptance_receipt.signer_key_id != acceptor.key_id
|
|
2147
|
+
or not verify_payload(
|
|
2148
|
+
"acceptance-receipt",
|
|
2149
|
+
acceptance_receipt.model_dump(mode="json"),
|
|
2150
|
+
decode_and_verify_key_binding(acceptor),
|
|
2151
|
+
)
|
|
2152
|
+
):
|
|
2153
|
+
raise AcceptanceTransactionError(
|
|
2154
|
+
"acceptance signature does not match the bound Acceptor"
|
|
2155
|
+
)
|
|
2156
|
+
return acceptance_receipt
|
|
2157
|
+
|
|
2158
|
+
|
|
2159
|
+
def rejection_id(
|
|
2160
|
+
*,
|
|
2161
|
+
work_order_digest: str,
|
|
2162
|
+
request_receipt_id: str,
|
|
2163
|
+
request_receipt_digest: str,
|
|
2164
|
+
report_digest: str,
|
|
2165
|
+
evidence_snapshot: str,
|
|
2166
|
+
) -> str:
|
|
2167
|
+
return hashlib.sha256(
|
|
2168
|
+
rfc8785.dumps(
|
|
2169
|
+
{
|
|
2170
|
+
"domain": "openworkproof/rejection-id/0.1",
|
|
2171
|
+
"work_order_digest": work_order_digest,
|
|
2172
|
+
"request_receipt_id": request_receipt_id,
|
|
2173
|
+
"request_receipt_digest": request_receipt_digest,
|
|
2174
|
+
"composition_report_digest": report_digest,
|
|
2175
|
+
"evidence_snapshot_digest": evidence_snapshot,
|
|
2176
|
+
}
|
|
2177
|
+
)
|
|
2178
|
+
).hexdigest()
|
|
2179
|
+
|
|
2180
|
+
|
|
2181
|
+
def _expected_rejection_payload(
|
|
2182
|
+
work_order,
|
|
2183
|
+
report: CompositionReport,
|
|
2184
|
+
request: ApprovalRequestedReceipt,
|
|
2185
|
+
snapshot: str,
|
|
2186
|
+
expected_id: str,
|
|
2187
|
+
rejected_at: datetime,
|
|
2188
|
+
prefix: tuple[ActionReceiptEnvelope, ...],
|
|
2189
|
+
reason_code: str,
|
|
2190
|
+
reason_detail: str,
|
|
2191
|
+
) -> dict:
|
|
2192
|
+
return {
|
|
2193
|
+
"protocol_version": "0.1",
|
|
2194
|
+
"rejection_id": expected_id,
|
|
2195
|
+
"work_order_digest": work_order.digest,
|
|
2196
|
+
"acceptance_request_receipt_id": request.receipt_id,
|
|
2197
|
+
"acceptance_request_receipt_digest": request.digest,
|
|
2198
|
+
"composition_report_digest": composition_report_digest(report),
|
|
2199
|
+
"evidence_snapshot_digest": snapshot,
|
|
2200
|
+
"receipt_digests": [item.digest for item in prefix],
|
|
2201
|
+
"causal_graph_root": report.causal_graph_root,
|
|
2202
|
+
"reason_code": reason_code,
|
|
2203
|
+
"reason_detail": reason_detail,
|
|
2204
|
+
"decision": "rejected",
|
|
2205
|
+
"rejected_at": rejected_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
|
|
2209
|
+
def validate_rejection_bindings(
|
|
2210
|
+
*,
|
|
2211
|
+
work_order,
|
|
2212
|
+
report: CompositionReport,
|
|
2213
|
+
receipts: tuple[ActionReceiptEnvelope, ...],
|
|
2214
|
+
rejection: AcceptanceRejectionReceipt,
|
|
2215
|
+
) -> AcceptanceRejectionReceipt:
|
|
2216
|
+
"""Verify one AcceptanceRejectionReceipt against its exact history."""
|
|
2217
|
+
if (
|
|
2218
|
+
not isinstance(report, CompositionReport)
|
|
2219
|
+
or type(receipts) is not tuple
|
|
2220
|
+
or not receipts
|
|
2221
|
+
or any(
|
|
2222
|
+
not isinstance(receipt, ActionReceiptEnvelope)
|
|
2223
|
+
for receipt in receipts
|
|
2224
|
+
)
|
|
2225
|
+
or not isinstance(rejection, AcceptanceRejectionReceipt)
|
|
2226
|
+
):
|
|
2227
|
+
raise AcceptanceTransactionError(
|
|
2228
|
+
"rejection binding inputs are unavailable"
|
|
2229
|
+
)
|
|
2230
|
+
request = receipts[-1]
|
|
2231
|
+
if (
|
|
2232
|
+
not isinstance(request, ApprovalRequestedReceipt)
|
|
2233
|
+
or request.request_kind != "final_acceptance"
|
|
2234
|
+
or request.required_role != "Acceptor"
|
|
2235
|
+
or request.policy_decision != "allow"
|
|
2236
|
+
or request.execution_status != "succeeded"
|
|
2237
|
+
or request.state_after != "awaiting_human"
|
|
2238
|
+
):
|
|
2239
|
+
raise AcceptanceTransactionError(
|
|
2240
|
+
"rejection has no current final-acceptance request"
|
|
2241
|
+
)
|
|
2242
|
+
report_digest = composition_report_digest(report)
|
|
2243
|
+
expected_scope = {
|
|
2244
|
+
"work_order_digest": work_order.digest,
|
|
2245
|
+
"operation": "submit_final_acceptance",
|
|
2246
|
+
"composition_report_digest": report_digest,
|
|
2247
|
+
}
|
|
2248
|
+
trigger = receipts[-2] if len(receipts) >= 2 else None
|
|
2249
|
+
if (
|
|
2250
|
+
report.work_order_digest != work_order.digest
|
|
2251
|
+
or report.verifier_conclusion != "proof_ready"
|
|
2252
|
+
or request.requested_scope != expected_scope
|
|
2253
|
+
or request.target_action_digest
|
|
2254
|
+
!= hashlib.sha256(
|
|
2255
|
+
rfc8785.dumps(
|
|
2256
|
+
{
|
|
2257
|
+
"domain": "openworkproof/final-acceptance-action/v0.1",
|
|
2258
|
+
"requested_scope": expected_scope,
|
|
2259
|
+
}
|
|
2260
|
+
)
|
|
2261
|
+
).hexdigest()
|
|
2262
|
+
or not isinstance(trigger, SystemEventReceipt)
|
|
2263
|
+
or trigger.system_event_name != "proof_composed"
|
|
2264
|
+
or getattr(trigger.cause, "composition_report_digest", None)
|
|
2265
|
+
!= report_digest
|
|
2266
|
+
):
|
|
2267
|
+
raise AcceptanceTransactionError(
|
|
2268
|
+
"rejection report or request binding is invalid"
|
|
2269
|
+
)
|
|
2270
|
+
initiator_indexes = tuple(
|
|
2271
|
+
index
|
|
2272
|
+
for index, receipt in enumerate(receipts)
|
|
2273
|
+
if receipt.receipt_id == report.initiator_receipt_id
|
|
2274
|
+
and receipt.digest == report.initiator_receipt_digest
|
|
2275
|
+
)
|
|
2276
|
+
if len(initiator_indexes) != 1:
|
|
2277
|
+
raise AcceptanceTransactionError(
|
|
2278
|
+
"rejection report initiator is not unique"
|
|
2279
|
+
)
|
|
2280
|
+
report_prefix = receipts[: initiator_indexes[0] + 1]
|
|
2281
|
+
report_refs = _sorted_unique_evidence_refs(report_prefix)
|
|
2282
|
+
if (
|
|
2283
|
+
report.receipt_digests
|
|
2284
|
+
!= tuple(receipt.digest for receipt in report_prefix)
|
|
2285
|
+
or report.causal_graph_root != causal_graph_root(report_prefix)
|
|
2286
|
+
or report.evidence_snapshot_digest
|
|
2287
|
+
!= evidence_snapshot_digest(report_refs)
|
|
2288
|
+
):
|
|
2289
|
+
raise AcceptanceTransactionError(
|
|
2290
|
+
"composition report does not match its authoritative prefix"
|
|
2291
|
+
)
|
|
2292
|
+
full_refs = _sorted_unique_evidence_refs(receipts)
|
|
2293
|
+
snapshot = evidence_snapshot_digest(full_refs)
|
|
2294
|
+
expected_id = rejection_id(
|
|
2295
|
+
work_order_digest=work_order.digest,
|
|
2296
|
+
request_receipt_id=request.receipt_id,
|
|
2297
|
+
request_receipt_digest=request.digest,
|
|
2298
|
+
report_digest=report_digest,
|
|
2299
|
+
evidence_snapshot=snapshot,
|
|
2300
|
+
)
|
|
2301
|
+
rejected_at = rejection.rejected_at
|
|
2302
|
+
if (
|
|
2303
|
+
rejected_at < request.occurred_at
|
|
2304
|
+
or rejected_at > request.expires_at
|
|
2305
|
+
or rejected_at > work_order.deadline
|
|
2306
|
+
or rejection.rejection_id != expected_id
|
|
2307
|
+
):
|
|
2308
|
+
raise AcceptanceTransactionError(
|
|
2309
|
+
"rejection time or identifier binding is invalid"
|
|
2310
|
+
)
|
|
2311
|
+
expected_payload = _expected_rejection_payload(
|
|
2312
|
+
work_order,
|
|
2313
|
+
report,
|
|
2314
|
+
request,
|
|
2315
|
+
snapshot,
|
|
2316
|
+
expected_id,
|
|
2317
|
+
rejected_at,
|
|
2318
|
+
receipts,
|
|
2319
|
+
rejection.reason_code,
|
|
2320
|
+
rejection.reason_detail,
|
|
2321
|
+
)
|
|
2322
|
+
actual_payload = {
|
|
2323
|
+
key: value
|
|
2324
|
+
for key, value in rejection.model_dump(mode="json").items()
|
|
2325
|
+
if key not in {"digest", "signature_alg", "signer_key_id", "signature"}
|
|
2326
|
+
}
|
|
2327
|
+
if actual_payload != expected_payload:
|
|
2328
|
+
raise AcceptanceTransactionError(
|
|
2329
|
+
"rejection payload does not match its authoritative prefix"
|
|
2330
|
+
)
|
|
2331
|
+
from openworkproof.signing import ( # noqa: PLC0415
|
|
2332
|
+
decode_and_verify_key_binding,
|
|
2333
|
+
verify_payload,
|
|
2334
|
+
)
|
|
2335
|
+
|
|
2336
|
+
rejection.validate_against_work_order(work_order)
|
|
2337
|
+
acceptor = work_order.key_bindings[5]
|
|
2338
|
+
if (
|
|
2339
|
+
rejection.signer_key_id != acceptor.key_id
|
|
2340
|
+
or not verify_payload(
|
|
2341
|
+
"acceptance-rejection-receipt",
|
|
2342
|
+
rejection.model_dump(mode="json"),
|
|
2343
|
+
decode_and_verify_key_binding(acceptor),
|
|
2344
|
+
)
|
|
2345
|
+
):
|
|
2346
|
+
raise AcceptanceTransactionError(
|
|
2347
|
+
"rejection signature does not match the bound Acceptor"
|
|
2348
|
+
)
|
|
2349
|
+
return rejection
|
|
2350
|
+
|
|
2351
|
+
|
|
2352
|
+
def verify_acceptance_bundle(
|
|
2353
|
+
*,
|
|
2354
|
+
work_order,
|
|
2355
|
+
report: CompositionReport,
|
|
2356
|
+
effective_grants: tuple,
|
|
2357
|
+
grant_attempts: tuple,
|
|
2358
|
+
receipts: tuple[ActionReceiptEnvelope, ...],
|
|
2359
|
+
committed_evidence: tuple,
|
|
2360
|
+
acceptance_receipt: AcceptanceReceipt | None = None,
|
|
2361
|
+
public_keys: Mapping,
|
|
2362
|
+
reports: tuple[CompositionReport, ...] | None = None,
|
|
2363
|
+
rejection: AcceptanceRejectionReceipt | None = None,
|
|
2364
|
+
) -> AcceptanceReceipt | AcceptanceRejectionReceipt:
|
|
2365
|
+
"""Verify a copied acceptance bundle without a live ledger.
|
|
2366
|
+
|
|
2367
|
+
Exactly one terminal decision (acceptance or rejection) must be bound
|
|
2368
|
+
to the request tip.
|
|
2369
|
+
"""
|
|
2370
|
+
from openworkproof.policy import ( # noqa: PLC0415
|
|
2371
|
+
AuthorizationLedgerPrefix,
|
|
2372
|
+
CommittedEvidence,
|
|
2373
|
+
_validate_committed_evidence,
|
|
2374
|
+
)
|
|
2375
|
+
|
|
2376
|
+
if (
|
|
2377
|
+
type(effective_grants) is not tuple
|
|
2378
|
+
or type(grant_attempts) is not tuple
|
|
2379
|
+
or type(committed_evidence) is not tuple
|
|
2380
|
+
or any(
|
|
2381
|
+
not isinstance(item, CommittedEvidence)
|
|
2382
|
+
for item in committed_evidence
|
|
2383
|
+
)
|
|
2384
|
+
or (acceptance_receipt is None) == (rejection is None)
|
|
2385
|
+
):
|
|
2386
|
+
raise AcceptanceTransactionError(
|
|
2387
|
+
"offline acceptance bundle is malformed"
|
|
2388
|
+
)
|
|
2389
|
+
selected_reports = reports if reports is not None else (report,)
|
|
2390
|
+
try:
|
|
2391
|
+
final_report = verify_composition_bundle(
|
|
2392
|
+
work_order=work_order,
|
|
2393
|
+
effective_grants=effective_grants,
|
|
2394
|
+
grant_attempts=grant_attempts,
|
|
2395
|
+
receipts=receipts,
|
|
2396
|
+
committed_evidence=committed_evidence,
|
|
2397
|
+
reports=selected_reports,
|
|
2398
|
+
public_keys=public_keys,
|
|
2399
|
+
)
|
|
2400
|
+
if final_report != report:
|
|
2401
|
+
raise AcceptanceTransactionError(
|
|
2402
|
+
"offline bundle final report does not match the acceptance report"
|
|
2403
|
+
)
|
|
2404
|
+
if rejection is not None:
|
|
2405
|
+
return validate_rejection_bindings(
|
|
2406
|
+
work_order=work_order,
|
|
2407
|
+
report=report,
|
|
2408
|
+
receipts=receipts,
|
|
2409
|
+
rejection=rejection,
|
|
2410
|
+
)
|
|
2411
|
+
assert acceptance_receipt is not None
|
|
2412
|
+
return validate_acceptance_bindings(
|
|
2413
|
+
work_order=work_order,
|
|
2414
|
+
report=report,
|
|
2415
|
+
receipts=receipts,
|
|
2416
|
+
acceptance_receipt=acceptance_receipt,
|
|
2417
|
+
)
|
|
2418
|
+
except AcceptanceTransactionError:
|
|
2419
|
+
raise
|
|
2420
|
+
except Exception as error:
|
|
2421
|
+
raise AcceptanceTransactionError(
|
|
2422
|
+
"offline acceptance bundle failed verification"
|
|
2423
|
+
) from error
|
|
2424
|
+
|
|
2425
|
+
|
|
2426
|
+
def _insert_acceptance_rows(
|
|
2427
|
+
connection: sqlite3.Connection,
|
|
2428
|
+
*,
|
|
2429
|
+
work_order,
|
|
2430
|
+
acceptance: AcceptanceReceipt,
|
|
2431
|
+
current_state: str,
|
|
2432
|
+
current_version: int,
|
|
2433
|
+
) -> None:
|
|
2434
|
+
connection.execute(
|
|
2435
|
+
"""
|
|
2436
|
+
INSERT INTO acceptance_receipts (
|
|
2437
|
+
acceptance_id, work_order_digest, acceptance_json
|
|
2438
|
+
)
|
|
2439
|
+
VALUES (?, ?, ?)
|
|
2440
|
+
""",
|
|
2441
|
+
(
|
|
2442
|
+
acceptance.acceptance_id,
|
|
2443
|
+
work_order.digest,
|
|
2444
|
+
evidence._canonical_json(acceptance.model_dump(mode="json")),
|
|
2445
|
+
),
|
|
2446
|
+
)
|
|
2447
|
+
state_update = connection.execute(
|
|
2448
|
+
"""
|
|
2449
|
+
UPDATE work_order_state
|
|
2450
|
+
SET current_state = ?, version = version + 1
|
|
2451
|
+
WHERE singleton = 1
|
|
2452
|
+
AND work_order_digest = ?
|
|
2453
|
+
AND current_state = ?
|
|
2454
|
+
AND version = ?
|
|
2455
|
+
""",
|
|
2456
|
+
(
|
|
2457
|
+
"accepted",
|
|
2458
|
+
work_order.digest,
|
|
2459
|
+
current_state,
|
|
2460
|
+
current_version,
|
|
2461
|
+
),
|
|
2462
|
+
)
|
|
2463
|
+
if state_update.rowcount != 1:
|
|
2464
|
+
raise AcceptanceTransactionError(
|
|
2465
|
+
"acceptance state could not be advanced"
|
|
2466
|
+
)
|
|
2467
|
+
|
|
2468
|
+
|
|
2469
|
+
def _readback_compose_committed(
|
|
2470
|
+
ledger_path: Path,
|
|
2471
|
+
*,
|
|
2472
|
+
work_order,
|
|
2473
|
+
initiator: ToolCallReceipt,
|
|
2474
|
+
trigger: SystemEventReceipt,
|
|
2475
|
+
report: CompositionReport,
|
|
2476
|
+
expected_state: str,
|
|
2477
|
+
expected_version: int,
|
|
2478
|
+
) -> bool:
|
|
2479
|
+
"""Prove the exact composition committed by reopening the ledger."""
|
|
2480
|
+
try:
|
|
2481
|
+
connection = evidence.connect_ledger(ledger_path)
|
|
2482
|
+
try:
|
|
2483
|
+
current_work_order, receipts, _, _ = (
|
|
2484
|
+
evidence._replay_receipt_publication_ledger(connection)
|
|
2485
|
+
)
|
|
2486
|
+
reports = evidence._validated_composition_reports(
|
|
2487
|
+
connection,
|
|
2488
|
+
current_work_order,
|
|
2489
|
+
)
|
|
2490
|
+
state_row = connection.execute(
|
|
2491
|
+
"SELECT current_state, version FROM work_order_state "
|
|
2492
|
+
"WHERE singleton = 1"
|
|
2493
|
+
).fetchone()
|
|
2494
|
+
finally:
|
|
2495
|
+
connection.close()
|
|
2496
|
+
except Exception:
|
|
2497
|
+
return False
|
|
2498
|
+
return (
|
|
2499
|
+
current_work_order == work_order
|
|
2500
|
+
and len(receipts) >= 2
|
|
2501
|
+
and receipts[-2:] == (initiator, trigger)
|
|
2502
|
+
and any(candidate == report for candidate in reports)
|
|
2503
|
+
and state_row == (expected_state, expected_version)
|
|
2504
|
+
)
|
|
2505
|
+
|
|
2506
|
+
|
|
2507
|
+
def _readback_request_committed(
|
|
2508
|
+
ledger_path: Path,
|
|
2509
|
+
*,
|
|
2510
|
+
work_order,
|
|
2511
|
+
request_receipt: ApprovalRequestedReceipt,
|
|
2512
|
+
) -> bool:
|
|
2513
|
+
try:
|
|
2514
|
+
connection = evidence.connect_ledger(ledger_path)
|
|
2515
|
+
try:
|
|
2516
|
+
current_work_order, receipts, _, _ = (
|
|
2517
|
+
evidence._replay_receipt_publication_ledger(connection)
|
|
2518
|
+
)
|
|
2519
|
+
state = connection.execute(
|
|
2520
|
+
"SELECT current_state FROM work_order_state WHERE singleton = 1"
|
|
2521
|
+
).fetchone()
|
|
2522
|
+
finally:
|
|
2523
|
+
connection.close()
|
|
2524
|
+
except Exception:
|
|
2525
|
+
return False
|
|
2526
|
+
return (
|
|
2527
|
+
current_work_order == work_order
|
|
2528
|
+
and bool(receipts)
|
|
2529
|
+
and receipts[-1] == request_receipt
|
|
2530
|
+
and state is not None
|
|
2531
|
+
and state[0] == request_receipt.state_after
|
|
2532
|
+
)
|
|
2533
|
+
|
|
2534
|
+
|
|
2535
|
+
def _readback_acceptance_committed(
|
|
2536
|
+
ledger_path: Path,
|
|
2537
|
+
*,
|
|
2538
|
+
work_order,
|
|
2539
|
+
acceptance: AcceptanceReceipt,
|
|
2540
|
+
) -> bool:
|
|
2541
|
+
try:
|
|
2542
|
+
connection = evidence.connect_ledger(ledger_path)
|
|
2543
|
+
try:
|
|
2544
|
+
current_work_order, _, _, _ = (
|
|
2545
|
+
evidence._replay_receipt_publication_ledger(connection)
|
|
2546
|
+
)
|
|
2547
|
+
acceptances = evidence._validated_acceptance_receipts(
|
|
2548
|
+
connection,
|
|
2549
|
+
current_work_order,
|
|
2550
|
+
)
|
|
2551
|
+
finally:
|
|
2552
|
+
connection.close()
|
|
2553
|
+
except Exception:
|
|
2554
|
+
return False
|
|
2555
|
+
return (
|
|
2556
|
+
current_work_order == work_order
|
|
2557
|
+
and acceptances == (acceptance,)
|
|
2558
|
+
)
|
|
2559
|
+
|
|
2560
|
+
|
|
2561
|
+
def _validate_composition_report_binding(
|
|
2562
|
+
*,
|
|
2563
|
+
work_order,
|
|
2564
|
+
report: CompositionReport,
|
|
2565
|
+
receipts: tuple[ActionReceiptEnvelope, ...],
|
|
2566
|
+
) -> CompositionReport:
|
|
2567
|
+
"""Validate one CompositionReport against its exact signed prefix."""
|
|
2568
|
+
initiator_indexes = tuple(
|
|
2569
|
+
index
|
|
2570
|
+
for index, receipt in enumerate(receipts)
|
|
2571
|
+
if receipt.receipt_id == report.initiator_receipt_id
|
|
2572
|
+
and receipt.digest == report.initiator_receipt_digest
|
|
2573
|
+
)
|
|
2574
|
+
if len(initiator_indexes) != 1:
|
|
2575
|
+
raise AcceptanceTransactionError(
|
|
2576
|
+
"composition report initiator is not unique"
|
|
2577
|
+
)
|
|
2578
|
+
initiator_index = initiator_indexes[0]
|
|
2579
|
+
trigger = (
|
|
2580
|
+
receipts[initiator_index + 1]
|
|
2581
|
+
if initiator_index + 1 < len(receipts)
|
|
2582
|
+
else None
|
|
2583
|
+
)
|
|
2584
|
+
report_digest = composition_report_digest(report)
|
|
2585
|
+
report_prefix = receipts[: initiator_index + 1]
|
|
2586
|
+
report_refs = _sorted_unique_evidence_refs(report_prefix)
|
|
2587
|
+
if (
|
|
2588
|
+
report.work_order_digest != work_order.digest
|
|
2589
|
+
or not isinstance(trigger, SystemEventReceipt)
|
|
2590
|
+
or trigger.system_event_name != "proof_composed"
|
|
2591
|
+
or getattr(trigger.cause, "composition_report_digest", None)
|
|
2592
|
+
!= report_digest
|
|
2593
|
+
or report.receipt_digests
|
|
2594
|
+
!= tuple(receipt.digest for receipt in report_prefix)
|
|
2595
|
+
or report.causal_graph_root != causal_graph_root(report_prefix)
|
|
2596
|
+
or report.evidence_snapshot_digest
|
|
2597
|
+
!= evidence_snapshot_digest(report_refs)
|
|
2598
|
+
):
|
|
2599
|
+
raise AcceptanceTransactionError(
|
|
2600
|
+
"composition report does not match its authoritative prefix"
|
|
2601
|
+
)
|
|
2602
|
+
return report
|
|
2603
|
+
|
|
2604
|
+
|
|
2605
|
+
def verify_composition_bundle(
|
|
2606
|
+
*,
|
|
2607
|
+
work_order,
|
|
2608
|
+
effective_grants: tuple,
|
|
2609
|
+
grant_attempts: tuple,
|
|
2610
|
+
receipts: tuple[ActionReceiptEnvelope, ...],
|
|
2611
|
+
committed_evidence: tuple,
|
|
2612
|
+
reports: tuple[CompositionReport, ...],
|
|
2613
|
+
public_keys: Mapping,
|
|
2614
|
+
) -> CompositionReport:
|
|
2615
|
+
"""Verify a copied multi-report bundle without a live ledger."""
|
|
2616
|
+
from openworkproof.evidence import validate_grant_chain # noqa: PLC0415
|
|
2617
|
+
from openworkproof.policy import ( # noqa: PLC0415
|
|
2618
|
+
AuthorizationLedgerPrefix,
|
|
2619
|
+
CommittedEvidence,
|
|
2620
|
+
_validate_committed_evidence,
|
|
2621
|
+
)
|
|
2622
|
+
from openworkproof.composition import replay_authorization_causality # noqa: PLC0415
|
|
2623
|
+
|
|
2624
|
+
if (
|
|
2625
|
+
type(effective_grants) is not tuple
|
|
2626
|
+
or type(grant_attempts) is not tuple
|
|
2627
|
+
or type(committed_evidence) is not tuple
|
|
2628
|
+
or type(reports) is not tuple
|
|
2629
|
+
or not reports
|
|
2630
|
+
or any(
|
|
2631
|
+
not isinstance(item, CommittedEvidence)
|
|
2632
|
+
for item in committed_evidence
|
|
2633
|
+
)
|
|
2634
|
+
or any(
|
|
2635
|
+
not isinstance(item, CompositionReport) for item in reports
|
|
2636
|
+
)
|
|
2637
|
+
):
|
|
2638
|
+
raise AcceptanceTransactionError(
|
|
2639
|
+
"offline composition bundle is malformed"
|
|
2640
|
+
)
|
|
2641
|
+
try:
|
|
2642
|
+
validate_grant_chain(
|
|
2643
|
+
work_order,
|
|
2644
|
+
effective_grants,
|
|
2645
|
+
grant_attempts,
|
|
2646
|
+
receipts,
|
|
2647
|
+
public_keys,
|
|
2648
|
+
)
|
|
2649
|
+
prefix = AuthorizationLedgerPrefix(
|
|
2650
|
+
effective_grants=effective_grants,
|
|
2651
|
+
grant_attempts=grant_attempts,
|
|
2652
|
+
receipts=receipts,
|
|
2653
|
+
)
|
|
2654
|
+
_validate_committed_evidence(
|
|
2655
|
+
work_order,
|
|
2656
|
+
prefix,
|
|
2657
|
+
committed_evidence,
|
|
2658
|
+
)
|
|
2659
|
+
replay_authorization_causality(work_order, receipts)
|
|
2660
|
+
triggers = tuple(
|
|
2661
|
+
receipt
|
|
2662
|
+
for receipt in receipts
|
|
2663
|
+
if isinstance(receipt, SystemEventReceipt)
|
|
2664
|
+
and receipt.system_event_name == "proof_composed"
|
|
2665
|
+
)
|
|
2666
|
+
if len(triggers) != len(reports):
|
|
2667
|
+
raise AcceptanceTransactionError(
|
|
2668
|
+
"composition report count does not match the trigger count"
|
|
2669
|
+
)
|
|
2670
|
+
# Reports must map one-to-one, in order, onto the proof_composed
|
|
2671
|
+
# triggers: duplicates and reordering are rejected so that a tuple
|
|
2672
|
+
# containing the final report twice cannot skip validating an earlier
|
|
2673
|
+
# report.
|
|
2674
|
+
initiator_indexes = tuple(
|
|
2675
|
+
tuple(
|
|
2676
|
+
index
|
|
2677
|
+
for index, receipt in enumerate(receipts)
|
|
2678
|
+
if receipt.receipt_id == report.initiator_receipt_id
|
|
2679
|
+
and receipt.digest == report.initiator_receipt_digest
|
|
2680
|
+
)
|
|
2681
|
+
for report in reports
|
|
2682
|
+
)
|
|
2683
|
+
if any(len(indexes) != 1 for indexes in initiator_indexes):
|
|
2684
|
+
raise AcceptanceTransactionError(
|
|
2685
|
+
"composition report initiator is not unique"
|
|
2686
|
+
)
|
|
2687
|
+
flattened = tuple(indexes[0] for indexes in initiator_indexes)
|
|
2688
|
+
if any(
|
|
2689
|
+
left >= right
|
|
2690
|
+
for left, right in zip(flattened, flattened[1:])
|
|
2691
|
+
):
|
|
2692
|
+
raise AcceptanceTransactionError(
|
|
2693
|
+
"composition reports are duplicated or out of order"
|
|
2694
|
+
)
|
|
2695
|
+
bound_triggers = {
|
|
2696
|
+
receipts[index + 1].receipt_id for index in flattened
|
|
2697
|
+
}
|
|
2698
|
+
if bound_triggers != {trigger.receipt_id for trigger in triggers}:
|
|
2699
|
+
raise AcceptanceTransactionError(
|
|
2700
|
+
"composition reports do not map one-to-one onto triggers"
|
|
2701
|
+
)
|
|
2702
|
+
for report in reports:
|
|
2703
|
+
_validate_composition_report_binding(
|
|
2704
|
+
work_order=work_order,
|
|
2705
|
+
report=report,
|
|
2706
|
+
receipts=receipts,
|
|
2707
|
+
)
|
|
2708
|
+
return reports[-1]
|
|
2709
|
+
except AcceptanceTransactionError:
|
|
2710
|
+
raise
|
|
2711
|
+
except Exception as error:
|
|
2712
|
+
raise AcceptanceTransactionError(
|
|
2713
|
+
"offline composition bundle failed verification"
|
|
2714
|
+
) from error
|
|
2715
|
+
|
|
2716
|
+
|
|
2717
|
+
def _insert_rejection_rows(
|
|
2718
|
+
connection: sqlite3.Connection,
|
|
2719
|
+
*,
|
|
2720
|
+
work_order,
|
|
2721
|
+
rejection: AcceptanceRejectionReceipt,
|
|
2722
|
+
current_state: str,
|
|
2723
|
+
current_version: int,
|
|
2724
|
+
) -> None:
|
|
2725
|
+
connection.execute(
|
|
2726
|
+
"""
|
|
2727
|
+
INSERT INTO acceptance_rejection_receipts (
|
|
2728
|
+
rejection_id, work_order_digest,
|
|
2729
|
+
acceptance_request_receipt_id, rejection_json
|
|
2730
|
+
)
|
|
2731
|
+
VALUES (?, ?, ?, ?)
|
|
2732
|
+
""",
|
|
2733
|
+
(
|
|
2734
|
+
rejection.rejection_id,
|
|
2735
|
+
work_order.digest,
|
|
2736
|
+
rejection.acceptance_request_receipt_id,
|
|
2737
|
+
evidence._canonical_json(rejection.model_dump(mode="json")),
|
|
2738
|
+
),
|
|
2739
|
+
)
|
|
2740
|
+
state_update = connection.execute(
|
|
2741
|
+
"""
|
|
2742
|
+
UPDATE work_order_state
|
|
2743
|
+
SET current_state = ?, version = version + 1
|
|
2744
|
+
WHERE singleton = 1
|
|
2745
|
+
AND work_order_digest = ?
|
|
2746
|
+
AND current_state = ?
|
|
2747
|
+
AND version = ?
|
|
2748
|
+
""",
|
|
2749
|
+
(
|
|
2750
|
+
"rejected",
|
|
2751
|
+
work_order.digest,
|
|
2752
|
+
current_state,
|
|
2753
|
+
current_version,
|
|
2754
|
+
),
|
|
2755
|
+
)
|
|
2756
|
+
if state_update.rowcount != 1:
|
|
2757
|
+
raise AcceptanceTransactionError(
|
|
2758
|
+
"rejection state could not be advanced"
|
|
2759
|
+
)
|
|
2760
|
+
|
|
2761
|
+
|
|
2762
|
+
def _readback_rejection_committed(
|
|
2763
|
+
ledger_path: Path,
|
|
2764
|
+
*,
|
|
2765
|
+
work_order,
|
|
2766
|
+
rejection: AcceptanceRejectionReceipt,
|
|
2767
|
+
) -> bool:
|
|
2768
|
+
"""Prove the exact rejection committed by reopening the ledger."""
|
|
2769
|
+
try:
|
|
2770
|
+
connection = evidence.connect_ledger(ledger_path)
|
|
2771
|
+
try:
|
|
2772
|
+
current_work_order, _, _, _ = (
|
|
2773
|
+
evidence._replay_receipt_publication_ledger(connection)
|
|
2774
|
+
)
|
|
2775
|
+
rejections = evidence._validated_acceptance_rejections(
|
|
2776
|
+
connection,
|
|
2777
|
+
current_work_order,
|
|
2778
|
+
)
|
|
2779
|
+
state = connection.execute(
|
|
2780
|
+
"SELECT current_state FROM work_order_state "
|
|
2781
|
+
"WHERE singleton = 1"
|
|
2782
|
+
).fetchone()
|
|
2783
|
+
finally:
|
|
2784
|
+
connection.close()
|
|
2785
|
+
except Exception:
|
|
2786
|
+
return False
|
|
2787
|
+
return (
|
|
2788
|
+
current_work_order == work_order
|
|
2789
|
+
and len(rejections) == 1
|
|
2790
|
+
and rejections[0].rejection_id == rejection.rejection_id
|
|
2791
|
+
and state is not None
|
|
2792
|
+
and state[0] == "rejected"
|
|
2793
|
+
)
|
|
2794
|
+
|
|
2795
|
+
|
|
2796
|
+
def reject_acceptance_transaction(
|
|
2797
|
+
ledger_path: Path,
|
|
2798
|
+
*,
|
|
2799
|
+
evidence_root: Path,
|
|
2800
|
+
context: AuthorizationContext,
|
|
2801
|
+
rejection: AcceptanceRejectionReceipt,
|
|
2802
|
+
public_keys=None,
|
|
2803
|
+
clock: Callable[[], datetime],
|
|
2804
|
+
) -> AcceptanceRejectionReceipt:
|
|
2805
|
+
"""Atomically commit the Acceptor-signed AcceptanceRejectionReceipt."""
|
|
2806
|
+
from openworkproof.signing import ( # noqa: PLC0415
|
|
2807
|
+
decode_and_verify_key_binding,
|
|
2808
|
+
verify_payload,
|
|
2809
|
+
)
|
|
2810
|
+
|
|
2811
|
+
path = Path(ledger_path)
|
|
2812
|
+
if not path.is_file():
|
|
2813
|
+
raise AcceptanceTransactionError("acceptance ledger is unavailable")
|
|
2814
|
+
lock_descriptor: int | None = None
|
|
2815
|
+
owns_lock = False
|
|
2816
|
+
connection: sqlite3.Connection | None = None
|
|
2817
|
+
committed_result: AcceptanceRejectionReceipt | None = None
|
|
2818
|
+
try:
|
|
2819
|
+
lock_descriptor, owns_lock = evidence._borrow_or_acquire_target_lock(
|
|
2820
|
+
path, None
|
|
2821
|
+
)
|
|
2822
|
+
now = _freeze_second(clock())
|
|
2823
|
+
if context.transaction_time != now:
|
|
2824
|
+
raise AcceptanceTransactionError(
|
|
2825
|
+
"authorization context time is stale"
|
|
2826
|
+
)
|
|
2827
|
+
try:
|
|
2828
|
+
require_current_context(
|
|
2829
|
+
path,
|
|
2830
|
+
evidence_root,
|
|
2831
|
+
context,
|
|
2832
|
+
now,
|
|
2833
|
+
lock_descriptor,
|
|
2834
|
+
)
|
|
2835
|
+
except RuntimeContextError as error:
|
|
2836
|
+
if _readback_rejection_committed(
|
|
2837
|
+
path,
|
|
2838
|
+
work_order=context.work_order,
|
|
2839
|
+
rejection=rejection,
|
|
2840
|
+
):
|
|
2841
|
+
committed_result = rejection
|
|
2842
|
+
raise AcceptanceCommittedError(
|
|
2843
|
+
"the exact rejection is already committed",
|
|
2844
|
+
rejection,
|
|
2845
|
+
) from error
|
|
2846
|
+
raise
|
|
2847
|
+
if context.current_state != "awaiting_human":
|
|
2848
|
+
raise AcceptanceTransactionError(
|
|
2849
|
+
"rejection requires awaiting_human"
|
|
2850
|
+
)
|
|
2851
|
+
work_order, receipts, _, _ = evidence._replay_receipt_publication_ledger(
|
|
2852
|
+
evidence.connect_ledger(path)
|
|
2853
|
+
)
|
|
2854
|
+
report = _current_report(path, work_order)
|
|
2855
|
+
request = _current_acceptance_request(path, work_order)
|
|
2856
|
+
if request.expires_at < now:
|
|
2857
|
+
raise AcceptanceTransactionError(
|
|
2858
|
+
"acceptance request has expired"
|
|
2859
|
+
)
|
|
2860
|
+
if context.ledger_prefix.receipts[-1].digest != request.digest:
|
|
2861
|
+
raise AcceptanceTransactionError(
|
|
2862
|
+
"acceptance request is not the current ledger tip"
|
|
2863
|
+
)
|
|
2864
|
+
acceptor = work_order.key_bindings[5]
|
|
2865
|
+
if rejection.signer_key_id != acceptor.key_id:
|
|
2866
|
+
raise AcceptanceTransactionError(
|
|
2867
|
+
"rejection must be signed by the bound Acceptor"
|
|
2868
|
+
)
|
|
2869
|
+
parsed = AcceptanceRejectionReceipt.model_validate(
|
|
2870
|
+
rejection.model_dump(mode="json")
|
|
2871
|
+
)
|
|
2872
|
+
parsed.validate_against_work_order(work_order)
|
|
2873
|
+
rejected_at = parsed.rejected_at
|
|
2874
|
+
if rejected_at < request.occurred_at:
|
|
2875
|
+
raise AcceptanceTransactionError(
|
|
2876
|
+
"rejected_at precedes the acceptance request"
|
|
2877
|
+
)
|
|
2878
|
+
if rejected_at > now:
|
|
2879
|
+
raise AcceptanceTransactionError(
|
|
2880
|
+
"rejected_at is in the future"
|
|
2881
|
+
)
|
|
2882
|
+
if (now - rejected_at).total_seconds() > 300:
|
|
2883
|
+
raise AcceptanceTransactionError(
|
|
2884
|
+
"rejection signature is stale"
|
|
2885
|
+
)
|
|
2886
|
+
if rejected_at > request.expires_at:
|
|
2887
|
+
raise AcceptanceTransactionError(
|
|
2888
|
+
"rejection signature exceeds request expiry"
|
|
2889
|
+
)
|
|
2890
|
+
if rejected_at > work_order.deadline:
|
|
2891
|
+
raise AcceptanceTransactionError(
|
|
2892
|
+
"rejection signature exceeds the WorkOrder deadline"
|
|
2893
|
+
)
|
|
2894
|
+
prefix = context.ledger_prefix.receipts
|
|
2895
|
+
refs = _sorted_unique_evidence_refs(prefix)
|
|
2896
|
+
snapshot = evidence_snapshot_digest(refs)
|
|
2897
|
+
expected_id = rejection_id(
|
|
2898
|
+
work_order_digest=work_order.digest,
|
|
2899
|
+
request_receipt_id=request.receipt_id,
|
|
2900
|
+
request_receipt_digest=request.digest,
|
|
2901
|
+
report_digest=composition_report_digest(report),
|
|
2902
|
+
evidence_snapshot=snapshot,
|
|
2903
|
+
)
|
|
2904
|
+
if parsed.rejection_id != expected_id:
|
|
2905
|
+
raise AcceptanceTransactionError(
|
|
2906
|
+
"rejection ID does not match the authoritative snapshot"
|
|
2907
|
+
)
|
|
2908
|
+
expected_payload = _expected_rejection_payload(
|
|
2909
|
+
work_order,
|
|
2910
|
+
report,
|
|
2911
|
+
request,
|
|
2912
|
+
snapshot,
|
|
2913
|
+
expected_id,
|
|
2914
|
+
rejected_at,
|
|
2915
|
+
prefix,
|
|
2916
|
+
parsed.reason_code,
|
|
2917
|
+
parsed.reason_detail,
|
|
2918
|
+
)
|
|
2919
|
+
actual_payload = {
|
|
2920
|
+
key: value
|
|
2921
|
+
for key, value in parsed.model_dump(mode="json").items()
|
|
2922
|
+
if key not in {"digest", "signature_alg", "signer_key_id", "signature"}
|
|
2923
|
+
}
|
|
2924
|
+
if actual_payload != expected_payload:
|
|
2925
|
+
raise AcceptanceTransactionError(
|
|
2926
|
+
"rejection payload does not match the authoritative snapshot"
|
|
2927
|
+
)
|
|
2928
|
+
acceptor_key = decode_and_verify_key_binding(acceptor)
|
|
2929
|
+
if not verify_payload(
|
|
2930
|
+
"acceptance-rejection-receipt",
|
|
2931
|
+
parsed.model_dump(mode="json"),
|
|
2932
|
+
acceptor_key,
|
|
2933
|
+
):
|
|
2934
|
+
raise AcceptanceTransactionError(
|
|
2935
|
+
"rejection signature verification failed"
|
|
2936
|
+
)
|
|
2937
|
+
connection = evidence.connect_ledger(path)
|
|
2938
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
2939
|
+
existing_acceptance = connection.execute(
|
|
2940
|
+
"SELECT COUNT(*) FROM acceptance_receipts"
|
|
2941
|
+
).fetchone()
|
|
2942
|
+
if existing_acceptance is not None and existing_acceptance[0] != 0:
|
|
2943
|
+
raise AcceptanceTransactionError(
|
|
2944
|
+
"WorkOrder already has an acceptance"
|
|
2945
|
+
)
|
|
2946
|
+
existing_rejection = connection.execute(
|
|
2947
|
+
"SELECT COUNT(*) FROM acceptance_rejection_receipts"
|
|
2948
|
+
).fetchone()
|
|
2949
|
+
if existing_rejection is not None and existing_rejection[0] != 0:
|
|
2950
|
+
raise AcceptanceTransactionError(
|
|
2951
|
+
"WorkOrder already has a rejection"
|
|
2952
|
+
)
|
|
2953
|
+
state_row = connection.execute(
|
|
2954
|
+
"SELECT current_state, version FROM work_order_state WHERE singleton = 1"
|
|
2955
|
+
).fetchone()
|
|
2956
|
+
if state_row != ("awaiting_human", _derive_version(connection, work_order)):
|
|
2957
|
+
raise AcceptanceTransactionError(
|
|
2958
|
+
"rejection ledger changed under the target lock"
|
|
2959
|
+
)
|
|
2960
|
+
_insert_rejection_rows(
|
|
2961
|
+
connection,
|
|
2962
|
+
work_order=work_order,
|
|
2963
|
+
rejection=parsed,
|
|
2964
|
+
current_state=state_row[0],
|
|
2965
|
+
current_version=state_row[1],
|
|
2966
|
+
)
|
|
2967
|
+
try:
|
|
2968
|
+
connection.execute("COMMIT")
|
|
2969
|
+
except Exception as error:
|
|
2970
|
+
if _readback_rejection_committed(
|
|
2971
|
+
path,
|
|
2972
|
+
work_order=work_order,
|
|
2973
|
+
rejection=parsed,
|
|
2974
|
+
):
|
|
2975
|
+
committed_result = parsed
|
|
2976
|
+
raise AcceptanceCommittedError(
|
|
2977
|
+
"rejection committed but its acknowledgement was lost",
|
|
2978
|
+
parsed,
|
|
2979
|
+
) from error
|
|
2980
|
+
raise AcceptanceCommitIndeterminateError(
|
|
2981
|
+
"rejection commit outcome is indeterminate"
|
|
2982
|
+
) from error
|
|
2983
|
+
if not _readback_rejection_committed(
|
|
2984
|
+
path,
|
|
2985
|
+
work_order=work_order,
|
|
2986
|
+
rejection=parsed,
|
|
2987
|
+
):
|
|
2988
|
+
raise AcceptanceCommitIndeterminateError(
|
|
2989
|
+
"rejection readback could not confirm the exact commit"
|
|
2990
|
+
)
|
|
2991
|
+
committed_result = parsed
|
|
2992
|
+
return parsed
|
|
2993
|
+
except Exception as error:
|
|
2994
|
+
if committed_result is not None and isinstance(
|
|
2995
|
+
error, AcceptanceCommittedError
|
|
2996
|
+
):
|
|
2997
|
+
raise
|
|
2998
|
+
raise
|
|
2999
|
+
finally:
|
|
3000
|
+
if connection is not None:
|
|
3001
|
+
try:
|
|
3002
|
+
connection.close()
|
|
3003
|
+
except Exception:
|
|
3004
|
+
pass
|
|
3005
|
+
if owns_lock and lock_descriptor is not None:
|
|
3006
|
+
evidence._release_target_lock(lock_descriptor)
|