q-runtime-core 0.9.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.
qruntime/cli.py ADDED
@@ -0,0 +1,594 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from qruntime.loader import load_workflow
8
+ from qruntime.rails.x402 import (
9
+ X402DecodeError,
10
+ as_jsonable,
11
+ normalize_payment_required,
12
+ parse_payment_payload,
13
+ parse_payment_required,
14
+ parse_settlement_response,
15
+ )
16
+ from qruntime.rails.x402_verify import trace_result_json, trace_result_text, verify_x402_trace
17
+ from qruntime.rails.x402_capture import (
18
+ X402CaptureError,
19
+ capture_to_file,
20
+ compare_capture_quotes,
21
+ )
22
+ from qruntime.rails.x402_external import (
23
+ ExternalProbeError,
24
+ audit_x402_list_directory,
25
+ probe_external,
26
+ write_external_probe,
27
+ )
28
+ from qruntime.rails.x402_external_suite import run_external_suite
29
+ from qruntime.verify.explorer import verify
30
+ from qruntime.verify.report import to_json, to_text
31
+ from qruntime.rails.x402_fullcycle import (
32
+ create_testnet_wallet,
33
+ get_persistent_payment_intent,
34
+ list_persistent_payment_intents,
35
+ reconcile_testnet_transaction,
36
+ fullcycle_result_dict,
37
+ fullcycle_result_text,
38
+ run_local_testnet_payment,
39
+ verify_fullcycle,
40
+ )
41
+
42
+
43
+ def _read_x402_input(path: str):
44
+ raw = Path(path).read_text(encoding="utf-8").strip()
45
+ try:
46
+ value = json.loads(raw)
47
+ except json.JSONDecodeError:
48
+ value = raw
49
+ return value
50
+
51
+
52
+ def main(argv: list[str] | None = None) -> int:
53
+ parser = argparse.ArgumentParser(prog="qruntime")
54
+ sub = parser.add_subparsers(dest="command", required=True)
55
+
56
+ verify_p = sub.add_parser("verify", help="verify economic invariants for a QFlow YAML file")
57
+ verify_p.add_argument("path")
58
+ verify_p.add_argument("--json", action="store_true", dest="as_json")
59
+
60
+ x402_p = sub.add_parser("x402", help="decode and verify x402 v2 economic traces")
61
+ xsub = x402_p.add_subparsers(dest="x402_command", required=True)
62
+
63
+ inspect = xsub.add_parser("inspect", help="decode one x402 v2 wire object")
64
+ inspect.add_argument("type", choices=["required", "signature", "response"])
65
+ inspect.add_argument("path")
66
+
67
+ normalize = xsub.add_parser("normalize", help="map PAYMENT-REQUIRED to a Q economic event")
68
+ normalize.add_argument("path")
69
+ normalize.add_argument("--accept-index", type=int, default=0)
70
+
71
+ trace = xsub.add_parser("verify-trace", help="verify a recorded/synthetic x402 event trace")
72
+ trace.add_argument("path")
73
+ trace.add_argument("--json", action="store_true", dest="as_json")
74
+
75
+ capture = xsub.add_parser("capture-402", help="capture a live HTTP 402/PAYMENT-REQUIRED without paying")
76
+ capture.add_argument("url")
77
+ capture.add_argument("--out", required=True)
78
+ capture.add_argument("--timeout", type=float, default=10.0)
79
+
80
+ compare = xsub.add_parser("compare-quotes", help="compare two captured PAYMENT-REQUIRED quotes")
81
+ compare.add_argument("old_capture")
82
+ compare.add_argument("new_capture")
83
+ compare.add_argument("--accept-index", type=int, default=0)
84
+ compare.add_argument("--json", action="store_true", dest="as_json")
85
+
86
+ external = xsub.add_parser("external-audit", help="audit an independent live x402 endpoint without paying")
87
+ external.add_argument("url")
88
+ external.add_argument("--method", choices=["GET", "POST", "HEAD"], default="GET")
89
+ external.add_argument("--json-body", default=None, help="inline JSON body for POST probes")
90
+ external.add_argument("--out", default=None)
91
+ external.add_argument("--timeout", type=float, default=10.0)
92
+ external.add_argument("--mode", choices=["strict", "compatibility"], default="strict")
93
+ external.add_argument("--json", action="store_true", dest="as_json")
94
+
95
+ ext_suite = xsub.add_parser("external-suite", help="cross-implementation checks against independent grug402 endpoints")
96
+ ext_suite.add_argument("--timeout", type=float, default=10.0)
97
+ ext_suite.add_argument("--out", default=None)
98
+ ext_suite.add_argument("--mode", choices=["strict", "compatibility"], default="strict")
99
+ ext_suite.add_argument("--json", action="store_true", dest="as_json")
100
+
101
+ directory = xsub.add_parser("directory-audit", help="safely audit GET endpoints discovered through x402 List")
102
+ directory.add_argument("--limit", type=int, default=10)
103
+ directory.add_argument("--timeout", type=float, default=10.0)
104
+ directory.add_argument("--mode", choices=["strict", "compatibility"], default="compatibility")
105
+ directory.add_argument("--out", required=True)
106
+
107
+ serve = xsub.add_parser("serve-official-demo", help="run a Base-Sepolia demo using the official x402 Python SDK")
108
+ serve.add_argument("--pay-to", required=True, help="public EVM test-wallet address; private key is never required")
109
+ serve.add_argument("--price-usd", default="0.01")
110
+ serve.add_argument("--host", default="127.0.0.1")
111
+ serve.add_argument("--port", type=int, default=4021)
112
+ serve.add_argument("--facilitator-url", default="https://x402.org/facilitator")
113
+
114
+ fullcycle = xsub.add_parser("verify-fullcycle", help="verify x402 v2 authorization-flow invariants")
115
+ fullcycle.add_argument("path")
116
+ fullcycle.add_argument("--json", action="store_true", dest="as_json")
117
+
118
+ wallet = xsub.add_parser("create-testnet-wallet", help="create a disposable Base-Sepolia EVM wallet file")
119
+ wallet.add_argument("--out", required=True)
120
+
121
+ live = xsub.add_parser("live-testnet", help="run one capped full-cycle payment against the local Base-Sepolia demo")
122
+ live.add_argument("url", nargs="?", default="http://127.0.0.1:4021/weather")
123
+ live.add_argument("--max-amount-atomic", required=True, type=int)
124
+ live.add_argument(
125
+ "--intent-id",
126
+ default=None,
127
+ help="stable logical operation id; reuse only when retrying the same payment intent",
128
+ )
129
+ live.add_argument("--out", required=True)
130
+ live.add_argument("--key-env", default="Q_RUNTIME_TESTNET_PRIVATE_KEY")
131
+ live.add_argument("--timeout", type=float, default=30.0)
132
+
133
+ reconcile = xsub.add_parser(
134
+ "reconcile-testnet",
135
+ help="read-only reconcile an already-broadcast Base-Sepolia transaction",
136
+ )
137
+ reconcile.add_argument("transaction")
138
+ reconcile.add_argument("--out", required=True)
139
+ reconcile.add_argument("--timeout", type=float, default=15.0)
140
+ reconcile.add_argument("--json", action="store_true", dest="as_json")
141
+
142
+ intents = xsub.add_parser(
143
+ "intents",
144
+ help="list persisted x402 payment intents",
145
+ )
146
+ intents.add_argument(
147
+ "--json",
148
+ action="store_true",
149
+ dest="as_json",
150
+ )
151
+
152
+ intent_view = intents.add_mutually_exclusive_group()
153
+
154
+ intent_view.add_argument(
155
+ "--state",
156
+ choices=[
157
+ "pending",
158
+ "confirmed",
159
+ "failed",
160
+ ],
161
+ default=None,
162
+ help="filter intents by economic state",
163
+ )
164
+
165
+ intent_view.add_argument(
166
+ "--summary",
167
+ action="store_true",
168
+ help="show aggregate intent-state counts",
169
+ )
170
+
171
+ intent = xsub.add_parser(
172
+ "intent",
173
+ help="inspect one persisted x402 payment intent",
174
+ )
175
+ intent.add_argument("intent_id")
176
+ intent.add_argument(
177
+ "--json",
178
+ action="store_true",
179
+ dest="as_json",
180
+ )
181
+
182
+ args = parser.parse_args(argv)
183
+
184
+ if args.command == "verify":
185
+ spec = load_workflow(args.path)
186
+ result = verify(spec)
187
+ print(to_json(result) if args.as_json else to_text(result))
188
+ return 0 if result.passed else 2
189
+
190
+ if args.command == "x402":
191
+ if args.x402_command == "inspect":
192
+ value = _read_x402_input(args.path)
193
+ if args.type == "required":
194
+ parsed = parse_payment_required(value)
195
+ elif args.type == "signature":
196
+ parsed = parse_payment_payload(value)
197
+ else:
198
+ parsed = parse_settlement_response(value)
199
+ print(json.dumps(as_jsonable(parsed), indent=2, ensure_ascii=False))
200
+ return 0
201
+
202
+ if args.x402_command == "normalize":
203
+ value = _read_x402_input(args.path)
204
+ event = normalize_payment_required(value, args.accept_index)
205
+ print(json.dumps(as_jsonable(event), indent=2, ensure_ascii=False))
206
+ return 0
207
+
208
+ if args.x402_command == "verify-trace":
209
+ result = verify_x402_trace(args.path)
210
+ print(trace_result_json(result) if args.as_json else trace_result_text(result))
211
+ return 0 if result.passed else 2
212
+
213
+ if args.x402_command == "capture-402":
214
+ try:
215
+ capture_result = capture_to_file(args.url, args.out, timeout=args.timeout)
216
+ except X402CaptureError as exc:
217
+ print(f"CAPTURE FAIL — {exc}")
218
+ return 2
219
+ req = capture_result.payment_required.selected(0)
220
+ print("CAPTURED — LIVE X402 PAYMENT-REQUIRED")
221
+ print(f"HTTP status: {capture_result.status_code}")
222
+ print(f"resource: {capture_result.payment_required.resource_url}")
223
+ print(f"scheme/network: {req.scheme} / {req.network}")
224
+ print(f"amount atomic: {req.amount_atomic}")
225
+ print(f"quote fingerprint: {req.fingerprint()}")
226
+ print(f"trace: {args.out}")
227
+ return 0
228
+
229
+ if args.x402_command == "compare-quotes":
230
+ try:
231
+ result = compare_capture_quotes(
232
+ args.old_capture, args.new_capture, accept_index=args.accept_index
233
+ )
234
+ except (X402CaptureError, ValueError, json.JSONDecodeError) as exc:
235
+ print(f"COMPARE FAIL — {exc}")
236
+ return 2
237
+ if args.as_json:
238
+ print(json.dumps(result, indent=2, ensure_ascii=False))
239
+ elif result["stable"]:
240
+ print("PASS — PaymentRequirementStableForIntent")
241
+ print("live quote fingerprint unchanged")
242
+ else:
243
+ print("FAIL — LIVE_X402_QUOTE_DRIFT")
244
+ print(f"invariant: {result['invariant']}")
245
+ if not result["same_resource"]:
246
+ print(f"resource: {result['old_resource']} -> {result['new_resource']}")
247
+ for field, change in result["changes"].items():
248
+ print(f"{field}: {change['before']} -> {change['after']}")
249
+ return 0 if result["stable"] else 2
250
+
251
+ if args.x402_command == "external-audit":
252
+ try:
253
+ body = None if args.json_body is None else json.loads(args.json_body)
254
+ result = probe_external(
255
+ args.url,
256
+ method=args.method,
257
+ json_body=body,
258
+ timeout=args.timeout,
259
+ require_public_https=True,
260
+ mode=args.mode,
261
+ )
262
+ except (ExternalProbeError, json.JSONDecodeError) as exc:
263
+ print(f"EXTERNAL AUDIT FAIL — {exc}")
264
+ return 2
265
+ if args.out:
266
+ write_external_probe(result, args.out)
267
+ data = result.as_dict()
268
+ if args.as_json:
269
+ print(json.dumps(data, indent=2, ensure_ascii=False))
270
+ else:
271
+ if result.classification == "legacy_x402_v1":
272
+ print("FAIL — LEGACY_X402_V1" if args.mode == "strict" else "LEGACY — X402_V1_DETECTED")
273
+ else:
274
+ print("PASS — EXTERNAL X402 AUDIT" if result.passed else "FAIL — EXTERNAL X402 AUDIT")
275
+ print(f"mode: {args.mode}")
276
+ print(f"HTTP: {result.status_code} {result.method} {result.final_url}")
277
+ print(f"challenge source: {result.challenge_source or 'none'}")
278
+ if result.payment_required is not None:
279
+ print(f"resource: {result.payment_required.resource_url}")
280
+ print(f"accepts: {len(result.payment_required.accepts)}")
281
+ first = result.payment_required.accepts[0]
282
+ print(f"first requirement: {first.scheme} / {first.network} / {first.amount_atomic}")
283
+ print(f"fingerprint: {first.fingerprint()}")
284
+ for finding in result.findings:
285
+ print(f"{finding.severity.upper()} {finding.invariant}: {finding.reason}")
286
+ if args.out:
287
+ print(f"report: {args.out}")
288
+ return 0 if result.passed else 2
289
+
290
+ if args.x402_command == "external-suite":
291
+ result = run_external_suite(timeout=args.timeout, mode=args.mode)
292
+ if args.out:
293
+ Path(args.out).parent.mkdir(parents=True, exist_ok=True)
294
+ Path(args.out).write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
295
+ if args.as_json:
296
+ print(json.dumps(result, indent=2, ensure_ascii=False))
297
+ else:
298
+ print("PASS — EXTERNAL SUITE" if result["suite_passed"] else "FAIL — EXTERNAL SUITE")
299
+ for case in result["cases"]:
300
+ mark = "PASS" if case.get("matched_expectation") else "FAIL"
301
+ print(f"{mark} {case['name']}: {case['url']}")
302
+ audit = case.get("audit")
303
+ if audit:
304
+ for f in audit.get("findings", []):
305
+ if f.get("severity") == "error":
306
+ print(f" {f['invariant']}: {f['reason']}")
307
+ elif case.get("error"):
308
+ print(f" {case['error']}")
309
+ if args.out:
310
+ print(f"report: {args.out}")
311
+ return 0 if result["suite_passed"] else 2
312
+
313
+ if args.x402_command == "directory-audit":
314
+ try:
315
+ result = audit_x402_list_directory(limit=args.limit, timeout=args.timeout, mode=args.mode)
316
+ except ExternalProbeError as exc:
317
+ print(f"DIRECTORY AUDIT FAIL — {exc}")
318
+ return 2
319
+ Path(args.out).parent.mkdir(parents=True, exist_ok=True)
320
+ Path(args.out).write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
321
+ print("DIRECTORY AUDIT COMPLETE")
322
+ print(f"services audited: {result['services_audited']}")
323
+ print(f"errors: {result['errors']}")
324
+ print(f"warnings: {result['warnings']}")
325
+ print(f"legacy x402 v1: {result.get('legacy_x402_v1', 0)}")
326
+ print(f"mode: {args.mode}")
327
+ print(f"report: {args.out}")
328
+ return 0 if result["errors"] == 0 and result["services_audited"] > 0 else 2
329
+
330
+ if args.x402_command == "verify-fullcycle":
331
+ result = verify_fullcycle(args.path)
332
+ print(json.dumps(fullcycle_result_dict(result), indent=2, ensure_ascii=False) if args.as_json else fullcycle_result_text(result))
333
+ return 0 if result.passed else 2
334
+
335
+ if args.x402_command == "create-testnet-wallet":
336
+ try:
337
+ result = create_testnet_wallet(args.out)
338
+ except (RuntimeError, FileExistsError, ValueError) as exc:
339
+ print(f"WALLET FAIL — {exc}")
340
+ return 2
341
+ print("CREATED — DISPOSABLE TESTNET WALLET")
342
+ print("network: eip155:84532 (Base Sepolia)")
343
+ print(f"address: {result['address']}")
344
+ print(f"secret file: {result['path']}")
345
+ print("WARNING: TESTNET ONLY. Never reuse this key/address for mainnet assets.")
346
+ return 0
347
+
348
+ if args.x402_command == "live-testnet":
349
+ try:
350
+ result = run_local_testnet_payment(
351
+ args.url,
352
+ max_amount_atomic=args.max_amount_atomic,
353
+ out=args.out,
354
+ intent_id=args.intent_id,
355
+ key_env=args.key_env,
356
+ timeout=args.timeout,
357
+ )
358
+ except (RuntimeError, ValueError, X402DecodeError) as exc:
359
+ print(f"LIVE TESTNET FAIL — {exc}")
360
+ return 2
361
+ print("PASS — LIVE X402 TESTNET SETTLEMENT")
362
+ print(f"payer: {result['payer']}")
363
+ print(f"resource: {result['request_url']}")
364
+ print(f"amount atomic: {result['preflight']['amount_atomic']}")
365
+ print(f"network: {result['settlement']['network']}")
366
+ print(f"transaction: {result['settlement']['transaction']}")
367
+ print(f"report: {args.out}")
368
+ return 0
369
+
370
+ if args.x402_command == "intents":
371
+ try:
372
+ entries = list_persistent_payment_intents()
373
+ except RuntimeError as exc:
374
+ print(f"INTENT STORE FAIL ? {exc}")
375
+ return 2
376
+
377
+ if args.summary:
378
+ states = [
379
+ str(entry.get("state", ""))
380
+ for entry in entries
381
+ ]
382
+
383
+ summary = {
384
+ "total": len(entries),
385
+ "pending": states.count("pending"),
386
+ "confirmed": states.count(
387
+ "resolved_confirmed"
388
+ ),
389
+ "failed": states.count(
390
+ "resolved_failed"
391
+ ),
392
+ }
393
+
394
+ summary["other"] = (
395
+ summary["total"]
396
+ - summary["pending"]
397
+ - summary["confirmed"]
398
+ - summary["failed"]
399
+ )
400
+
401
+ if args.as_json:
402
+ print(
403
+ json.dumps(
404
+ summary,
405
+ indent=2,
406
+ ensure_ascii=False,
407
+ )
408
+ )
409
+ else:
410
+ print("X402 INTENT SUMMARY")
411
+ print(f"total: {summary['total']}")
412
+ print(
413
+ f"pending: "
414
+ f"{summary['pending']}"
415
+ )
416
+ print(
417
+ f"confirmed: "
418
+ f"{summary['confirmed']}"
419
+ )
420
+ print(
421
+ f"failed: "
422
+ f"{summary['failed']}"
423
+ )
424
+ print(
425
+ f"other: "
426
+ f"{summary['other']}"
427
+ )
428
+
429
+ return 0
430
+
431
+ if args.state:
432
+ state_map = {
433
+ "pending": "pending",
434
+ "confirmed": "resolved_confirmed",
435
+ "failed": "resolved_failed",
436
+ }
437
+
438
+ wanted = state_map[args.state]
439
+
440
+ entries = tuple(
441
+ entry
442
+ for entry in entries
443
+ if str(
444
+ entry.get("state", "")
445
+ ) == wanted
446
+ )
447
+
448
+ if args.as_json:
449
+ print(
450
+ json.dumps(
451
+ list(entries),
452
+ indent=2,
453
+ ensure_ascii=False,
454
+ )
455
+ )
456
+ return 0
457
+
458
+ if not entries:
459
+ print("NO PERSISTED X402 INTENTS")
460
+ return 0
461
+
462
+ print(f"PERSISTED X402 INTENTS: {len(entries)}")
463
+
464
+ for entry in entries:
465
+ print(
466
+ f"{entry.get('state', 'unknown')} "
467
+ f"{entry.get('intent_id', '')}"
468
+ )
469
+ print(
470
+ f" resource: "
471
+ f"{entry.get('resource', '')}"
472
+ )
473
+ print(
474
+ f" network: "
475
+ f"{entry.get('network', '')}"
476
+ )
477
+ print(
478
+ f" transaction: "
479
+ f"{entry.get('transaction', '')}"
480
+ )
481
+ print(
482
+ f" updated_at: "
483
+ f"{entry.get('updated_at', '')}"
484
+ )
485
+
486
+ return 0
487
+
488
+ if args.x402_command == "intent":
489
+ try:
490
+ entry = get_persistent_payment_intent(
491
+ args.intent_id
492
+ )
493
+ except (RuntimeError, ValueError) as exc:
494
+ print(f"INTENT LOOKUP FAIL ? {exc}")
495
+ return 2
496
+
497
+ if entry is None:
498
+ print(
499
+ f"INTENT NOT FOUND: "
500
+ f"{args.intent_id}"
501
+ )
502
+ return 2
503
+
504
+ if args.as_json:
505
+ print(
506
+ json.dumps(
507
+ entry,
508
+ indent=2,
509
+ ensure_ascii=False,
510
+ )
511
+ )
512
+ return 0
513
+
514
+ print("X402 INTENT")
515
+ print(f"intent_id: {entry.get('intent_id', '')}")
516
+ print(f"state: {entry.get('state', '')}")
517
+ print(f"resource: {entry.get('resource', '')}")
518
+ print(f"network: {entry.get('network', '')}")
519
+ print(
520
+ f"transaction: "
521
+ f"{entry.get('transaction', '')}"
522
+ )
523
+ print(
524
+ f"reconciliation: "
525
+ f"{entry.get('reconciliation_outcome', '')}"
526
+ )
527
+ print(
528
+ f"first_seen_at: "
529
+ f"{entry.get('first_seen_at', '')}"
530
+ )
531
+ print(
532
+ f"updated_at: "
533
+ f"{entry.get('updated_at', '')}"
534
+ )
535
+
536
+ return 0
537
+
538
+ if args.x402_command == "reconcile-testnet":
539
+ try:
540
+ result = reconcile_testnet_transaction(
541
+ args.transaction,
542
+ out=args.out,
543
+ timeout=args.timeout,
544
+ )
545
+ except (RuntimeError, ValueError) as exc:
546
+ print(f"RECONCILIATION FAIL ? {exc}")
547
+ return 2
548
+
549
+ if args.as_json:
550
+ print(json.dumps(result, indent=2, ensure_ascii=False))
551
+ else:
552
+ outcome = result["outcome"]
553
+ if outcome == "confirmed":
554
+ print("RECONCILIATION CONFIRMED")
555
+ elif outcome == "failed":
556
+ print("RECONCILIATION FAILED")
557
+ else:
558
+ print("RECONCILIATION PENDING")
559
+
560
+ print(f"network: {result['network']}")
561
+ print(f"transaction: {result['transaction']}")
562
+ print(f"outcome: {result['outcome']}")
563
+ print(f"terminal: {result['terminal']}")
564
+ print(f"status: {result['chain']['status']}")
565
+ print(f"block: {result['chain']['block_number']}")
566
+ print(f"confirmations: {result['chain']['confirmations']}")
567
+ print(f"method: {result['chain']['method']}")
568
+ print(f"report: {args.out}")
569
+
570
+ # confirmed/failed are terminal reconciliation outcomes.
571
+ # pending remains fail-closed for automation/retry decisions.
572
+ return 0 if result["terminal"] else 2
573
+
574
+ if args.x402_command == "serve-official-demo":
575
+ from qruntime.integrations.official_x402_demo import serve as serve_official_x402
576
+
577
+ try:
578
+ serve_official_x402(
579
+ pay_to=args.pay_to,
580
+ price_usd=args.price_usd,
581
+ host=args.host,
582
+ port=args.port,
583
+ facilitator_url=args.facilitator_url,
584
+ )
585
+ except (RuntimeError, ValueError) as exc:
586
+ print(f"SERVER FAIL — {exc}")
587
+ return 2
588
+ return 0
589
+
590
+ return 1
591
+
592
+
593
+ if __name__ == "__main__":
594
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from dataclasses import dataclass
5
+
6
+ from .model import RuntimeState, TaskStatus, WorkflowSpec, unresolved_required_cost
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Violation:
11
+ invariant: str
12
+ message: str
13
+
14
+
15
+ def check_invariants(spec: WorkflowSpec, state: RuntimeState) -> list[Violation]:
16
+ violations: list[Violation] = []
17
+
18
+ if state.spent + state.reserved > spec.budget:
19
+ violations.append(Violation(
20
+ "NoBudgetOversubscription",
21
+ f"spent({state.spent}) + reserved({state.reserved}) > budget({spec.budget})",
22
+ ))
23
+
24
+ duplicates = [intent for intent, n in Counter(state.settled_intents).items() if n > 1]
25
+ if duplicates:
26
+ violations.append(Violation(
27
+ "NoDuplicateEconomicEffect",
28
+ f"economic intent settled more than once: {', '.join(sorted(duplicates))}",
29
+ ))
30
+
31
+ if state.ambiguous_intents:
32
+ violations.append(Violation(
33
+ "PaymentStateReconciledBeforeRetry",
34
+ "retry attempted while prior payment outcome remained ambiguous: "
35
+ + ", ".join(sorted(state.ambiguous_intents)),
36
+ ))
37
+
38
+ if state.evidence_bypasses:
39
+ violations.append(Violation(
40
+ "EvidenceBeforeDependentSpend",
41
+ "economic reservation occurred without required evidence: "
42
+ + ", ".join(sorted(state.evidence_bypasses)),
43
+ ))
44
+
45
+ if state.unauthorized_effects:
46
+ violations.append(Violation(
47
+ "AuthorizedTransitionsOnly",
48
+ "economic effect attempted by actor outside delegated authority: "
49
+ + ", ".join(sorted(state.unauthorized_effects)),
50
+ ))
51
+
52
+ required_remaining = unresolved_required_cost(spec, state)
53
+ if state.spent + state.reserved + required_remaining > spec.budget:
54
+ violations.append(Violation(
55
+ "CompletionReservePreserved",
56
+ "current commitments leave insufficient authorized budget for unresolved required steps",
57
+ ))
58
+
59
+ if state.task_status == TaskStatus.COMPLETED:
60
+ missing = [s.id for s in spec.steps if s.required and state.status(s.id).value != "DONE"]
61
+ if missing:
62
+ violations.append(Violation(
63
+ "TerminalStateHonesty",
64
+ f"task completed with unresolved required steps: {', '.join(missing)}",
65
+ ))
66
+
67
+ return violations