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
openworkproof/state.py ADDED
@@ -0,0 +1,765 @@
1
+ """Evidence-gated task state transitions for protocol v0.1."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from datetime import datetime, timezone
7
+ from enum import StrEnum
8
+
9
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
10
+ from pydantic import ValidationError
11
+
12
+ from openworkproof.models import (
13
+ ACTION_RECEIPT_ADAPTER,
14
+ AcceptanceReceipt,
15
+ ActionReceipt,
16
+ ActionReceiptEnvelope,
17
+ ApprovalDecisionReceipt,
18
+ ApprovalRequestedReceipt,
19
+ GrantConsumedReceipt,
20
+ GrantIssuedReceipt,
21
+ GrantRevokedReceipt,
22
+ RollbackReceipt,
23
+ RunTestsArguments,
24
+ SystemEventReceipt,
25
+ TerminationDecisionReceipt,
26
+ TestsPassedPredicateInput,
27
+ ToolCallReceipt,
28
+ TransitionDecision,
29
+ WorkOrder,
30
+ )
31
+ from openworkproof.signing import (
32
+ verify_nested_claim,
33
+ verify_payload,
34
+ verify_work_order_identity_bindings,
35
+ )
36
+
37
+
38
+ class TaskState(StrEnum):
39
+ ISSUED = "issued"
40
+ RUNNING = "running"
41
+ NEEDS_REWORK = "needs_rework"
42
+ RETRYING = "retrying"
43
+ LOCALLY_VERIFIED = "locally_verified"
44
+ EVIDENCE_INCOMPLETE = "evidence_incomplete"
45
+ PROOF_READY = "proof_ready"
46
+ AWAITING_HUMAN = "awaiting_human"
47
+ ACCEPTED = "accepted"
48
+ REJECTED = "rejected"
49
+ FROZEN = "frozen"
50
+
51
+
52
+ ALLOWED_TRANSITIONS: dict[TaskState, frozenset[TaskState]] = {
53
+ TaskState.ISSUED: frozenset(
54
+ {TaskState.RUNNING, TaskState.FROZEN, TaskState.REJECTED}
55
+ ),
56
+ TaskState.RUNNING: frozenset(
57
+ {
58
+ TaskState.NEEDS_REWORK,
59
+ TaskState.LOCALLY_VERIFIED,
60
+ TaskState.FROZEN,
61
+ TaskState.REJECTED,
62
+ }
63
+ ),
64
+ TaskState.NEEDS_REWORK: frozenset(
65
+ {TaskState.RETRYING, TaskState.FROZEN, TaskState.REJECTED}
66
+ ),
67
+ TaskState.RETRYING: frozenset(
68
+ {
69
+ TaskState.NEEDS_REWORK,
70
+ TaskState.LOCALLY_VERIFIED,
71
+ TaskState.FROZEN,
72
+ TaskState.REJECTED,
73
+ }
74
+ ),
75
+ TaskState.LOCALLY_VERIFIED: frozenset(
76
+ {
77
+ TaskState.EVIDENCE_INCOMPLETE,
78
+ TaskState.PROOF_READY,
79
+ TaskState.FROZEN,
80
+ TaskState.REJECTED,
81
+ }
82
+ ),
83
+ TaskState.EVIDENCE_INCOMPLETE: frozenset(
84
+ {TaskState.PROOF_READY, TaskState.FROZEN, TaskState.REJECTED}
85
+ ),
86
+ TaskState.PROOF_READY: frozenset(
87
+ {TaskState.AWAITING_HUMAN, TaskState.FROZEN, TaskState.REJECTED}
88
+ ),
89
+ TaskState.AWAITING_HUMAN: frozenset(
90
+ {TaskState.ACCEPTED, TaskState.FROZEN, TaskState.REJECTED}
91
+ ),
92
+ TaskState.ACCEPTED: frozenset(),
93
+ TaskState.REJECTED: frozenset(),
94
+ TaskState.FROZEN: frozenset({TaskState.REJECTED}),
95
+ }
96
+
97
+ _EXPIRABLE_STATES = frozenset(
98
+ {
99
+ TaskState.ISSUED,
100
+ TaskState.RUNNING,
101
+ TaskState.NEEDS_REWORK,
102
+ TaskState.RETRYING,
103
+ TaskState.LOCALLY_VERIFIED,
104
+ TaskState.EVIDENCE_INCOMPLETE,
105
+ TaskState.PROOF_READY,
106
+ TaskState.AWAITING_HUMAN,
107
+ }
108
+ )
109
+
110
+
111
+ def _allowed(reason: str) -> TransitionDecision:
112
+ return TransitionDecision(allowed=True, error_code=None, reason=reason)
113
+
114
+
115
+ def _denied(code: str, reason: str) -> TransitionDecision:
116
+ return TransitionDecision(allowed=False, error_code=code, reason=reason)
117
+
118
+
119
+ def _public_key(
120
+ public_keys: Mapping[str, Ed25519PublicKey],
121
+ key_id: str,
122
+ ) -> Ed25519PublicKey | None:
123
+ value = public_keys.get(key_id)
124
+ return value if isinstance(value, Ed25519PublicKey) else None
125
+
126
+
127
+ def _work_order_is_trusted(
128
+ work_order: WorkOrder,
129
+ public_keys: Mapping[str, Ed25519PublicKey],
130
+ ) -> bool:
131
+ if not verify_work_order_identity_bindings(work_order):
132
+ return False
133
+ maintainer = work_order.key_bindings[0]
134
+ public_key = _public_key(public_keys, maintainer.key_id)
135
+ return public_key is not None and verify_payload(
136
+ "work-order",
137
+ work_order.model_dump(mode="json"),
138
+ public_key,
139
+ )
140
+
141
+
142
+ def _validate_action_receipt(
143
+ receipt: ActionReceipt,
144
+ work_order: WorkOrder,
145
+ public_keys: Mapping[str, Ed25519PublicKey],
146
+ now: datetime,
147
+ ) -> ActionReceipt | None:
148
+ try:
149
+ parsed = ACTION_RECEIPT_ADAPTER.validate_python(
150
+ receipt.model_dump(mode="json")
151
+ )
152
+ parsed.validate_against_work_order(work_order)
153
+ if isinstance(parsed, ToolCallReceipt):
154
+ parsed.validate_predicates_against(work_order)
155
+
156
+ gateway_key = _public_key(public_keys, parsed.gateway_signer_key_id)
157
+ if gateway_key is None or not verify_payload(
158
+ "action-receipt",
159
+ parsed.model_dump(mode="json"),
160
+ gateway_key,
161
+ ):
162
+ return None
163
+ if parsed.actor_type in {"agent", "human"}:
164
+ actor_key = _public_key(public_keys, parsed.actor_key_id)
165
+ domain = (
166
+ "agent-request"
167
+ if parsed.actor_type == "agent"
168
+ else "human-decision"
169
+ )
170
+ if (
171
+ actor_key is None
172
+ or not verify_nested_claim(parsed.nested_claim, work_order)
173
+ or not verify_payload(
174
+ domain,
175
+ parsed.nested_claim.model_dump(mode="json"),
176
+ actor_key,
177
+ )
178
+ ):
179
+ return None
180
+
181
+ if now.tzinfo is None or now.utcoffset() is None:
182
+ return None
183
+ normalized_now = now.astimezone(timezone.utc)
184
+ if parsed.occurred_at > normalized_now:
185
+ return None
186
+ if parsed.sequence == 1 and (
187
+ parsed.previous_receipt_digest is not None
188
+ or parsed.parent_receipt_ids
189
+ ):
190
+ return None
191
+ if parsed.sequence > 1 and parsed.previous_receipt_digest is None:
192
+ return None
193
+ return parsed
194
+ except (AttributeError, TypeError, ValidationError, ValueError):
195
+ return None
196
+
197
+
198
+ def _actor_role(receipt: ActionReceiptEnvelope, work_order: WorkOrder) -> str | None:
199
+ for binding in work_order.key_bindings:
200
+ if (
201
+ binding.subject_id == receipt.actor_id
202
+ and binding.key_id == receipt.actor_key_id
203
+ ):
204
+ return binding.role
205
+ return None
206
+
207
+
208
+ def _agent_direct_call_is_authorized(
209
+ receipt: ActionReceipt,
210
+ work_order: WorkOrder,
211
+ ) -> bool:
212
+ role = _actor_role(receipt, work_order)
213
+ tool_name = receipt.nested_claim.tool_name
214
+ if role == "Manager":
215
+ if isinstance(receipt, GrantIssuedReceipt):
216
+ expected = (
217
+ "owp.activate_root_grant"
218
+ if receipt.parent_grant_id is None
219
+ else "owp.delegate_grant"
220
+ )
221
+ return tool_name == expected
222
+ if isinstance(receipt, GrantRevokedReceipt):
223
+ return tool_name == "owp.revoke_grant"
224
+ if isinstance(receipt, GrantConsumedReceipt):
225
+ return tool_name == "owp.start_retry"
226
+ if isinstance(receipt, ApprovalRequestedReceipt):
227
+ expected = (
228
+ "owp.request_acceptance"
229
+ if receipt.request_kind == "final_acceptance"
230
+ else "owp.request_pr_proposal"
231
+ )
232
+ return tool_name == expected
233
+ return isinstance(receipt, ToolCallReceipt) and tool_name in {
234
+ "owp.create_pr_proposal",
235
+ "owp.compose_proof",
236
+ }
237
+ if role == "Developer":
238
+ if isinstance(receipt, RollbackReceipt):
239
+ return tool_name == "owp.rollback_patch"
240
+ if not isinstance(receipt, ToolCallReceipt):
241
+ return False
242
+ if tool_name in {"owp.repo_read", "owp.apply_patch"}:
243
+ return True
244
+ return (
245
+ tool_name == "owp.run_tests"
246
+ and isinstance(receipt.request_arguments, RunTestsArguments)
247
+ and receipt.request_arguments.test_mode == "developer"
248
+ )
249
+ if role == "Verifier":
250
+ return (
251
+ isinstance(receipt, ToolCallReceipt)
252
+ and tool_name == "owp.run_tests"
253
+ and isinstance(receipt.request_arguments, RunTestsArguments)
254
+ and receipt.request_arguments.test_mode == "verifier"
255
+ )
256
+ return False
257
+
258
+
259
+ def _validate_acceptance(
260
+ acceptance: AcceptanceReceipt,
261
+ work_order: WorkOrder,
262
+ public_keys: Mapping[str, Ed25519PublicKey],
263
+ now: datetime,
264
+ ) -> bool:
265
+ try:
266
+ parsed = AcceptanceReceipt.model_validate(
267
+ acceptance.model_dump(mode="json")
268
+ )
269
+ parsed.validate_against_work_order(work_order)
270
+ acceptor = work_order.key_bindings[5]
271
+ public_key = _public_key(public_keys, acceptor.key_id)
272
+ return (
273
+ public_key is not None
274
+ and verify_payload(
275
+ "acceptance-receipt",
276
+ parsed.model_dump(mode="json"),
277
+ public_key,
278
+ )
279
+ and parsed.accepted_at <= now.astimezone(timezone.utc)
280
+ and parsed.accepted_at <= work_order.deadline
281
+ )
282
+ except (AttributeError, TypeError, ValidationError, ValueError):
283
+ return False
284
+
285
+
286
+ def _validate_verifier_transition(
287
+ receipt: ToolCallReceipt,
288
+ work_order: WorkOrder,
289
+ state_after: TaskState,
290
+ ) -> bool:
291
+ if (
292
+ not _agent_direct_call_is_authorized(receipt, work_order)
293
+ or receipt.policy_decision != "allow"
294
+ or receipt.execution_status != "succeeded"
295
+ ):
296
+ return False
297
+ arguments = receipt.request_arguments
298
+ profile = next(
299
+ (
300
+ profile
301
+ for profile in work_order.test_profiles
302
+ if profile.test_mode == "verifier"
303
+ ),
304
+ None,
305
+ )
306
+ if profile is None or (
307
+ getattr(arguments, "test_mode", None),
308
+ getattr(arguments, "command_digest", None),
309
+ getattr(arguments, "source_commit", None),
310
+ getattr(arguments, "container_image_digest", None),
311
+ getattr(arguments, "fixed_test_source_digest", None),
312
+ ) != (
313
+ "verifier",
314
+ profile.command_digest,
315
+ work_order.source_commit,
316
+ profile.container_image_digest,
317
+ profile.fixed_test_source_digest,
318
+ ):
319
+ return False
320
+
321
+ post_ids = {spec.predicate_id for spec in work_order.postconditions}
322
+ post_results = [
323
+ result
324
+ for result in receipt.predicate_results
325
+ if result.predicate_id in post_ids
326
+ ]
327
+ if len(post_results) != len(work_order.postconditions):
328
+ return False
329
+ tests_results = [
330
+ result for result in post_results if result.name == "tests_passed"
331
+ ]
332
+ if len(tests_results) != 1 or not isinstance(
333
+ tests_results[0].input,
334
+ TestsPassedPredicateInput,
335
+ ):
336
+ return False
337
+ test_result = tests_results[0]
338
+ if state_after is TaskState.LOCALLY_VERIFIED:
339
+ return all(result.passed for result in post_results) and (
340
+ test_result.input.actual_exit_code
341
+ == test_result.input.expected_exit_code
342
+ )
343
+ if state_after is TaskState.NEEDS_REWORK:
344
+ return any(
345
+ not result.passed and result.error_code == "PREDICATE_FALSE"
346
+ for result in post_results
347
+ )
348
+ return False
349
+
350
+
351
+ def _validate_final_request(
352
+ receipt: ApprovalRequestedReceipt,
353
+ work_order: WorkOrder,
354
+ now: datetime,
355
+ ) -> bool:
356
+ validity_seconds = (receipt.expires_at - receipt.occurred_at).total_seconds()
357
+ return (
358
+ _agent_direct_call_is_authorized(receipt, work_order)
359
+ and receipt.request_kind == "final_acceptance"
360
+ and receipt.nested_claim.tool_name == "owp.request_acceptance"
361
+ and receipt.policy_decision == "allow"
362
+ and receipt.execution_status == "succeeded"
363
+ and 0 <= validity_seconds <= 3600
364
+ and receipt.occurred_at <= now.astimezone(timezone.utc)
365
+ and now.astimezone(timezone.utc) <= receipt.expires_at
366
+ and receipt.expires_at <= work_order.deadline
367
+ )
368
+
369
+
370
+ def _validate_termination(
371
+ receipt: TerminationDecisionReceipt,
372
+ work_order: WorkOrder,
373
+ state_before: TaskState,
374
+ now: datetime,
375
+ ) -> bool:
376
+ if now.tzinfo is None or now.utcoffset() is None:
377
+ return False
378
+ skew = abs((receipt.occurred_at - receipt.decided_at).total_seconds())
379
+ deadline_valid = state_before is TaskState.FROZEN or (
380
+ state_before in _EXPIRABLE_STATES
381
+ and receipt.decided_at <= work_order.deadline
382
+ and receipt.occurred_at <= work_order.deadline
383
+ )
384
+ return (
385
+ _actor_role(receipt, work_order) == "Maintainer"
386
+ and receipt.policy_decision == "allow"
387
+ and receipt.execution_status == "succeeded"
388
+ and receipt.target_work_order_digest == work_order.digest
389
+ and receipt.decision == "rejected"
390
+ and receipt.termination_reason == "MAINTAINER_REJECTED"
391
+ and receipt.decided_at >= work_order.issued_at
392
+ and receipt.occurred_at <= now.astimezone(timezone.utc)
393
+ and skew <= 300
394
+ and deadline_valid
395
+ )
396
+
397
+
398
+ def _validate_contract_expiry(
399
+ receipt: SystemEventReceipt,
400
+ work_order: WorkOrder,
401
+ now: datetime,
402
+ ) -> bool:
403
+ cause = receipt.cause
404
+ return (
405
+ receipt.system_event_name == "contract_expired"
406
+ and receipt.error_code == "CONTRACT_EXPIRED"
407
+ and getattr(cause, "deadline", None) == work_order.deadline
408
+ and getattr(cause, "observed_at", None) == receipt.occurred_at
409
+ and getattr(cause, "tip_receipt_digest", None)
410
+ == receipt.previous_receipt_digest
411
+ and receipt.occurred_at > work_order.deadline
412
+ and now.astimezone(timezone.utc) > work_order.deadline
413
+ )
414
+
415
+
416
+ def validate_transition_evidence(
417
+ work_order: WorkOrder,
418
+ state_before: TaskState,
419
+ state_after: TaskState,
420
+ trigger_receipt: ActionReceipt | None,
421
+ acceptance_receipt: AcceptanceReceipt | None,
422
+ public_keys: Mapping[str, Ed25519PublicKey],
423
+ now: datetime,
424
+ ) -> TransitionDecision:
425
+ if (
426
+ not isinstance(work_order, WorkOrder)
427
+ or not isinstance(state_before, TaskState)
428
+ or not isinstance(state_after, TaskState)
429
+ or state_after not in ALLOWED_TRANSITIONS[state_before]
430
+ ):
431
+ return _denied("INVALID_TRANSITION", "transition is not adjacent")
432
+ if (
433
+ not isinstance(now, datetime)
434
+ or now.tzinfo is None
435
+ or now.utcoffset() is None
436
+ ):
437
+ return _denied("INVALID_EVIDENCE", "now must be timezone-aware")
438
+ if not _work_order_is_trusted(work_order, public_keys):
439
+ return _denied("INVALID_EVIDENCE", "WorkOrder trust failed")
440
+
441
+ if state_after is TaskState.ACCEPTED:
442
+ if (
443
+ trigger_receipt is not None
444
+ or not isinstance(acceptance_receipt, AcceptanceReceipt)
445
+ or not _validate_acceptance(
446
+ acceptance_receipt,
447
+ work_order,
448
+ public_keys,
449
+ now,
450
+ )
451
+ ):
452
+ return _denied("INVALID_EVIDENCE", "acceptance evidence is invalid")
453
+ return _allowed("Acceptor accepted the WorkOrder")
454
+
455
+ if acceptance_receipt is not None or trigger_receipt is None:
456
+ return _denied("INVALID_EVIDENCE", "action transition evidence is missing")
457
+ receipt = _validate_action_receipt(
458
+ trigger_receipt,
459
+ work_order,
460
+ public_keys,
461
+ now,
462
+ )
463
+ if receipt is None or (
464
+ receipt.state_before != state_before.value
465
+ or receipt.state_after != state_after.value
466
+ ):
467
+ return _denied("INVALID_EVIDENCE", "receipt binding is invalid")
468
+
469
+ if isinstance(receipt, SystemEventReceipt) and receipt.system_event_name in {
470
+ "proof_composed",
471
+ "security_violation",
472
+ }:
473
+ # Receipt-level validation is authoritative here; the full report
474
+ # recomputation is enforced by the compose/commit transactions.
475
+ return _allowed("composition event authorized the transition")
476
+
477
+ if (
478
+ isinstance(receipt, TerminationDecisionReceipt)
479
+ and (
480
+ state_before is TaskState.FROZEN
481
+ or receipt.sequence > 1
482
+ )
483
+ ) or (
484
+ receipt.sequence > 1
485
+ and (
486
+ isinstance(receipt, SystemEventReceipt)
487
+ and receipt.system_event_name == "contract_expired"
488
+ )
489
+ ):
490
+ return _denied(
491
+ "TRANSITION_CONTEXT_UNAVAILABLE",
492
+ "trusted chain tip is not available in Task 5",
493
+ )
494
+
495
+ if (
496
+ state_before is TaskState.NEEDS_REWORK
497
+ and state_after is TaskState.RETRYING
498
+ ):
499
+ if (
500
+ not isinstance(receipt, GrantConsumedReceipt)
501
+ or not _agent_direct_call_is_authorized(receipt, work_order)
502
+ or receipt.metric != "repair_rounds"
503
+ or receipt.amount != 1
504
+ ):
505
+ return _denied("INVALID_EVIDENCE", "retry charge is invalid")
506
+ return _denied(
507
+ "TRANSITION_CONTEXT_UNAVAILABLE",
508
+ "failure episode and rollback history are unavailable in Task 5",
509
+ )
510
+
511
+ if state_before is TaskState.ISSUED and state_after is TaskState.RUNNING:
512
+ root = work_order.root_grant_template
513
+ if (
514
+ isinstance(receipt, GrantIssuedReceipt)
515
+ and _agent_direct_call_is_authorized(receipt, work_order)
516
+ and receipt.parent_grant_id is None
517
+ and receipt.authorizing_grant_id == root.grant_id
518
+ and receipt.issued_grant_id == root.grant_id
519
+ and receipt.nested_claim.tool_name == "owp.activate_root_grant"
520
+ ):
521
+ return _allowed("root Grant activated")
522
+ return _denied("INVALID_EVIDENCE", "root activation evidence is invalid")
523
+
524
+ if state_before in {TaskState.RUNNING, TaskState.RETRYING} and state_after in {
525
+ TaskState.NEEDS_REWORK,
526
+ TaskState.LOCALLY_VERIFIED,
527
+ }:
528
+ if isinstance(receipt, ToolCallReceipt) and _validate_verifier_transition(
529
+ receipt,
530
+ work_order,
531
+ state_after,
532
+ ):
533
+ return _allowed("Verifier outcome authorizes transition")
534
+ return _denied("INVALID_EVIDENCE", "Verifier evidence is invalid")
535
+
536
+ if (
537
+ state_before is TaskState.PROOF_READY
538
+ and state_after is TaskState.AWAITING_HUMAN
539
+ ):
540
+ if isinstance(receipt, ApprovalRequestedReceipt) and _validate_final_request(
541
+ receipt,
542
+ work_order,
543
+ now,
544
+ ):
545
+ return _allowed("Manager requested final acceptance")
546
+ return _denied("INVALID_EVIDENCE", "final request evidence is invalid")
547
+
548
+ if state_after is TaskState.REJECTED:
549
+ if isinstance(receipt, TerminationDecisionReceipt) and _validate_termination(
550
+ receipt,
551
+ work_order,
552
+ state_before,
553
+ now,
554
+ ):
555
+ return _allowed("Maintainer terminated WorkOrder")
556
+ return _denied("INVALID_EVIDENCE", "termination evidence is invalid")
557
+
558
+ if state_after is TaskState.FROZEN:
559
+ if isinstance(receipt, SystemEventReceipt) and _validate_contract_expiry(
560
+ receipt,
561
+ work_order,
562
+ now,
563
+ ):
564
+ return _allowed("contract expiry observed")
565
+ return _denied("INVALID_EVIDENCE", "freeze evidence is invalid")
566
+
567
+ return _denied("INVALID_EVIDENCE", "transition evidence is unsupported")
568
+
569
+
570
+ def apply_state_transition(
571
+ *,
572
+ work_order: WorkOrder,
573
+ state_before: TaskState,
574
+ state_after: TaskState,
575
+ trigger_receipt: ActionReceipt | None,
576
+ acceptance_receipt: AcceptanceReceipt | None,
577
+ public_keys: Mapping[str, Ed25519PublicKey],
578
+ now: datetime,
579
+ ) -> TransitionDecision:
580
+ if state_before is state_after:
581
+ return _denied(
582
+ "STATE_CHANGE_REQUIRED",
583
+ "same-state receipts use append_receipt",
584
+ )
585
+ return validate_transition_evidence(
586
+ work_order,
587
+ state_before,
588
+ state_after,
589
+ trigger_receipt,
590
+ acceptance_receipt,
591
+ public_keys,
592
+ now,
593
+ )
594
+
595
+
596
+ def append_receipt(
597
+ *,
598
+ work_order: WorkOrder,
599
+ state: TaskState,
600
+ receipt: ActionReceipt,
601
+ public_keys: Mapping[str, Ed25519PublicKey],
602
+ now: datetime,
603
+ allow_independent_verifier_rerun: bool = False,
604
+ ) -> TransitionDecision:
605
+ if not _work_order_is_trusted(work_order, public_keys):
606
+ return _denied("INVALID_EVIDENCE", "WorkOrder trust failed")
607
+ parsed = _validate_action_receipt(receipt, work_order, public_keys, now)
608
+ if parsed is None:
609
+ return _denied("INVALID_EVIDENCE", "receipt integrity failed")
610
+ if parsed.state_before != state.value or parsed.state_after != state.value:
611
+ return _denied(
612
+ "TRANSITION_REQUIRED",
613
+ "state-changing receipts use apply_state_transition",
614
+ )
615
+ if state in {TaskState.ACCEPTED, TaskState.REJECTED, TaskState.FROZEN}:
616
+ return _denied("STATE_DENIED", "state does not accept same-state receipts")
617
+ if parsed.policy_decision == "deny":
618
+ return _allowed("authenticated same-state denial appended")
619
+ role = _actor_role(parsed, work_order)
620
+ if isinstance(
621
+ parsed,
622
+ (
623
+ GrantIssuedReceipt,
624
+ GrantConsumedReceipt,
625
+ GrantRevokedReceipt,
626
+ ToolCallReceipt,
627
+ ApprovalRequestedReceipt,
628
+ RollbackReceipt,
629
+ ),
630
+ ) and not _agent_direct_call_is_authorized(parsed, work_order):
631
+ return _denied(
632
+ "STATE_DENIED",
633
+ "Agent direct call is outside the frozen role matrix",
634
+ )
635
+ if (
636
+ isinstance(parsed, ToolCallReceipt)
637
+ and parsed.tool_name == "owp.compose_proof"
638
+ and role == "Manager"
639
+ and state
640
+ in {TaskState.LOCALLY_VERIFIED, TaskState.EVIDENCE_INCOMPLETE}
641
+ ):
642
+ return _denied(
643
+ "COMPOSITION_VALIDATOR_UNAVAILABLE",
644
+ "same-state composition requires canonical receipt history",
645
+ )
646
+ if state is TaskState.EVIDENCE_INCOMPLETE:
647
+ if (
648
+ isinstance(parsed, ToolCallReceipt)
649
+ and parsed.tool_name == "owp.run_tests"
650
+ and role == "Verifier"
651
+ ):
652
+ # The single-receipt API fails closed because it has no canonical
653
+ # episode history. Full-history replay paths pass the explicit
654
+ # allowance flag after their own causal/policy verification.
655
+ if allow_independent_verifier_rerun:
656
+ return _allowed(
657
+ "independent Verifier rerun in evidence-incomplete"
658
+ )
659
+ return _denied(
660
+ "TRANSITION_CONTEXT_UNAVAILABLE",
661
+ "same-state evidence requires unavailable episode history",
662
+ )
663
+ return _denied(
664
+ "STATE_DENIED",
665
+ "evidence-incomplete only accepts an independent Verifier rerun",
666
+ )
667
+ if state is TaskState.NEEDS_REWORK and isinstance(parsed, RollbackReceipt):
668
+ return _denied(
669
+ "TRANSITION_CONTEXT_UNAVAILABLE",
670
+ "same-state evidence requires unavailable episode history",
671
+ )
672
+ if state in {TaskState.PROOF_READY, TaskState.AWAITING_HUMAN}:
673
+ return _denied("STATE_DENIED", "human-gate tail forbids successful append")
674
+ if isinstance(parsed, GrantConsumedReceipt):
675
+ if state is TaskState.NEEDS_REWORK and role == "Manager":
676
+ return _denied(
677
+ "TRANSITION_REQUIRED",
678
+ "retry charge must select the retrying state transition",
679
+ )
680
+ return _denied(
681
+ "STATE_DENIED",
682
+ "retry charge is not a same-state audit event",
683
+ )
684
+ grant_audit_states = {
685
+ TaskState.RUNNING,
686
+ TaskState.RETRYING,
687
+ TaskState.NEEDS_REWORK,
688
+ }
689
+ if isinstance(parsed, GrantIssuedReceipt):
690
+ if (
691
+ parsed.parent_grant_id is not None
692
+ and role == "Manager"
693
+ and state in grant_audit_states
694
+ ):
695
+ return _allowed("child Grant issuance appended")
696
+ return _denied(
697
+ "STATE_DENIED",
698
+ "child Grant issuance is outside its role or state window",
699
+ )
700
+ if isinstance(parsed, GrantRevokedReceipt):
701
+ if role == "Manager" and state in grant_audit_states:
702
+ return _allowed("Grant revocation appended")
703
+ return _denied(
704
+ "STATE_DENIED",
705
+ "Grant revocation is outside its role or state window",
706
+ )
707
+ if isinstance(parsed, ToolCallReceipt):
708
+ if state not in {TaskState.RUNNING, TaskState.RETRYING}:
709
+ return _denied(
710
+ "STATE_DENIED",
711
+ "tool results are outside their state window",
712
+ )
713
+ if (
714
+ parsed.tool_name == "owp.run_tests"
715
+ and role == "Verifier"
716
+ and parsed.execution_status == "succeeded"
717
+ ):
718
+ return _denied(
719
+ "TRANSITION_REQUIRED",
720
+ "normal Verifier result must select a state transition",
721
+ )
722
+ if parsed.tool_name in {
723
+ "owp.repo_read",
724
+ "owp.apply_patch",
725
+ "owp.run_tests",
726
+ "owp.create_pr_proposal",
727
+ }:
728
+ return _allowed("ordinary tool result appended")
729
+ return _denied(
730
+ "STATE_DENIED",
731
+ "tool result is outside its role or tool window",
732
+ )
733
+ if (
734
+ isinstance(parsed, ApprovalRequestedReceipt)
735
+ and parsed.request_kind == "high_risk_action"
736
+ ):
737
+ if (
738
+ state in {TaskState.RUNNING, TaskState.RETRYING}
739
+ and role == "Manager"
740
+ ):
741
+ return _allowed("high-risk approval request appended")
742
+ return _denied(
743
+ "STATE_DENIED",
744
+ "approval request is outside its role or state window",
745
+ )
746
+ if isinstance(parsed, ApprovalDecisionReceipt):
747
+ if (
748
+ state in {TaskState.RUNNING, TaskState.RETRYING}
749
+ and role == "Maintainer"
750
+ ):
751
+ return _allowed("approval decision appended")
752
+ return _denied(
753
+ "STATE_DENIED",
754
+ "approval decision is outside its role or state window",
755
+ )
756
+ return _denied("STATE_DENIED", "event is not a valid same-state append")
757
+
758
+
759
+ __all__ = [
760
+ "ALLOWED_TRANSITIONS",
761
+ "TaskState",
762
+ "append_receipt",
763
+ "apply_state_transition",
764
+ "validate_transition_evidence",
765
+ ]