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.
Files changed (39) hide show
  1. openworkproof/__init__.py +1 -0
  2. openworkproof/acceptance.py +3006 -0
  3. openworkproof/cli.py +164 -0
  4. openworkproof/composition.py +832 -0
  5. openworkproof/evidence.py +8281 -0
  6. openworkproof/execution_adapter.py +256 -0
  7. openworkproof/external_acceptor.py +211 -0
  8. openworkproof/mcp_server.py +2939 -0
  9. openworkproof/mcp_transport.py +72 -0
  10. openworkproof/models.py +3380 -0
  11. openworkproof/policy.py +2494 -0
  12. openworkproof/predicates.py +333 -0
  13. openworkproof/repo_pipeline/__init__.py +74 -0
  14. openworkproof/repo_pipeline/analysis.py +152 -0
  15. openworkproof/repo_pipeline/errors.py +64 -0
  16. openworkproof/repo_pipeline/models.py +80 -0
  17. openworkproof/repo_pipeline/output.py +68 -0
  18. openworkproof/repo_pipeline/reader.py +47 -0
  19. openworkproof/repo_pipeline/sources.py +120 -0
  20. openworkproof/repo_pipeline/traversal.py +253 -0
  21. openworkproof/repo_tools.py +8382 -0
  22. openworkproof/runtime_context.py +189 -0
  23. openworkproof/schema_registry.py +623 -0
  24. openworkproof/schemas/v0.1/acceptance-receipt.schema.json +1 -0
  25. openworkproof/schemas/v0.1/acceptance-rejection-receipt.schema.json +1 -0
  26. openworkproof/schemas/v0.1/action-receipt.schema.json +1 -0
  27. openworkproof/schemas/v0.1/capability-grant.schema.json +1 -0
  28. openworkproof/schemas/v0.1/schema-registry.json +1 -0
  29. openworkproof/schemas/v0.1/work-order.schema.json +1 -0
  30. openworkproof/signing.py +421 -0
  31. openworkproof/state.py +765 -0
  32. openworkproof/team_network_client.py +419 -0
  33. openworkproof/trusted_helper.py +269 -0
  34. openworkproof-1.0.0.dist-info/METADATA +578 -0
  35. openworkproof-1.0.0.dist-info/RECORD +39 -0
  36. openworkproof-1.0.0.dist-info/WHEEL +5 -0
  37. openworkproof-1.0.0.dist-info/entry_points.txt +2 -0
  38. openworkproof-1.0.0.dist-info/licenses/LICENSE +202 -0
  39. 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)