agentflow-proof 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.
@@ -0,0 +1,646 @@
1
+ """Execution verification coverage for Agentflow v0.3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from .artifacts import append_jsonl, read_jsonl, try_read_json, utc_now
10
+ from .contracts import TRUSTED_PROVENANCE_VALUES, VERIFICATION_RUNS_SCHEMA_VERSION
11
+ from .execution import (
12
+ TERMINAL_EVENTS,
13
+ attempt_is_expired,
14
+ lease_grace_seconds,
15
+ lease_policy,
16
+ read_step_events,
17
+ read_step_state,
18
+ resolve_attempt,
19
+ )
20
+ from .git import changed_file_records
21
+ from .hunks import effective_hunk_policy, unmapped_hunks
22
+ from .receipts import (
23
+ command_receipts,
24
+ file_receipts,
25
+ path_in_effective_scope,
26
+ replay_gates,
27
+ sha256_path,
28
+ )
29
+ from .stuck import detect_stuck
30
+ from .validation import audit_drift, matches_path, validate_plan
31
+
32
+
33
+ def _step_by_id(plan: Dict[str, Any], step_id: str) -> Dict[str, Any]:
34
+ for step in plan.get("steps", []):
35
+ if isinstance(step, dict) and step.get("id") == step_id:
36
+ return step
37
+ raise ValueError(f"unknown step id: {step_id}")
38
+
39
+
40
+ def _evidence_ids(root: Path) -> set[str]:
41
+ return {
42
+ item["id"]
43
+ for item in read_jsonl(root / ".agent/evidence.jsonl")
44
+ if isinstance(item, dict) and isinstance(item.get("id"), str)
45
+ }
46
+
47
+
48
+ def _command_gate_key(gate: Dict[str, Any]) -> str:
49
+ return " ".join(gate["run"])
50
+
51
+
52
+ def _matching_command_receipt(
53
+ gate: Dict[str, Any],
54
+ receipts: List[Dict[str, Any]],
55
+ receipt_by_gate: Dict[str, Dict[str, Any]],
56
+ ) -> Optional[Dict[str, Any]]:
57
+ if isinstance(gate.get("run"), list):
58
+ for receipt in receipts:
59
+ if receipt.get("command") == gate["run"]:
60
+ return receipt
61
+ for alias in gate.get("aliases", [gate.get("gate")]):
62
+ receipt = receipt_by_gate.get(alias)
63
+ if receipt is not None:
64
+ return receipt
65
+ return None
66
+
67
+
68
+ def _gates_for_step(step: Dict[str, Any]) -> List[Dict[str, Any]]:
69
+ gates = step.get("gates")
70
+ if isinstance(gates, list) and gates:
71
+ resolved = []
72
+ validation = [
73
+ item
74
+ for item in step.get("validation", [])
75
+ if isinstance(item, str)
76
+ ]
77
+ for index, gate in enumerate(gates):
78
+ if not isinstance(gate, dict):
79
+ continue
80
+ if gate.get("kind") == "command" and isinstance(gate.get("run"), list):
81
+ aliases = [_command_gate_key(gate)]
82
+ if index < len(validation):
83
+ aliases.append(validation[index])
84
+ resolved.append(
85
+ {
86
+ "kind": "command",
87
+ "gate": aliases[0],
88
+ "aliases": aliases,
89
+ "run": gate["run"],
90
+ }
91
+ )
92
+ elif gate.get("kind") == "inspection":
93
+ resolved.append(
94
+ {
95
+ "kind": "inspection",
96
+ "evidence_id": gate.get("evidence_id"),
97
+ "describe": gate.get("describe", ""),
98
+ }
99
+ )
100
+ return resolved
101
+ return [
102
+ {"kind": "legacy", "gate": item}
103
+ for item in step.get("validation", [])
104
+ if isinstance(item, str)
105
+ ]
106
+
107
+
108
+ def _add_finding(target: Dict[str, List[Dict[str, str]]], severity: str, message: str) -> None:
109
+ target["errors" if severity == "error" else "warnings"].append(
110
+ {"severity": severity, "message": message}
111
+ )
112
+
113
+
114
+ def _receipt_timed_out(receipt: Dict[str, Any]) -> bool:
115
+ return receipt.get("decision") == "timeout" or receipt.get("timed_out") is True
116
+
117
+
118
+ def _timeout_message(gate: str, receipt: Dict[str, Any]) -> str:
119
+ timeout_seconds = receipt.get("timeout_seconds")
120
+ return f"gate {gate} timed out after {timeout_seconds} seconds"
121
+
122
+
123
+ def _proof_policy(root: Path) -> Dict[str, Any]:
124
+ contract_path = root / ".agent/execution.contract.json"
125
+ if not contract_path.exists():
126
+ return {}
127
+ try:
128
+ contract, _ = try_read_json(contract_path)
129
+ except OSError:
130
+ return {}
131
+ if contract is None:
132
+ return {}
133
+ policy = contract.get("proof_policy", {})
134
+ return policy if isinstance(policy, dict) else {}
135
+
136
+
137
+ def _verification_run_id(root: Path) -> str:
138
+ path = root / ".agent/verification-runs.jsonl"
139
+ return f"VR{len(read_jsonl(path)) + 1}"
140
+
141
+
142
+ def _append_verification(
143
+ root: Path,
144
+ scope: str,
145
+ step_id: Optional[str],
146
+ attempt_id: Optional[str],
147
+ strict: bool,
148
+ replay: bool,
149
+ result: Dict[str, Any],
150
+ ) -> None:
151
+ entry: Dict[str, Any] = {
152
+ "schema_version": VERIFICATION_RUNS_SCHEMA_VERSION,
153
+ "id": _verification_run_id(root),
154
+ "scope": scope,
155
+ "strict": strict,
156
+ "replay": replay,
157
+ "status": result["status"],
158
+ "findings": [*result["errors"], *result["warnings"]],
159
+ "recorded_at": utc_now(),
160
+ }
161
+ if step_id is not None:
162
+ entry["step_id"] = step_id
163
+ if attempt_id is not None:
164
+ entry["attempt_id"] = attempt_id
165
+ append_jsonl(root / ".agent/verification-runs.jsonl", entry)
166
+
167
+
168
+ def verify_step(
169
+ root: Path,
170
+ plan: Dict[str, Any],
171
+ step_id: str,
172
+ attempt_id: Optional[str],
173
+ strict: bool = False,
174
+ replay: bool = False,
175
+ record: bool = True,
176
+ include_gates: bool = False,
177
+ ) -> Dict[str, Any]:
178
+ step = _step_by_id(plan, step_id)
179
+ resolved_attempt = resolve_attempt(root, step_id, attempt_id)
180
+ result: Dict[str, Any] = {
181
+ "status": "passed",
182
+ "errors": [],
183
+ "warnings": [],
184
+ }
185
+ gate_results: List[Dict[str, Any]] = []
186
+ if include_gates:
187
+ result["gates"] = gate_results
188
+ policy = _proof_policy(root)
189
+ effective_strict = strict or bool(policy.get("strict_by_default"))
190
+ require_command_receipts = effective_strict or bool(
191
+ policy.get("require_command_receipts_for_validation")
192
+ )
193
+ require_inspection_evidence = effective_strict or bool(
194
+ policy.get("require_evidence_for_inspection_gates")
195
+ )
196
+ require_trusted_receipts = effective_strict or bool(
197
+ policy.get("require_managed_receipts_for_validation")
198
+ )
199
+ evidence = _evidence_ids(root)
200
+ receipts = [
201
+ receipt
202
+ for receipt in command_receipts(root)
203
+ if receipt.get("step_id") == step_id and receipt.get("attempt_id") == resolved_attempt
204
+ ]
205
+ files = [
206
+ receipt
207
+ for receipt in file_receipts(root)
208
+ if receipt.get("step_id") == step_id and receipt.get("attempt_id") == resolved_attempt
209
+ ]
210
+ receipt_by_gate = {
211
+ receipt.get("gate"): receipt
212
+ for receipt in receipts
213
+ if isinstance(receipt.get("gate"), str)
214
+ }
215
+ for gate in _gates_for_step(step):
216
+ gate_result = {
217
+ "kind": gate["kind"],
218
+ "label": gate.get("gate") or gate.get("evidence_id") or gate.get("describe", ""),
219
+ "status": "satisfied",
220
+ "receipt_id": None,
221
+ "evidence_id": gate.get("evidence_id"),
222
+ }
223
+ gate_results.append(gate_result)
224
+ if gate["kind"] == "command":
225
+ receipt = _matching_command_receipt(gate, receipts, receipt_by_gate)
226
+ if receipt is None:
227
+ gate_result["status"] = "missing"
228
+ _add_finding(
229
+ result,
230
+ "error" if require_command_receipts else "warning",
231
+ f"missing command receipt for gate: {gate['gate']}",
232
+ )
233
+ continue
234
+ gate_result["receipt_id"] = receipt.get("id")
235
+ if _receipt_timed_out(receipt):
236
+ gate_result["status"] = "failed"
237
+ _add_finding(result, "error", _timeout_message(gate["gate"], receipt))
238
+ elif receipt.get("exit_code") != 0:
239
+ gate_result["status"] = "failed"
240
+ _add_finding(
241
+ result,
242
+ "error",
243
+ f"gate {gate['gate']} recorded exit code {receipt.get('exit_code')}",
244
+ )
245
+ if require_trusted_receipts and receipt.get("provenance") not in TRUSTED_PROVENANCE_VALUES:
246
+ gate_result["status"] = "failed"
247
+ _add_finding(result, "error", f"gate {gate['gate']} uses attested provenance")
248
+ elif gate["kind"] == "inspection":
249
+ evidence_id = gate.get("evidence_id")
250
+ if evidence_id not in evidence:
251
+ gate_result["status"] = "missing"
252
+ _add_finding(result, "error", f"inspection evidence id {evidence_id} is missing")
253
+ else:
254
+ receipt = receipt_by_gate.get(gate["gate"])
255
+ if receipt:
256
+ gate_result["receipt_id"] = receipt.get("id")
257
+ if _receipt_timed_out(receipt):
258
+ gate_result["status"] = "failed"
259
+ _add_finding(result, "error", _timeout_message(gate["gate"], receipt))
260
+ elif receipt.get("exit_code") != 0:
261
+ gate_result["status"] = "failed"
262
+ _add_finding(
263
+ result,
264
+ "error",
265
+ f"gate {gate['gate']} recorded exit code {receipt.get('exit_code')}",
266
+ )
267
+ if (
268
+ require_trusted_receipts
269
+ and receipt.get("provenance") not in TRUSTED_PROVENANCE_VALUES
270
+ ):
271
+ gate_result["status"] = "failed"
272
+ _add_finding(result, "error", f"gate {gate['gate']} uses attested provenance")
273
+ elif not any(evidence_id in evidence for evidence_id in step.get("evidence_ids", [])):
274
+ gate_result["status"] = "missing"
275
+ _add_finding(
276
+ result,
277
+ "error"
278
+ if require_command_receipts or require_inspection_evidence
279
+ else "warning",
280
+ f"legacy inspection gate needs evidence or receipt: {gate['gate']}",
281
+ )
282
+ for receipt in files:
283
+ path = receipt.get("path")
284
+ if isinstance(path, str) and not path_in_effective_scope(plan, step_id, path):
285
+ _add_finding(result, "error", f"{path} is outside effective file scope for {step_id}")
286
+ if replay:
287
+ replay_result = replay_gates(root, plan, step_id=step_id, record=False)
288
+ result["errors"].extend(replay_result["errors"])
289
+ result["warnings"].extend(replay_result["warnings"])
290
+ if result["errors"]:
291
+ result["status"] = "failed"
292
+ elif result["warnings"]:
293
+ result["status"] = "warning"
294
+ if record:
295
+ _append_verification(root, "step", step_id, resolved_attempt, effective_strict, replay, result)
296
+ return result
297
+
298
+
299
+ def build_execution_coverage(
300
+ root: Path,
301
+ plan: Dict[str, Any],
302
+ strict: bool = False,
303
+ *,
304
+ now: Optional[datetime] = None,
305
+ ) -> Dict[str, Any]:
306
+ state = read_step_state(root)
307
+ coverage: Dict[str, Any] = {
308
+ "steps": {},
309
+ "attempts": len(state["attempts"]),
310
+ "completed_steps": [],
311
+ "failed_attempts": [],
312
+ }
313
+ # #20 lease diagnostics. abandoned_attempts is a deterministic ledger fact.
314
+ # expired_leases / no_deadline_open_attempts are evaluated against `now` at
315
+ # build time and may legitimately differ before vs. after an expiry -- they
316
+ # are proof facts at build time, not reproducible-across-time invariants.
317
+ coverage["expired_leases"] = []
318
+ coverage["no_deadline_open_attempts"] = []
319
+ coverage["abandoned_attempts"] = [
320
+ {
321
+ "attempt_id": aid,
322
+ "abandoned_by": attempt["events"][-1].get("abandoned_by"),
323
+ "superseded_by": attempt["events"][-1].get("superseded_by"),
324
+ }
325
+ for aid, attempt in state["attempts"].items()
326
+ if attempt.get("status") == "abandoned" and attempt.get("events")
327
+ ]
328
+ enforce = lease_policy(root) == "enforce"
329
+ grace = lease_grace_seconds(root)
330
+ current = now or datetime.now(timezone.utc)
331
+ for step in plan.get("steps", []):
332
+ if not isinstance(step, dict) or not isinstance(step.get("id"), str):
333
+ continue
334
+ step_id = step["id"]
335
+ step_state = state["steps"].get(
336
+ step_id,
337
+ {"status": "pending", "completed": False, "open_attempts": []},
338
+ )
339
+ step_result = {"status": step_state["status"], "errors": [], "warnings": []}
340
+ if step_state.get("completed"):
341
+ coverage["completed_steps"].append(step_id)
342
+ if step_state.get("open_attempts"):
343
+ step_result["warnings"].append(
344
+ {"severity": "warning", "message": f"{step_id} has open attempt"}
345
+ )
346
+ for aid in step_state.get("open_attempts", []):
347
+ attempt = state["attempts"].get(aid, {})
348
+ if attempt.get("lease_expires_at") is None:
349
+ if enforce:
350
+ coverage["no_deadline_open_attempts"].append(aid)
351
+ step_result["warnings"].append(
352
+ {
353
+ "severity": "warning",
354
+ "message": f"{step_id}/{aid} has no lease deadline; owner renew-lease or fail-step",
355
+ }
356
+ )
357
+ elif attempt_is_expired(attempt, current, grace):
358
+ coverage["expired_leases"].append(aid)
359
+ if enforce:
360
+ step_result["errors"].append(
361
+ {
362
+ "severity": "error",
363
+ "message": f"{step_id}/{aid} lease expired at {attempt['lease_expires_at']}; reclaim-step or fail-step",
364
+ }
365
+ )
366
+ else:
367
+ step_result["warnings"].append(
368
+ {
369
+ "severity": "warning",
370
+ "message": f"{step_id}/{aid} lease expired (advisory)",
371
+ }
372
+ )
373
+ if step_state["status"] == "failed":
374
+ coverage["failed_attempts"].extend(step_state.get("attempts", []))
375
+ coverage["steps"][step_id] = step_result
376
+ return coverage
377
+
378
+
379
+ def _ignored(path: str, prefixes: List[str]) -> bool:
380
+ return any(path.startswith(prefix) for prefix in prefixes)
381
+
382
+
383
+ def _latest_file_receipts_by_path(root: Path) -> Dict[str, Dict[str, Any]]:
384
+ latest: Dict[str, Dict[str, Any]] = {}
385
+ for receipt in file_receipts(root):
386
+ path = receipt.get("path")
387
+ if isinstance(path, str):
388
+ latest[path] = receipt
389
+ return latest
390
+
391
+
392
+ def _reconcile_changed_files(
393
+ root: Path,
394
+ plan: Dict[str, Any],
395
+ require_file_receipts: bool = True,
396
+ ) -> List[Dict[str, str]]:
397
+ contract: Dict[str, Any] = {}
398
+ contract_path = root / ".agent/execution.contract.json"
399
+ if contract_path.exists():
400
+ try:
401
+ contract, _ = try_read_json(contract_path)
402
+ except OSError:
403
+ contract = None
404
+ if contract is None:
405
+ contract = {}
406
+ ignore = contract.get("concurrency", {}).get("reconcile_ignore", [".agent/", ".git/"])
407
+ latest = _latest_file_receipts_by_path(root)
408
+ findings: List[Dict[str, str]] = []
409
+ for record in changed_file_records(root):
410
+ path = record["path"]
411
+ if _ignored(path, ignore):
412
+ continue
413
+ receipt = latest.get(path)
414
+ if receipt is None:
415
+ if not require_file_receipts:
416
+ continue
417
+ findings.append(
418
+ {
419
+ "severity": "error",
420
+ "message": f"changed file is not mapped to a step receipt: {path}",
421
+ }
422
+ )
423
+ continue
424
+ if record["change_kind"] == "deleted":
425
+ if receipt.get("change_kind") != "deleted":
426
+ findings.append(
427
+ {"severity": "error", "message": f"deleted file lacks deleted receipt: {path}"}
428
+ )
429
+ continue
430
+ expected_hash = receipt.get("after_sha256")
431
+ actual_hash = sha256_path(root / path)
432
+ if expected_hash != actual_hash:
433
+ findings.append(
434
+ {"severity": "error", "message": f"out-of-band edit after latest file receipt: {path}"}
435
+ )
436
+ if effective_hunk_policy(root) == "enforce":
437
+ # Scope-filter candidates the same way audit_drift does, so verify-run
438
+ # and audit-drift cannot diverge on which paths are hunk-checked.
439
+ allowed = plan.get("allowed_files", [])
440
+ blocked = plan.get("blocked_files", [])
441
+ candidates = [
442
+ record
443
+ for record in changed_file_records(root)
444
+ if not _ignored(record["path"], ignore)
445
+ and matches_path(record["path"], allowed)
446
+ and not matches_path(record["path"], blocked)
447
+ ]
448
+ for entry in unmapped_hunks(root, candidates):
449
+ findings.append(
450
+ {
451
+ "severity": "error",
452
+ "message": (
453
+ f"unmapped hunk in {entry['path']} "
454
+ f"(@@ -{entry['old_start']},{entry['old_count']} "
455
+ f"+{entry['new_start']},{entry['new_count']} @@): no matching file receipt"
456
+ ),
457
+ }
458
+ )
459
+ return findings
460
+
461
+
462
+ def _parse_iso_timestamp(value: str) -> Optional[datetime]:
463
+ normalized = f"{value[:-1]}+00:00" if value.endswith("Z") else value
464
+ try:
465
+ parsed = datetime.fromisoformat(normalized)
466
+ except ValueError:
467
+ try:
468
+ parsed_date = date.fromisoformat(normalized)
469
+ except ValueError:
470
+ return None
471
+ parsed = datetime.combine(parsed_date, datetime.min.time())
472
+ if parsed.tzinfo is None:
473
+ parsed = parsed.replace(tzinfo=timezone.utc)
474
+ return parsed.astimezone(timezone.utc)
475
+
476
+
477
+ def _terminal_attempt_violations(root: Path) -> List[Dict[str, str]]:
478
+ """Flag receipts whose attempt is unknown/foreign or terminal-at-write.
479
+
480
+ Backstop for hand-edited ledgers; the write guard is the primary path.
481
+ Every receipt must name an existing attempt for the same step. New-work
482
+ receipts must additionally predate their attempt's terminal event;
483
+ reconstructed/managed command receipts are exempt from that terminal check
484
+ (gate replay legitimately writes them to completed attempts).
485
+ """
486
+ state = read_step_state(root)
487
+ attempts = state["attempts"]
488
+ terminal_at: Dict[tuple, datetime] = {}
489
+ for event in read_step_events(root):
490
+ if event.get("event") in TERMINAL_EVENTS:
491
+ stamp = event.get("recorded_at")
492
+ if isinstance(stamp, str):
493
+ parsed = _parse_iso_timestamp(stamp)
494
+ if parsed is not None:
495
+ terminal_at[(event.get("step_id"), event.get("attempt_id"))] = parsed
496
+ exempt = {"reconstructed", "managed"}
497
+ findings: List[Dict[str, str]] = []
498
+
499
+ def _check(receipt: Dict[str, Any], stamp_key: str, new_work: bool) -> None:
500
+ step_id = receipt.get("step_id")
501
+ attempt_id = receipt.get("attempt_id")
502
+ receipt_id = receipt.get("id")
503
+ attempt = attempts.get(attempt_id)
504
+ if attempt is None or attempt.get("step_id") != step_id:
505
+ findings.append(
506
+ {
507
+ "severity": "error",
508
+ "message": f"receipt references unknown attempt: {receipt_id}",
509
+ }
510
+ )
511
+ return
512
+ if not new_work:
513
+ return
514
+ term = terminal_at.get((step_id, attempt_id))
515
+ stamp = receipt.get(stamp_key)
516
+ parsed_stamp = _parse_iso_timestamp(stamp) if isinstance(stamp, str) else None
517
+ if term and parsed_stamp is not None and parsed_stamp > term:
518
+ findings.append(
519
+ {
520
+ "severity": "error",
521
+ "message": f"terminal-attempt receipt violation: {receipt_id}",
522
+ }
523
+ )
524
+
525
+ for receipt in file_receipts(root):
526
+ _check(receipt, "recorded_at", new_work=True)
527
+ for receipt in command_receipts(root):
528
+ _check(receipt, "finished_at", new_work=receipt.get("provenance") not in exempt)
529
+ return findings
530
+
531
+
532
+ def _timeout_receipt_violations(root: Path) -> List[Dict[str, str]]:
533
+ findings: List[Dict[str, str]] = []
534
+ for receipt in command_receipts(root):
535
+ if _receipt_timed_out(receipt):
536
+ findings.append(
537
+ {
538
+ "severity": "error",
539
+ "message": (
540
+ f"command receipt {receipt.get('id')} for {receipt.get('step_id')} "
541
+ f"timed out after {receipt.get('timeout_seconds')} seconds"
542
+ ),
543
+ }
544
+ )
545
+ return findings
546
+
547
+
548
+ def _unresolved_finding_refs(root: Path) -> List[Dict[str, str]]:
549
+ """Flag amendment finding refs whose review run is not recorded.
550
+
551
+ Backstop for review-cycle integration: an ``amendment_started`` event may
552
+ cite findings from a review run that was never written to
553
+ ``.agent/review-runs.jsonl``. Each such reference is surfaced as a finding.
554
+ """
555
+ recorded = {
556
+ row.get("review_run_id")
557
+ for row in read_jsonl(root / ".agent/review-runs.jsonl")
558
+ if isinstance(row, dict)
559
+ }
560
+ findings: List[Dict[str, str]] = []
561
+ for event in read_step_events(root):
562
+ if event.get("event") != "amendment_started":
563
+ continue
564
+ for ref in event.get("finding_refs", []) or []:
565
+ if not isinstance(ref, dict):
566
+ continue
567
+ if ref.get("review_run_id") not in recorded:
568
+ findings.append(
569
+ {
570
+ "severity": "warning",
571
+ "message": (
572
+ f"unresolved finding ref {ref.get('review_run_id')}#"
573
+ f"{ref.get('finding_id')} on {event.get('step_id')}/"
574
+ f"{event.get('attempt_id')}"
575
+ ),
576
+ }
577
+ )
578
+ return findings
579
+
580
+
581
+ def verify_run(
582
+ root: Path,
583
+ plan: Dict[str, Any],
584
+ strict: bool = False,
585
+ record: bool = True,
586
+ *,
587
+ now: Optional[datetime] = None,
588
+ ) -> Dict[str, Any]:
589
+ result: Dict[str, Any] = {"status": "passed", "errors": [], "warnings": []}
590
+ policy = _proof_policy(root)
591
+ effective_strict = strict or bool(policy.get("strict_by_default"))
592
+ require_file_receipts = effective_strict or bool(
593
+ policy.get("require_file_receipts_for_changed_files", True)
594
+ )
595
+ for error in validate_plan(plan):
596
+ result["errors"].append({"severity": "error", "message": error})
597
+ drift = audit_drift(root, plan)
598
+ if drift.get("status") == "fail":
599
+ for path in drift.get("out_of_scope_files", []):
600
+ if not path.startswith(".agent/"):
601
+ result["errors"].append(
602
+ {"severity": "error", "message": f"out-of-scope changed file: {path}"}
603
+ )
604
+ for path in drift.get("blocked_files_changed", []):
605
+ result["errors"].append(
606
+ {"severity": "error", "message": f"blocked file changed: {path}"}
607
+ )
608
+ result["errors"].extend(_reconcile_changed_files(root, plan, require_file_receipts))
609
+ coverage = build_execution_coverage(root, plan, effective_strict, now=now)
610
+ for step_id, step in coverage["steps"].items():
611
+ for warning in step.get("warnings", []):
612
+ result["warnings"].append(warning)
613
+ # #20: an unrecovered expired lease under enforce is a coverage error and
614
+ # must gate the run (advisory downgrades it to a warning above).
615
+ for error in step.get("errors", []):
616
+ result["errors"].append(error)
617
+ if step["status"] == "failed":
618
+ result["errors"].append({"severity": "error", "message": f"{step_id} has failed attempt"})
619
+ if effective_strict and step["status"] != "completed":
620
+ result["errors"].append({"severity": "error", "message": f"{step_id} is not completed"})
621
+ result["errors"].extend(_terminal_attempt_violations(root))
622
+ result["errors"].extend(_timeout_receipt_violations(root))
623
+ for finding in _unresolved_finding_refs(root):
624
+ if effective_strict:
625
+ result["errors"].append(
626
+ {"severity": "error", "message": finding["message"]}
627
+ )
628
+ else:
629
+ result["warnings"].append(finding)
630
+ for finding in detect_stuck(root, plan)["findings"]:
631
+ result["warnings"].append(
632
+ {
633
+ "severity": "warning",
634
+ "message": finding["message"],
635
+ "rule": finding["rule"],
636
+ "step_id": finding["step_id"],
637
+ "attempt_id": finding["attempt_id"],
638
+ }
639
+ )
640
+ if result["errors"]:
641
+ result["status"] = "failed"
642
+ elif result["warnings"]:
643
+ result["status"] = "warning"
644
+ if record:
645
+ _append_verification(root, "run", None, None, effective_strict, False, result)
646
+ return result