delimit-cli 4.6.0 → 4.6.1

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,290 @@
1
+ """Heartbeat liveness framework — Phase 1 local file-based (LED-1412).
2
+
3
+ Solves the silent-staleness class that the 2026-05-15 session exposed:
4
+ delimit-reddit-proxy.service was inactive/disabled for 13 days, all
5
+ reddit scans failed silently with 429/403, and the founder noticed via
6
+ "3 day old posts" — not the system. There was no central liveness
7
+ reporting and no alert.
8
+
9
+ Phase 1 (this module): every scheduled task writes a heartbeat file
10
+ when it runs. A central check tool walks the heartbeat directory and
11
+ flags anything stale. Local-only — Codex's correct caveat that
12
+ heartbeats can't catch a full-host outage motivates Phase 2 (external
13
+ deadman ping, tracked separately as LED-1414).
14
+
15
+ Heartbeat file format — one per service at ~/.delimit/heartbeats/<service>.json:
16
+ {
17
+ "service": "delimit-reddit-proxy",
18
+ "last_run": "2026-05-15T14:23:51Z",
19
+ "last_success": "2026-05-15T14:23:51Z", # may differ from last_run on partial failure
20
+ "status": "ok" | "degraded" | "failed",
21
+ "next_expected": "2026-05-15T15:23:51Z",
22
+ "detail": "string — optional one-line context for status != ok"
23
+ }
24
+
25
+ Memory anchor: feedback_corrupted_worktree_phantom_failures.md (sister
26
+ failure class — both surface as "system reports stale data because no-one
27
+ checks freshness").
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ import time
35
+ from pathlib import Path
36
+ from typing import Any, Dict, List, Optional
37
+
38
+ # All heartbeats live under one directory. Override via env for tests.
39
+ DEFAULT_HEARTBEAT_DIR = Path.home() / ".delimit" / "heartbeats"
40
+
41
+ # Per-service staleness thresholds (seconds). Overridable via config file
42
+ # at ~/.delimit/heartbeats/_thresholds.json. Service names match the
43
+ # `service` key written by write_heartbeat().
44
+ DEFAULT_STALENESS_THRESHOLDS: Dict[str, int] = {
45
+ # Reddit scanner: hourly social loop. >2 hours = stale.
46
+ "delimit-reddit-proxy": 7200,
47
+ "delimit-social-loop": 7200,
48
+ # Inbox daemon: 5-min poll. >30 min = stale.
49
+ "delimit-inbox": 1800,
50
+ # License watch: daily timer. >36 hours = stale.
51
+ "delimit-license-watch": 129600,
52
+ # Drift check: daily. >36 hours = stale.
53
+ "delimit-drift-check": 129600,
54
+ # stake.one INJ-claim: daily 13:00 UTC. >30 hours = stale.
55
+ "stakeone-inj-claim": 108000,
56
+ }
57
+
58
+ # Fallback for services not in the threshold map.
59
+ DEFAULT_FALLBACK_STALENESS = 86400 # 24 hours
60
+
61
+
62
+ def _heartbeat_dir(override: Optional[str] = None) -> Path:
63
+ """Resolve the heartbeat directory. Honors:
64
+ - explicit override arg
65
+ - DELIMIT_HEARTBEAT_DIR env var
66
+ - default ~/.delimit/heartbeats/
67
+ """
68
+ if override:
69
+ return Path(override)
70
+ env = os.environ.get("DELIMIT_HEARTBEAT_DIR")
71
+ if env:
72
+ return Path(env)
73
+ return DEFAULT_HEARTBEAT_DIR
74
+
75
+
76
+ def _now_iso() -> str:
77
+ """Current UTC time as ISO 8601 with Z suffix (matches existing
78
+ delimit timestamp convention)."""
79
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
80
+
81
+
82
+ def _parse_iso(ts: str) -> Optional[float]:
83
+ """Parse an ISO 8601 timestamp to a unix epoch float. Returns None
84
+ on parse failure — callers treat None as 'unknown' (degraded but
85
+ not actionable)."""
86
+ if not ts:
87
+ return None
88
+ try:
89
+ # %Y-%m-%dT%H:%M:%SZ — UTC, no fractional seconds.
90
+ return time.mktime(time.strptime(ts, "%Y-%m-%dT%H:%M:%SZ")) - time.timezone
91
+ except (ValueError, TypeError):
92
+ return None
93
+
94
+
95
+ def write_heartbeat(
96
+ service: str,
97
+ status: str = "ok",
98
+ next_expected_in: Optional[int] = None,
99
+ detail: str = "",
100
+ success: bool = True,
101
+ heartbeat_dir: Optional[str] = None,
102
+ ) -> Dict[str, Any]:
103
+ """Write a heartbeat for `service`.
104
+
105
+ Called by every scheduled task at the end of its run. On success,
106
+ pass status='ok' and success=True (default). On partial failure
107
+ (e.g., one of N subreddits 429'd but most succeeded), pass
108
+ status='degraded'. On total failure, status='failed' + success=False.
109
+
110
+ Args:
111
+ service: stable service identifier (e.g., 'delimit-reddit-proxy').
112
+ Should match the systemd unit name where applicable.
113
+ status: 'ok' | 'degraded' | 'failed'.
114
+ next_expected_in: seconds until the next run is expected. Used
115
+ by check_staleness to compute next_expected timestamp.
116
+ detail: optional one-line context (printed to operators on stale).
117
+ success: True if the run achieved its primary purpose (independent
118
+ of `status` — a successful run can still be 'degraded' if
119
+ some optional sub-tasks failed). last_success only updates
120
+ when True.
121
+ heartbeat_dir: override the heartbeat directory (for tests).
122
+
123
+ Returns:
124
+ Dict with the written record (also persisted to disk).
125
+ """
126
+ target_dir = _heartbeat_dir(heartbeat_dir)
127
+ target_dir.mkdir(parents=True, exist_ok=True)
128
+ file_path = target_dir / f"{service}.json"
129
+
130
+ now = _now_iso()
131
+ next_expected = ""
132
+ if next_expected_in:
133
+ next_expected_epoch = time.time() + next_expected_in
134
+ next_expected = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(next_expected_epoch))
135
+
136
+ # Preserve last_success across runs (only update if this run succeeded).
137
+ last_success = now if success else ""
138
+ if not success and file_path.exists():
139
+ try:
140
+ prior = json.loads(file_path.read_text())
141
+ last_success = prior.get("last_success", "")
142
+ except (json.JSONDecodeError, OSError):
143
+ pass # Ignore corrupted prior; treat as no last_success known.
144
+
145
+ record = {
146
+ "service": service,
147
+ "last_run": now,
148
+ "last_success": last_success,
149
+ "status": status,
150
+ "next_expected": next_expected,
151
+ "detail": detail,
152
+ }
153
+ file_path.write_text(json.dumps(record, indent=2) + "\n")
154
+ return record
155
+
156
+
157
+ def read_heartbeats(heartbeat_dir: Optional[str] = None) -> List[Dict[str, Any]]:
158
+ """Read every heartbeat file in the directory. Skips files that
159
+ don't parse as JSON (corrupted heartbeats are reported as a separate
160
+ 'parse_error' entry so the operator sees them)."""
161
+ target_dir = _heartbeat_dir(heartbeat_dir)
162
+ if not target_dir.exists():
163
+ return []
164
+ out: List[Dict[str, Any]] = []
165
+ for path in sorted(target_dir.glob("*.json")):
166
+ # Skip the threshold config file
167
+ if path.name == "_thresholds.json":
168
+ continue
169
+ try:
170
+ data = json.loads(path.read_text())
171
+ out.append(data)
172
+ except (json.JSONDecodeError, OSError) as e:
173
+ out.append({
174
+ "service": path.stem,
175
+ "status": "parse_error",
176
+ "detail": f"heartbeat file {path.name} unreadable: {type(e).__name__}: {e}",
177
+ "last_run": "",
178
+ "last_success": "",
179
+ "next_expected": "",
180
+ })
181
+ return out
182
+
183
+
184
+ def _load_thresholds(heartbeat_dir: Optional[str] = None) -> Dict[str, int]:
185
+ """Merge defaults with the optional override at <dir>/_thresholds.json."""
186
+ thresholds = dict(DEFAULT_STALENESS_THRESHOLDS)
187
+ target_dir = _heartbeat_dir(heartbeat_dir)
188
+ override_path = target_dir / "_thresholds.json"
189
+ if override_path.exists():
190
+ try:
191
+ override = json.loads(override_path.read_text())
192
+ if isinstance(override, dict):
193
+ thresholds.update({k: int(v) for k, v in override.items() if isinstance(v, (int, float))})
194
+ except (json.JSONDecodeError, OSError, ValueError):
195
+ pass
196
+ return thresholds
197
+
198
+
199
+ def check_staleness(heartbeat_dir: Optional[str] = None) -> Dict[str, Any]:
200
+ """Walk all heartbeats and classify each by staleness.
201
+
202
+ Returns:
203
+ {
204
+ "checked_at": ISO8601 string,
205
+ "summary": {"ok": N, "stale": N, "degraded": N, "failed": N, "parse_error": N},
206
+ "services": [{service, status, last_run, last_success, age_seconds,
207
+ threshold_seconds, classification}],
208
+ "stale_services": [<service names that are stale>], # convenience for alerts
209
+ }
210
+
211
+ Classification rules (most-severe-first):
212
+ - parse_error: heartbeat file unreadable
213
+ - failed: status='failed' in the record
214
+ - stale: last_run older than threshold
215
+ - degraded: status='degraded' in the record
216
+ - ok: status='ok' AND last_run within threshold
217
+ - never_seen: heartbeat directory exists but service has no file
218
+ (only reported when a service is configured in thresholds but
219
+ has never written a heartbeat — surfaces "scheduled task never
220
+ ran since heartbeat instrumentation landed")
221
+ """
222
+ now = time.time()
223
+ records = read_heartbeats(heartbeat_dir)
224
+ thresholds = _load_thresholds(heartbeat_dir)
225
+
226
+ by_service: Dict[str, Dict[str, Any]] = {}
227
+ for rec in records:
228
+ service = rec.get("service", "?unknown?")
229
+ last_run_epoch = _parse_iso(rec.get("last_run", ""))
230
+ threshold = thresholds.get(service, DEFAULT_FALLBACK_STALENESS)
231
+ if last_run_epoch is not None:
232
+ age_seconds = int(now - last_run_epoch)
233
+ else:
234
+ age_seconds = -1
235
+
236
+ # Classify (most-severe-first)
237
+ if rec.get("status") == "parse_error":
238
+ classification = "parse_error"
239
+ elif rec.get("status") == "failed":
240
+ classification = "failed"
241
+ elif age_seconds < 0:
242
+ classification = "unknown_age"
243
+ elif age_seconds > threshold:
244
+ classification = "stale"
245
+ elif rec.get("status") == "degraded":
246
+ classification = "degraded"
247
+ else:
248
+ classification = "ok"
249
+
250
+ by_service[service] = {
251
+ "service": service,
252
+ "status": rec.get("status", "?"),
253
+ "last_run": rec.get("last_run", ""),
254
+ "last_success": rec.get("last_success", ""),
255
+ "age_seconds": age_seconds,
256
+ "threshold_seconds": threshold,
257
+ "classification": classification,
258
+ "detail": rec.get("detail", ""),
259
+ }
260
+
261
+ # Add never_seen entries for configured services that have no record
262
+ for service in thresholds.keys():
263
+ if service not in by_service:
264
+ by_service[service] = {
265
+ "service": service,
266
+ "status": "never_seen",
267
+ "last_run": "",
268
+ "last_success": "",
269
+ "age_seconds": -1,
270
+ "threshold_seconds": thresholds[service],
271
+ "classification": "never_seen",
272
+ "detail": "no heartbeat file found — service may not be instrumented yet",
273
+ }
274
+
275
+ services = list(by_service.values())
276
+ summary = {"ok": 0, "stale": 0, "degraded": 0, "failed": 0, "parse_error": 0,
277
+ "never_seen": 0, "unknown_age": 0}
278
+ stale_services = []
279
+ for svc in services:
280
+ c = svc["classification"]
281
+ summary[c] = summary.get(c, 0) + 1
282
+ if c in ("stale", "failed", "parse_error", "never_seen"):
283
+ stale_services.append(svc["service"])
284
+
285
+ return {
286
+ "checked_at": _now_iso(),
287
+ "summary": summary,
288
+ "services": services,
289
+ "stale_services": stale_services,
290
+ }
@@ -124,7 +124,16 @@ def _detect_venture(project_path: str = ".") -> Dict[str, str]:
124
124
 
125
125
 
126
126
  def _register_venture(info: Dict[str, str]):
127
- """Silently register a venture in the global registry."""
127
+ """Silently register a venture in the global registry.
128
+
129
+ Phase C follow-up (2026-05-18): reject paths under /tmp/* or the
130
+ bare "/tmp" itself. Pytest tmp_path values leaked into the registry
131
+ as ventures (`tmp: /tmp`, `test_project: /tmp/pytest-of-root/...`),
132
+ causing every fresh tmp_path to match via path-prefix in
133
+ resolve_venture and breaking test_resolve_venture_unregistered_path.
134
+ The guard fails-silently — tests that pass tmp_path to functions
135
+ which auto-register simply don't pollute the registry going forward.
136
+ """
128
137
  GLOBAL_DIR.mkdir(parents=True, exist_ok=True)
129
138
  ventures = {}
130
139
  if VENTURES_FILE.exists():
@@ -134,9 +143,19 @@ def _register_venture(info: Dict[str, str]):
134
143
  pass
135
144
 
136
145
  name = info["name"]
146
+ path = info.get("path", "")
147
+ # Guard against the specific test-state pollution that broke
148
+ # test_resolve_venture_unregistered_path: a `tmp: /tmp` venture
149
+ # caught EVERY pytest tmp_path via path-prefix in resolve_venture.
150
+ # Reject bare "/tmp" only. Deeper /tmp/<X> paths are fine — they
151
+ # only path-prefix-match their own subtree, not every tmp_path,
152
+ # AND legitimate test fixtures (e.g. test_ledger_proof) register
153
+ # subpaths during a single test run and need that to work.
154
+ if path == "/tmp" or path.rstrip("/") == "/tmp":
155
+ return
137
156
  if name not in ventures:
138
157
  ventures[name] = {
139
- "path": info.get("path", ""),
158
+ "path": path,
140
159
  "repo": info.get("repo", ""),
141
160
  "type": info.get("type", ""),
142
161
  "registered_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
@@ -565,8 +584,18 @@ def update_item(
565
584
  blocks: Optional[str] = None,
566
585
  project_path: str = ".",
567
586
  worked_by: str = "",
587
+ commit_sha: Optional[str] = None,
588
+ pr_url: Optional[str] = None,
568
589
  ) -> Dict[str, Any]:
569
- """Update an existing ledger item's fields."""
590
+ """Update an existing ledger item's fields.
591
+
592
+ LED-1408 Phase 1: when `status="done"` is requested, callers MAY provide
593
+ `commit_sha` and/or `pr_url` as proof that the work shipped to main.
594
+ The proof is recorded on the update event under `ship_proof` with a
595
+ `verified: bool` flag. Phase 1 does NOT enforce — items still
596
+ transition to `done` even without proof — but the flag lets future
597
+ audits and the Phase 2 reconciler find unverified-done items.
598
+ """
570
599
  _ensure(project_path)
571
600
  ledger_dir = _project_ledger_dir(project_path)
572
601
 
@@ -633,6 +662,49 @@ def update_item(
633
662
  update["blocked_by"] = blocked_by
634
663
  if blocks:
635
664
  update["blocks"] = blocks
665
+
666
+ # LED-1408 Phase 1: attach ship_proof block when status transitions to
667
+ # `done` or `shipped_pending`. Verified=True iff commit_sha or pr_url
668
+ # was supplied (directly or scraped from the note). Phase 2's
669
+ # reconciler will use this to distinguish "trustworthy done" from
670
+ # "marked done but never verified on main."
671
+ if status in ("done", "shipped_pending"):
672
+ try:
673
+ from ai.ledger_proof import build_ship_proof
674
+ update["ship_proof"] = build_ship_proof(
675
+ commit_sha=commit_sha,
676
+ pr_url=pr_url,
677
+ note=note,
678
+ )
679
+ # LED-1420 Phase 2 strict-mode flip: when DELIMIT_LEDGER_STRICT_DONE=1,
680
+ # an unverified `done` transition is downgraded to `shipped_pending`
681
+ # so the nightly reconciler (scripts/delimit_ledger_reconciler.py)
682
+ # can promote it to `done` once a commit-trailer match shows up on
683
+ # origin/main. Off by default so existing workflows keep closing
684
+ # items without hitting an unexpected gate; flip when the
685
+ # reconciler has been observed running for ~1 week without
686
+ # surprises.
687
+ strict = os.environ.get("DELIMIT_LEDGER_STRICT_DONE") == "1"
688
+ if (
689
+ strict
690
+ and status == "done"
691
+ and not update["ship_proof"].get("verified")
692
+ ):
693
+ update["status"] = "shipped_pending"
694
+ existing_note = update.get("note") or ""
695
+ suffix = (
696
+ "[LED-1420 strict-mode: downgraded done → shipped_pending — "
697
+ "no commit_sha/pr_url proof; reconciler will upgrade to "
698
+ "done when it finds a Ledger-Item: " + item_id + " trailer "
699
+ "on origin/main]"
700
+ )
701
+ update["note"] = (existing_note + " " + suffix).strip() if existing_note else suffix
702
+ except Exception:
703
+ # Soft-fail: a ship_proof bug must not break ledger close.
704
+ # The unverified state will be re-detectable from the missing
705
+ # key on the next audit pass.
706
+ pass
707
+
636
708
  _append(path, update)
637
709
 
638
710
  # Sync to Supabase for dashboard visibility
@@ -1306,7 +1378,12 @@ def session_history(limit: int = 5) -> Dict[str, Any]:
1306
1378
  # `archive` is a soft transition (status="archived", appended to JSONL); items
1307
1379
  # stay in replay forever. NO hard delete. Per-item failures don't block others.
1308
1380
  BULK_ACTIONS = ("archive", "set_status", "set_priority", "add_tag", "mark_done", "cancel")
1309
- _VALID_BULK_STATUSES = ("open", "in_progress", "blocked", "done", "cancelled", "archived", "completed")
1381
+ # LED-1408: `shipped_pending` is the intermediate state between "committed" and
1382
+ # "verified on main." Items transition to shipped_pending when a worker reports
1383
+ # completion (commit exists somewhere) but the orchestrator hasn't yet verified
1384
+ # the commit is reachable from origin/main. The reconciler (Phase 2) promotes
1385
+ # shipped_pending → done once reachability is confirmed.
1386
+ _VALID_BULK_STATUSES = ("open", "in_progress", "blocked", "shipped_pending", "done", "cancelled", "archived", "completed")
1310
1387
  _VALID_BULK_PRIORITIES = ("P0", "P1", "P2", "P3")
1311
1388
 
1312
1389
 
@@ -0,0 +1,127 @@
1
+ """Ledger ship-state proof helpers (LED-1408 Phase 1).
2
+
3
+ When a ledger item transitions to `done`, we want auditable evidence that the
4
+ fix actually shipped. Two forms of proof:
5
+
6
+ 1. **Commit-trailer binding** — the merge commit carries a `Ledger-Item:
7
+ LED-NNNN` trailer. Lets a webhook or reconciler walk `git log
8
+ origin/main` and find every commit-to-ledger link without naming
9
+ conventions or fuzzy matching.
10
+
11
+ 2. **PR-URL linkage** — `https://github.com/<org>/<repo>/pull/<N>` plus a
12
+ verified `merged_at` timestamp. Fallback for items closed without a
13
+ trailer.
14
+
15
+ Phase 1 (this module): parse + record. Items closed with proof get
16
+ `verified: true` on the event; items closed without proof get
17
+ `verified: false` so a future audit can find them.
18
+
19
+ Phase 2 (separate LED): the reconciler enforces stricter semantics —
20
+ items without proof default to `shipped_pending`, not `done`.
21
+
22
+ Memory anchor: feedback_agent_dashboard_done_means_committed_not_merged.md
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ from typing import Dict, Optional
29
+
30
+ # Match `Ledger-Item: LED-1234` (case-insensitive, leading whitespace allowed).
31
+ # Pattern intentionally tolerant of trailing whitespace + multiple LED IDs:
32
+ # we extract the FIRST LED-N on the line.
33
+ _LEDGER_TRAILER_RE = re.compile(
34
+ r"(?im)^\s*Ledger-Item\s*:\s*(LED-\d+)",
35
+ )
36
+
37
+ # Match a GitHub PR URL: https://github.com/<owner>/<repo>/pull/<N>
38
+ _PR_URL_RE = re.compile(
39
+ r"https://github\.com/([\w.-]+)/([\w.-]+)/pull/(\d+)",
40
+ )
41
+
42
+
43
+ def parse_ledger_trailer(commit_message: str) -> Optional[str]:
44
+ """Extract the `Ledger-Item: LED-NNNN` trailer value from a commit message.
45
+
46
+ Returns the LED id (e.g. `LED-1408`) or None if no trailer is present.
47
+ The trailer must be on its own line; mentions inside prose (e.g.
48
+ `mentions LED-1408 in passing`) do NOT match.
49
+ """
50
+ if not commit_message:
51
+ return None
52
+ match = _LEDGER_TRAILER_RE.search(commit_message)
53
+ if match:
54
+ return match.group(1)
55
+ return None
56
+
57
+
58
+ def parse_pr_url(text: str) -> Optional[Dict[str, str]]:
59
+ """Extract the first GitHub PR URL from any string.
60
+
61
+ Returns {owner, repo, number} or None.
62
+ """
63
+ if not text:
64
+ return None
65
+ match = _PR_URL_RE.search(text)
66
+ if match:
67
+ return {
68
+ "owner": match.group(1),
69
+ "repo": match.group(2),
70
+ "number": match.group(3),
71
+ }
72
+ return None
73
+
74
+
75
+ def build_ship_proof(
76
+ commit_sha: Optional[str] = None,
77
+ pr_url: Optional[str] = None,
78
+ note: Optional[str] = None,
79
+ ) -> Dict[str, object]:
80
+ """Build a ship-proof block to attach to a ledger `done` event.
81
+
82
+ Inputs may come from explicit MCP-tool args OR from inline mentions
83
+ in the note (the caller might paste a PR URL into the note field
84
+ without realizing it's also queryable).
85
+
86
+ Returns a dict with keys:
87
+ - verified: bool — True iff commit_sha OR pr_url was provided
88
+ - commit_sha: str or None
89
+ - pr_url: str or None
90
+ - pr_owner / pr_repo / pr_number: str or None (parsed from pr_url)
91
+ - ledger_trailer: str or None (parsed from note, if present)
92
+
93
+ The `verified` flag is the primary downstream signal. A future
94
+ reconciler will refuse to transition `done` without verified=True;
95
+ Phase 1 only records the flag without enforcing.
96
+ """
97
+ proof: Dict[str, object] = {
98
+ "verified": bool(commit_sha or pr_url),
99
+ "commit_sha": commit_sha or None,
100
+ "pr_url": pr_url or None,
101
+ }
102
+
103
+ if pr_url:
104
+ parsed = parse_pr_url(pr_url)
105
+ if parsed:
106
+ proof["pr_owner"] = parsed["owner"]
107
+ proof["pr_repo"] = parsed["repo"]
108
+ proof["pr_number"] = parsed["number"]
109
+
110
+ # If pr_url not explicitly passed but appears in the note, capture it
111
+ if not pr_url and note:
112
+ parsed = parse_pr_url(note)
113
+ if parsed:
114
+ proof["pr_url"] = f"https://github.com/{parsed['owner']}/{parsed['repo']}/pull/{parsed['number']}"
115
+ proof["pr_owner"] = parsed["owner"]
116
+ proof["pr_repo"] = parsed["repo"]
117
+ proof["pr_number"] = parsed["number"]
118
+ proof["verified"] = True
119
+
120
+ # Capture any Ledger-Item trailer from the note (rare but possible —
121
+ # a worker might paste the commit message into the close note).
122
+ if note:
123
+ trailer = parse_ledger_trailer(note)
124
+ if trailer:
125
+ proof["ledger_trailer"] = trailer
126
+
127
+ return proof