failloud 0.2.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.
failloud/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """FailLoud — a verification harness for LLM agent outputs.
2
+
3
+ Agents rarely fail loudly. They return ``status: done`` with an empty
4
+ payload, claim an action no artifact backs, plan from state that went stale
5
+ two steps ago, report counts their own itemized record contradicts, and
6
+ ship deliverables with silent holes in them. FailLoud reads the output the
7
+ way a skeptical reviewer would and produces a failure report instead of a
8
+ shrug.
9
+
10
+ Quick start::
11
+
12
+ import json
13
+ from failloud import check_output
14
+
15
+ result = check_output(json.load(open("agent_output.json")))
16
+ if not result.passed:
17
+ print(result.verdict) # "FAIL"
18
+ for finding in result.errors:
19
+ print(finding.check_id, finding.path, finding.message)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+ from .engine import run_checks, run_single
27
+ from .models import CheckConfig, CheckOutcome, CheckResult, Finding, Severity
28
+ from .receipts import Receipt, create_receipt, verify_chain, verify_receipt
29
+
30
+ __version__ = "0.2.0"
31
+
32
+ __all__ = [
33
+ "check_output",
34
+ "run_checks",
35
+ "run_single",
36
+ "CheckConfig",
37
+ "CheckOutcome",
38
+ "CheckResult",
39
+ "Finding",
40
+ "Severity",
41
+ "Receipt",
42
+ "create_receipt",
43
+ "verify_receipt",
44
+ "verify_chain",
45
+ "__version__",
46
+ ]
47
+
48
+
49
+ def check_output(doc: Any, config: CheckConfig | None = None) -> CheckResult:
50
+ """Verify one agent output document. The main entry point."""
51
+ return run_checks(doc, config)
@@ -0,0 +1,17 @@
1
+ """The check registry.
2
+
3
+ Each module in this package detects one class of silent failure and exposes
4
+ ``CHECK_ID``, ``NAME``, ``DESCRIPTION`` and ``check(doc, config)``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from types import ModuleType
10
+
11
+ from . import completeness, counts, evidence, placeholder, stale
12
+
13
+ MODULES: list[ModuleType] = [placeholder, evidence, stale, counts, completeness]
14
+
15
+ BY_ID: dict[str, ModuleType] = {m.CHECK_ID: m for m in MODULES}
16
+
17
+ __all__ = ["MODULES", "BY_ID"]
@@ -0,0 +1,241 @@
1
+ """FL005 — missing_components.
2
+
3
+ Catches deliverables that are structurally present but silently incomplete:
4
+ a required field that exists on paper and is empty in fact, and — for
5
+ segmented media — stretches where a component should be and nothing is.
6
+
7
+ Production origin: a delivered video passed every existence check — right
8
+ file, right length, right format — while one segment inside it was silent.
9
+ Existence is not completeness. The check that exists because of that miss
10
+ measures the inside of the deliverable, not just its outline.
11
+
12
+ Three passes:
13
+
14
+ 1. Required paths (``--require output.url`` / config) and JSON Schema
15
+ ``required`` fields: missing *or* empty is an error.
16
+ 2. Heuristic emptiness: fields inside the payload that are present but
17
+ empty while their siblings carry content are warnings — often benign,
18
+ always worth a look.
19
+ 3. Segment integrity: zero-length segments and gaps between segments
20
+ longer than ``max_segment_gap_seconds`` are dead stretches, and are
21
+ errors.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any
27
+
28
+ from ..models import CheckConfig, Finding, Severity
29
+ from ..util import is_empty, normalize_key, resolve_path
30
+
31
+ CHECK_ID = "FL005"
32
+ NAME = "missing_components"
33
+ DESCRIPTION = (
34
+ "Required components missing or silently empty, and dead stretches "
35
+ "inside segmented deliverables."
36
+ )
37
+
38
+ #: Fields that are legitimately empty in healthy outputs.
39
+ ALLOW_EMPTY_KEYS = {"error", "errors", "warnings", "cc", "bcc", "metadata", "parentid"}
40
+
41
+ SEGMENT_LIST_KEYS = {"segments", "scenes", "tracks", "clips", "chapters", "shots"}
42
+ START_KEYS = ("start", "start_sec", "start_time", "begin", "from")
43
+ END_KEYS = ("end", "end_sec", "end_time", "to")
44
+ DURATION_KEYS = ("duration", "duration_sec", "length", "duration_seconds")
45
+
46
+
47
+ def _first_number(node: dict[str, Any], keys: tuple[str, ...]) -> float | None:
48
+ lowered = {normalize_key(k): v for k, v in node.items()}
49
+ for key in keys:
50
+ value = lowered.get(normalize_key(key))
51
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
52
+ return float(value)
53
+ return None
54
+
55
+
56
+ def _segment_span(segment: dict[str, Any]) -> tuple[float, float] | None:
57
+ start = _first_number(segment, START_KEYS)
58
+ end = _first_number(segment, END_KEYS)
59
+ duration = _first_number(segment, DURATION_KEYS)
60
+ if start is None and duration is None:
61
+ return None
62
+ start = start if start is not None else 0.0
63
+ if end is None and duration is not None:
64
+ end = start + duration
65
+ if end is None:
66
+ return None
67
+ return start, end
68
+
69
+
70
+ def _check_segments(doc: Any, config: CheckConfig) -> list[Finding]:
71
+ findings: list[Finding] = []
72
+ from ..util import iter_nodes
73
+
74
+ for path, value in iter_nodes(doc):
75
+ key = path.rsplit(".", 1)[-1]
76
+ if normalize_key(key) not in SEGMENT_LIST_KEYS or not isinstance(value, list):
77
+ continue
78
+ spans: list[tuple[float, float, int]] = []
79
+ for index, segment in enumerate(value):
80
+ if not isinstance(segment, dict):
81
+ continue
82
+ span = _segment_span(segment)
83
+ if span is None:
84
+ continue
85
+ start, end = span
86
+ spans.append((start, end, index))
87
+ if end - start <= 0:
88
+ findings.append(
89
+ Finding(
90
+ check_id=CHECK_ID,
91
+ code="zero_length_segment",
92
+ severity=Severity.ERROR,
93
+ path=f"{path}[{index}]",
94
+ message=(
95
+ f"Segment {index} has zero length "
96
+ f"({start:g}s–{end:g}s) — a component that "
97
+ "occupies a slot and contains nothing."
98
+ ),
99
+ detail={"start": start, "end": end},
100
+ )
101
+ )
102
+ spans.sort()
103
+ for (prev_start, prev_end, _), (start, end, index) in zip(spans, spans[1:]):
104
+ gap = start - prev_end
105
+ if gap > config.max_segment_gap_seconds:
106
+ findings.append(
107
+ Finding(
108
+ check_id=CHECK_ID,
109
+ code="dead_stretch",
110
+ severity=Severity.ERROR,
111
+ path=f"{path}[{index}]",
112
+ message=(
113
+ f"Dead stretch of {gap:.1f}s before segment "
114
+ f"{index} (gap limit "
115
+ f"{config.max_segment_gap_seconds:g}s) — the "
116
+ "deliverable goes silent where content should be."
117
+ ),
118
+ detail={"gap_seconds": round(gap, 3), "before_segment": index},
119
+ )
120
+ )
121
+ return findings
122
+
123
+
124
+ def _check_schema(node: Any, schema: dict[str, Any], path: str) -> list[Finding]:
125
+ """Walk the supported JSON Schema subset: ``required`` + ``properties``
126
+ on objects, ``items`` on arrays."""
127
+ findings: list[Finding] = []
128
+ if not isinstance(schema, dict):
129
+ return findings
130
+ required = schema.get("required")
131
+ if isinstance(required, list) and isinstance(node, dict):
132
+ for key in required:
133
+ if key not in node:
134
+ findings.append(
135
+ Finding(
136
+ check_id=CHECK_ID,
137
+ code="missing_component",
138
+ severity=Severity.ERROR,
139
+ path=f"{path}.{key}",
140
+ message=f"Required component {key!r} is missing entirely.",
141
+ detail={"required_by": "schema"},
142
+ )
143
+ )
144
+ elif is_empty(node[key]):
145
+ findings.append(
146
+ Finding(
147
+ check_id=CHECK_ID,
148
+ code="empty_component",
149
+ severity=Severity.ERROR,
150
+ path=f"{path}.{key}",
151
+ message=(
152
+ f"Required component {key!r} is present but "
153
+ "empty — it satisfies the shape and delivers "
154
+ "nothing."
155
+ ),
156
+ detail={"required_by": "schema"},
157
+ )
158
+ )
159
+ properties = schema.get("properties")
160
+ if isinstance(properties, dict) and isinstance(node, dict):
161
+ for key, subschema in properties.items():
162
+ if key in node:
163
+ findings.extend(_check_schema(node[key], subschema, f"{path}.{key}"))
164
+ items = schema.get("items")
165
+ if isinstance(items, dict) and isinstance(node, list):
166
+ for index, item in enumerate(node):
167
+ findings.extend(_check_schema(item, items, f"{path}[{index}]"))
168
+ return findings
169
+
170
+
171
+ def check(doc: Any, config: CheckConfig) -> list[Finding]:
172
+ findings: list[Finding] = []
173
+ if not isinstance(doc, dict):
174
+ return findings
175
+
176
+ # Pass 1a: explicitly required dotted paths.
177
+ for dotted in config.required_paths:
178
+ found, value = resolve_path(doc, dotted)
179
+ if not found:
180
+ findings.append(
181
+ Finding(
182
+ check_id=CHECK_ID,
183
+ code="missing_component",
184
+ severity=Severity.ERROR,
185
+ path=f"$.{dotted}",
186
+ message=f"Required component {dotted!r} is missing entirely.",
187
+ detail={"required_by": "required_paths"},
188
+ )
189
+ )
190
+ elif is_empty(value):
191
+ findings.append(
192
+ Finding(
193
+ check_id=CHECK_ID,
194
+ code="empty_component",
195
+ severity=Severity.ERROR,
196
+ path=f"$.{dotted}",
197
+ message=(
198
+ f"Required component {dotted!r} is present but empty "
199
+ "— it satisfies the shape and delivers nothing."
200
+ ),
201
+ detail={"required_by": "required_paths"},
202
+ )
203
+ )
204
+
205
+ # Pass 1b: JSON Schema subset.
206
+ if config.schema:
207
+ findings.extend(_check_schema(doc, config.schema, "$"))
208
+
209
+ # Pass 2: heuristic emptiness inside the payload.
210
+ payload: Any = doc.get("output", doc)
211
+ if isinstance(payload, dict):
212
+ from ..util import iter_nodes
213
+
214
+ for path, value in iter_nodes(payload, "$.output" if "output" in doc else "$"):
215
+ if not isinstance(value, dict):
216
+ continue
217
+ populated = [k for k, v in value.items() if not is_empty(v)]
218
+ if not populated:
219
+ continue
220
+ for key, item in value.items():
221
+ if normalize_key(key) in ALLOW_EMPTY_KEYS:
222
+ continue
223
+ if is_empty(item):
224
+ findings.append(
225
+ Finding(
226
+ check_id=CHECK_ID,
227
+ code="empty_field",
228
+ severity=Severity.WARNING,
229
+ path=f"{path}.{key}",
230
+ message=(
231
+ f"Field {key!r} is present but empty while "
232
+ "its siblings carry content — verify it was "
233
+ "meant to be blank."
234
+ ),
235
+ detail={},
236
+ )
237
+ )
238
+
239
+ # Pass 3: segment integrity.
240
+ findings.extend(_check_segments(doc, config))
241
+ return findings
@@ -0,0 +1,169 @@
1
+ """FL004 — count_mismatch.
2
+
3
+ Catches reported numbers that disagree with the itemized record in the
4
+ same document: the summary claims 12, the list contains 9. The claimed
5
+ number is the one that travels — into dashboards, digests, and decisions —
6
+ while the list is the only part that was ever true.
7
+
8
+ Production origin: a report counted actions *attempted* while its audience
9
+ read them as actions *completed*. Attempted and completed differed by the
10
+ failures nobody itemized. The rule that came out of it: any count a reader
11
+ will repeat must reconcile with a list a reader can count.
12
+
13
+ What is verified (structured data only — prose numbers are out of scope):
14
+
15
+ - A numeric claim (``counts.sent``, ``emails_sent_count``, ``total_sent``)
16
+ whose metric has an itemized list in the document must equal the list's
17
+ length.
18
+ - A ``total`` inside a counts block must equal the sum of its siblings.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from typing import Any
25
+
26
+ from ..models import CheckConfig, Finding, Severity
27
+ from ..util import iter_nodes, key_tokens, normalize_key
28
+
29
+ CHECK_ID = "FL004"
30
+ NAME = "count_mismatch"
31
+ DESCRIPTION = (
32
+ "A reported count that does not match the itemized record in the same "
33
+ "document (or a total that does not match its own parts)."
34
+ )
35
+
36
+ COUNT_CONTAINER_KEYS = {"counts", "stats", "summarycounts", "totals", "metrics"}
37
+ COUNT_KEY_RES = (
38
+ re.compile(r"^(.+?)_count$"),
39
+ re.compile(r"^count_(.+)$"),
40
+ re.compile(r"^total_(.+)$"),
41
+ re.compile(r"^num_(.+)$"),
42
+ )
43
+
44
+
45
+ def _metric_of_count_key(key: str) -> str | None:
46
+ for pattern in COUNT_KEY_RES:
47
+ match = pattern.match(key.lower())
48
+ if match:
49
+ return match.group(1)
50
+ return None
51
+
52
+
53
+ def _is_number(value: Any) -> bool:
54
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
55
+
56
+
57
+ def _names_match(claim_metric: str, array_metric: str) -> bool:
58
+ a, b = normalize_key(claim_metric), normalize_key(array_metric)
59
+ if a == b:
60
+ return True
61
+ if a.rstrip("s") == b.rstrip("s") and a.rstrip("s"):
62
+ return True
63
+ tokens_a, tokens_b = key_tokens(claim_metric), key_tokens(array_metric)
64
+ if tokens_a and tokens_b and (tokens_a <= tokens_b or tokens_b <= tokens_a):
65
+ return True
66
+ return False
67
+
68
+
69
+ def check(doc: Any, config: CheckConfig) -> list[Finding]:
70
+ findings: list[Finding] = []
71
+ if not isinstance(doc, (dict, list)):
72
+ return findings
73
+
74
+ # Collect itemized lists: (metric name, path, length).
75
+ arrays: list[tuple[str, str, int]] = []
76
+ for path, value in iter_nodes(doc):
77
+ if isinstance(value, list) and path != "$":
78
+ metric = path.rsplit(".", 1)[-1]
79
+ if metric.startswith("$"):
80
+ continue
81
+ arrays.append((metric, path, len(value)))
82
+
83
+ # Collect numeric claims: (metric name, path, claimed value).
84
+ claims: list[tuple[str, str, float]] = []
85
+ seen: set[tuple[str, float]] = set()
86
+
87
+ def add_claim(metric: str, path: str, value: float) -> None:
88
+ dedupe_key = (normalize_key(metric), float(value))
89
+ if dedupe_key in seen:
90
+ return
91
+ seen.add(dedupe_key)
92
+ claims.append((metric, path, value))
93
+
94
+ for path, value in iter_nodes(doc):
95
+ if not isinstance(value, dict):
96
+ continue
97
+ parent_key = normalize_key(path.rsplit(".", 1)[-1]) if path != "$" else ""
98
+ for key, item in value.items():
99
+ if not _is_number(item):
100
+ continue
101
+ item_path = f"{path}.{key}"
102
+ if parent_key in COUNT_CONTAINER_KEYS:
103
+ add_claim(key, item_path, float(item))
104
+ else:
105
+ metric = _metric_of_count_key(key)
106
+ if metric is not None:
107
+ add_claim(metric, item_path, float(item))
108
+
109
+ # A "total" must equal the sum of the siblings it totals.
110
+ for path, value in iter_nodes(doc):
111
+ if not isinstance(value, dict):
112
+ continue
113
+ parent_key = normalize_key(path.rsplit(".", 1)[-1]) if path != "$" else ""
114
+ if parent_key not in COUNT_CONTAINER_KEYS:
115
+ continue
116
+ numeric = {k: v for k, v in value.items() if _is_number(v)}
117
+ for key, total in numeric.items():
118
+ if normalize_key(key) in {"total", "grandtotal", "count"} and len(numeric) >= 3:
119
+ parts = sum(v for k, v in numeric.items() if k != key)
120
+ if float(total) != float(parts):
121
+ findings.append(
122
+ Finding(
123
+ check_id=CHECK_ID,
124
+ code="count_total_mismatch",
125
+ severity=Severity.ERROR,
126
+ path=f"{path}.{key}",
127
+ message=(
128
+ f"Reported total {total:g} does not equal "
129
+ f"the sum of its parts ({parts:g})."
130
+ ),
131
+ detail={"claimed": total, "sum_of_parts": parts},
132
+ )
133
+ )
134
+
135
+ # Every claim with an itemized list must reconcile with it.
136
+ for metric, path, claimed in claims:
137
+ matching = [a for a in arrays if _names_match(metric, a[0])]
138
+ if not matching:
139
+ continue # Nothing itemized: the claim is unverifiable, not wrong.
140
+ # Prefer the longest list among name matches — the fullest record.
141
+ array_metric, array_path, actual = max(matching, key=lambda a: a[2])
142
+ if float(actual) != claimed:
143
+ inflated = claimed > actual
144
+ findings.append(
145
+ Finding(
146
+ check_id=CHECK_ID,
147
+ code="count_mismatch",
148
+ severity=Severity.ERROR,
149
+ path=path,
150
+ message=(
151
+ f"Count for {metric!r} is reported as {claimed:g} "
152
+ f"but the itemized record at {array_path} contains "
153
+ f"{actual} item(s) — "
154
+ + (
155
+ "the report overstates what the record supports."
156
+ if inflated
157
+ else "the report understates its own record."
158
+ )
159
+ ),
160
+ detail={
161
+ "metric": metric,
162
+ "claimed": claimed,
163
+ "actual": actual,
164
+ "inflated": inflated,
165
+ "items_path": array_path,
166
+ },
167
+ )
168
+ )
169
+ return findings
@@ -0,0 +1,182 @@
1
+ """FL002 — unbacked_claim.
2
+
3
+ Catches claims of completed real-world actions that carry no evidence: the
4
+ agent says it sent, posted, published, updated, deployed — and nothing in
5
+ the output (no message id, no URL, no receipt, no artifact) backs the claim.
6
+
7
+ Production origin: a run reported a batch of messages as sent. One address
8
+ had bounced; the report never knew, because "sent" was asserted by the same
9
+ component that performed the send and nothing downstream demanded proof.
10
+ The rule that came out of it: a claim without evidence is a rumor.
11
+
12
+ Two passes:
13
+
14
+ 1. Structured claims — every entry in a ``claims``/``actions`` list that
15
+ asserts a completed action must carry at least one evidence field.
16
+ 2. Narrative claims — if a ``summary``/``message`` field asserts completed
17
+ actions in prose while the *entire document* contains no evidence at
18
+ all, the narrative is flagged once per field.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from typing import Any
25
+
26
+ from ..models import CheckConfig, Finding, Severity
27
+ from ..util import has_content, is_empty, iter_nodes, normalize_key
28
+ from .placeholder import COMPLETION_VALUES
29
+
30
+ CHECK_ID = "FL002"
31
+ NAME = "unbacked_claim"
32
+ DESCRIPTION = (
33
+ "A claimed action (sent, posted, published, updated…) with no evidence "
34
+ "— no id, URL, receipt, or artifact — anywhere to back it."
35
+ )
36
+
37
+ #: Keys whose non-empty value counts as evidence for a claim.
38
+ EVIDENCE_KEYS = {
39
+ "evidence",
40
+ "proof",
41
+ "artifact",
42
+ "artifacts",
43
+ "url",
44
+ "urls",
45
+ "link",
46
+ "links",
47
+ "id",
48
+ "messageid",
49
+ "recordid",
50
+ "receipt",
51
+ "receiptid",
52
+ "confirmation",
53
+ "confirmationid",
54
+ "externalid",
55
+ "path",
56
+ "file",
57
+ "hash",
58
+ "sha",
59
+ "permalink",
60
+ "reference",
61
+ "ticket",
62
+ "postid",
63
+ "threadid",
64
+ }
65
+
66
+ #: Document keys that conventionally hold the list of claimed actions.
67
+ CLAIM_LIST_KEYS = {"claims", "actions", "actionstaken", "steps", "completedactions"}
68
+
69
+ #: Past-tense action verbs that assert something happened in the world.
70
+ CLAIM_VERB_RE = re.compile(
71
+ r"\b(sent|posted|published|emailed|uploaded|deployed|updated|created|"
72
+ r"deleted|booked|charged|notified|delivered|filed|submitted|released|"
73
+ r"merged|announced)\b",
74
+ re.IGNORECASE,
75
+ )
76
+
77
+ URL_RE = re.compile(r"https?://[^\s)\]]+")
78
+
79
+ #: Free-text fields where narrative claims usually live.
80
+ TEXT_CLAIM_KEYS = {"summary", "message", "report", "description", "notes", "result"}
81
+
82
+
83
+ def _value_has_evidence(value: Any) -> bool:
84
+ """True if *value* contains, at any depth, an evidence-named field
85
+ holding actual content. An ``evidence`` object whose fields are all
86
+ empty is a container, not evidence, and does not count."""
87
+ if isinstance(value, dict):
88
+ for key, item in value.items():
89
+ if normalize_key(key) in EVIDENCE_KEYS and has_content(item):
90
+ return True
91
+ if _value_has_evidence(item):
92
+ return True
93
+ return False
94
+ if isinstance(value, list):
95
+ return any(_value_has_evidence(item) for item in value)
96
+ return False
97
+
98
+
99
+ def _claim_asserts_completion(claim: dict[str, Any]) -> str | None:
100
+ """Return the asserted action name if *claim* says something was done,
101
+ else None (planned, in-progress, or honestly failed claims pass)."""
102
+ status = claim.get("status") or claim.get("state")
103
+ if isinstance(status, str):
104
+ lowered = status.strip().lower()
105
+ if lowered in {"failed", "error", "skipped", "pending", "planned", "cancelled"}:
106
+ return None
107
+ for key in ("action", "type", "verb", "did", "name", "operation"):
108
+ value = claim.get(key)
109
+ if isinstance(value, str) and value.strip():
110
+ return value.strip()
111
+ if claim.get("done") is True or claim.get("completed") is True:
112
+ return "completed action"
113
+ if isinstance(status, str) and status.strip().lower() in COMPLETION_VALUES:
114
+ return "completed action"
115
+ return None
116
+
117
+
118
+ def _document_has_evidence(doc: Any) -> bool:
119
+ for _, value in iter_nodes(doc):
120
+ if isinstance(value, dict):
121
+ for key, item in value.items():
122
+ if normalize_key(key) in EVIDENCE_KEYS and not is_empty(item):
123
+ return True
124
+ if isinstance(value, str) and URL_RE.search(value):
125
+ return True
126
+ return False
127
+
128
+
129
+ def check(doc: Any, config: CheckConfig) -> list[Finding]:
130
+ findings: list[Finding] = []
131
+ if not isinstance(doc, (dict, list)):
132
+ return findings
133
+
134
+ # Pass 1: structured claims.
135
+ for path, value in iter_nodes(doc):
136
+ key = path.rsplit(".", 1)[-1]
137
+ if normalize_key(key) not in CLAIM_LIST_KEYS or not isinstance(value, list):
138
+ continue
139
+ for index, claim in enumerate(value):
140
+ if not isinstance(claim, dict):
141
+ continue
142
+ action = _claim_asserts_completion(claim)
143
+ if action is None:
144
+ continue
145
+ if not _value_has_evidence(claim):
146
+ findings.append(
147
+ Finding(
148
+ check_id=CHECK_ID,
149
+ code="unbacked_claim",
150
+ severity=Severity.ERROR,
151
+ path=f"{path}[{index}]",
152
+ message=(
153
+ f"Claimed action {action!r} has no evidence — no "
154
+ "id, URL, receipt, or artifact in the claim "
155
+ "backs that it happened."
156
+ ),
157
+ detail={"action": action},
158
+ )
159
+ )
160
+
161
+ # Pass 2: narrative claims with zero evidence in the whole document.
162
+ if isinstance(doc, dict) and not _document_has_evidence(doc):
163
+ for key, value in doc.items():
164
+ if normalize_key(key) in {normalize_key(k) for k in TEXT_CLAIM_KEYS}:
165
+ if isinstance(value, str) and CLAIM_VERB_RE.search(value):
166
+ verbs = sorted({m.lower() for m in CLAIM_VERB_RE.findall(value)})
167
+ findings.append(
168
+ Finding(
169
+ check_id=CHECK_ID,
170
+ code="unbacked_claim",
171
+ severity=Severity.ERROR,
172
+ path=f"$.{key}",
173
+ message=(
174
+ "Narrative claims completed actions "
175
+ f"({', '.join(verbs)}) but the document "
176
+ "contains no evidence — no id, URL, receipt, "
177
+ "or artifact — anywhere."
178
+ ),
179
+ detail={"verbs": verbs},
180
+ )
181
+ )
182
+ return findings