recusal 0.1.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.
recusal/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ """
2
+ Recusal, separation-of-powers governance for Claude agents.
3
+
4
+ A judge recuses themselves from a case they can't impartially decide. The same
5
+ principle governs autonomous agents: the thing that *generates* the work must
6
+ never be the thing that *certifies* it. Recusal is that independent authority -
7
+ you collect evidence, it adjudicates deterministically into PASS / RETRY / FAIL,
8
+ and it can **refuse to certify**. No model call in the decision path. Zero
9
+ dependencies. Same evidence, same verdict, every time.
10
+
11
+ The spine is the evidence contract (see ``evidence`` and docs/EVIDENCE.md):
12
+
13
+ Finding one observation about the work.
14
+ Verdict the decision those findings add up to.
15
+
16
+ Everything hangs off it:
17
+
18
+ - ``evidence``, ``Finding`` / ``Verdict`` / ``Severity`` / ``Decision`` and
19
+ ``compute_verdict(findings)`` (the typed core).
20
+ - ``checks`` , built-in deterministic checks that turn raw data into Findings.
21
+ - ``claude`` , drop the gate in front of a Claude agent's tool calls so it can
22
+ refuse *before* a tool runs.
23
+ - ``gates`` , staged release checkpoints (G0-G8) rolled into release evidence.
24
+
25
+ The constitution it encodes: **builders cannot grade their own work** ·
26
+ deterministic before AI · the judge owns evidence, not progression · no shadow
27
+ authority. (See CONSTITUTION.md.)
28
+
29
+ Zero runtime dependencies, standard library only.
30
+ """
31
+
32
+ from .audit import AuditLog, verify
33
+ from .classify import (
34
+ DEFAULT_TAXONOMY,
35
+ Classification,
36
+ FailureClass,
37
+ classify_failure,
38
+ classify_verdict,
39
+ )
40
+ from .evidence import (
41
+ Decision,
42
+ Finding,
43
+ RuleSeverity,
44
+ Severity,
45
+ Verdict,
46
+ compute_verdict,
47
+ )
48
+ from .gates import GateAdjudicator, GateResult, ReleaseEvidence
49
+
50
+ __all__ = [
51
+ "Severity",
52
+ "RuleSeverity",
53
+ "Finding",
54
+ "Verdict",
55
+ "Decision",
56
+ "compute_verdict",
57
+ "GateAdjudicator",
58
+ "GateResult",
59
+ "ReleaseEvidence",
60
+ "AuditLog",
61
+ "verify",
62
+ "classify_failure",
63
+ "classify_verdict",
64
+ "FailureClass",
65
+ "Classification",
66
+ "DEFAULT_TAXONOMY",
67
+ ]
68
+
69
+ __version__ = "0.1.0"
recusal/audit.py ADDED
@@ -0,0 +1,177 @@
1
+ """
2
+ Hash-chained audit log, a linked record of every verdict.
3
+
4
+ A gate that can refuse is only half of an auditable control; the other half is a
5
+ record you can replay and an auditor can read. ``recusal.audit`` appends each verdict to an
6
+ append-only, hash-chained log: every entry carries the SHA-256 hash of the entry
7
+ before it, so an **in-place edit or a reordering** of existing entries breaks the
8
+ chain and ``verify`` catches it, naming the entry and the reason.
9
+
10
+ What this does and does not guarantee (read before relying on it):
11
+
12
+ - It detects in-place edits and reordering. It is tamper-**evident**, not tamper-proof.
13
+ - The digest is **unkeyed** and the head is **unanchored**, so an attacker with write
14
+ access to the file can truncate the tail, or rewrite the whole chain and recompute every
15
+ hash, and still pass ``verify``. To catch those, commit the head ``(count, last_hash)``
16
+ somewhere the attacker cannot also rewrite (a witness, a WORM store, a signature) and
17
+ pass it as ``verify(..., expected_head=...)``.
18
+ - It is **single-writer**: two processes appending to the same file will fork the chain.
19
+
20
+ Deterministic and dependency-free: SHA-256 over canonical JSON, standard library only.
21
+ The record shape maps cleanly onto OWASP Agentic logging and EU AI Act Article 12
22
+ (record-keeping).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import json
29
+ from datetime import datetime, timezone
30
+ from typing import Any, Callable, Dict, List, Optional, Tuple
31
+
32
+ from .evidence import Verdict
33
+
34
+ GENESIS = "0" * 64 # the prev_hash of the first entry
35
+
36
+
37
+ def _canonical(entry: Dict[str, Any]) -> str:
38
+ """Stable serialization of an entry for hashing, everything but the hash field."""
39
+ payload = {k: v for k, v in entry.items() if k != "hash"}
40
+ return json.dumps(
41
+ payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True, default=str
42
+ )
43
+
44
+
45
+ def _digest(entry: Dict[str, Any]) -> str:
46
+ return hashlib.sha256(_canonical(entry).encode("utf-8")).hexdigest()
47
+
48
+
49
+ def _summarize(verdict: Verdict) -> Dict[str, Any]:
50
+ return {
51
+ "decision": verdict.decision.value,
52
+ "highest_severity": verdict.highest_severity.value,
53
+ "reasons": verdict.reasons(),
54
+ "failures": [
55
+ {"check": f.check, "severity": f.severity.value, "message": f.message}
56
+ for f in verdict.failures
57
+ ],
58
+ }
59
+
60
+
61
+ class AuditLog:
62
+ """Append-only, hash-chained log of verdicts.
63
+
64
+ Pass ``path`` to persist as JSONL (one entry per line); otherwise it lives in
65
+ memory on ``self.entries``. An existing file is resumed, the chain continues
66
+ from its last entry.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ path: Optional[str] = None,
72
+ *,
73
+ clock: Optional[Callable[[], datetime]] = None,
74
+ ) -> None:
75
+ self.path = path
76
+ self._clock = clock or (lambda: datetime.now(timezone.utc))
77
+ self.entries: List[Dict[str, Any]] = []
78
+ self.last_hash = GENESIS
79
+ if path:
80
+ for entry in load(path):
81
+ self.entries.append(entry)
82
+ head = entry.get("hash")
83
+ if head is not None:
84
+ self.last_hash = head
85
+
86
+ def append(
87
+ self,
88
+ verdict: Verdict,
89
+ *,
90
+ action: Any = None,
91
+ actor: Optional[str] = None,
92
+ timestamp: Optional[str] = None,
93
+ ) -> Dict[str, Any]:
94
+ """Record one verdict. ``action`` is any JSON-serializable description of what
95
+ was adjudicated (e.g. ``{"tool": "Bash", "command": "..."}``); ``actor`` is an
96
+ optional agent/session id. Returns the written entry (including its hash)."""
97
+ entry: Dict[str, Any] = {
98
+ "seq": len(self.entries),
99
+ "timestamp": timestamp or self._clock().isoformat(),
100
+ "actor": actor,
101
+ "action": action,
102
+ **_summarize(verdict),
103
+ "prev_hash": self.last_hash,
104
+ }
105
+ entry["hash"] = _digest(entry)
106
+
107
+ self.entries.append(entry)
108
+ self.last_hash = entry["hash"]
109
+ if self.path:
110
+ with open(self.path, "a", encoding="utf-8") as fh:
111
+ fh.write(json.dumps(entry, ensure_ascii=True, default=str) + "\n")
112
+ return entry
113
+
114
+
115
+ def load(path: str) -> List[Dict[str, Any]]:
116
+ """Read a JSONL audit file into a list of entries (empty if the file is absent).
117
+
118
+ A line that is not valid JSON (e.g. a partial final line left by a process killed
119
+ mid-append) is skipped rather than crashing the read, so a half-written tail can never
120
+ brick the log. ``verify`` still surfaces any resulting chain break.
121
+ """
122
+ entries: List[Dict[str, Any]] = []
123
+ try:
124
+ with open(path, "r", encoding="utf-8") as fh:
125
+ for line in fh:
126
+ line = line.strip()
127
+ if not line:
128
+ continue
129
+ try:
130
+ entries.append(json.loads(line))
131
+ except json.JSONDecodeError:
132
+ continue # skip a corrupt/partial line instead of bricking the log
133
+ except FileNotFoundError:
134
+ return []
135
+ return entries
136
+
137
+
138
+ def verify(
139
+ entries: List[Dict[str, Any]],
140
+ *,
141
+ expected_head: Optional[Tuple[int, str]] = None,
142
+ ) -> Tuple[bool, List[str]]:
143
+ """Check the hash chain. Returns ``(intact, problems)``, ``problems`` is empty when the
144
+ log is intact, otherwise it names each broken entry and why.
145
+
146
+ This detects in-place edits and reordering of existing entries. It does **not**, on its
147
+ own, detect truncation of the tail or a full re-hash by an attacker with write access
148
+ (the digest is unkeyed and the head is unanchored). Pass ``expected_head=(count,
149
+ last_hash)``, a value committed somewhere the attacker cannot also rewrite, to catch
150
+ truncation and whole-chain forgery.
151
+ """
152
+ problems: List[str] = []
153
+ prev: str = GENESIS
154
+ for i, entry in enumerate(entries):
155
+ seq = entry.get("seq")
156
+ if seq != i:
157
+ problems.append(f"entry {i} (seq {seq}): out of order or gap - seq != position")
158
+ if entry.get("prev_hash") != prev:
159
+ problems.append(f"entry {i} (seq {seq}): broken link - prev_hash does not match")
160
+ if entry.get("hash") != _digest(entry):
161
+ problems.append(f"entry {i} (seq {seq}): content tampered - hash does not match")
162
+ prev = entry.get("hash") or ""
163
+ if expected_head is not None:
164
+ count, last_hash = expected_head
165
+ if len(entries) != count:
166
+ problems.append(
167
+ f"length mismatch: {len(entries)} entries, anchor expects {count} (possible truncation)"
168
+ )
169
+ actual_head = entries[-1].get("hash") if entries else GENESIS
170
+ if actual_head != last_hash:
171
+ problems.append("head mismatch: last hash does not match the external anchor")
172
+ return (not problems), problems
173
+
174
+
175
+ def verify_file(path: str) -> Tuple[bool, List[str]]:
176
+ """Convenience: load a JSONL audit file and verify its chain."""
177
+ return verify(load(path))
recusal/checks.py ADDED
@@ -0,0 +1,242 @@
1
+ """
2
+ Built-in deterministic checks, turn data into evidence.
3
+
4
+ The verdict kernel (``compute_verdict``) decides; these functions do the tedious
5
+ part that earns the decision: inspect real data and emit findings. Each check
6
+ takes a materialized sequence of dict-like rows (a ``list`` of dicts, JSON records,
7
+ or pandas rows, anything with ``[]`` access and a length). Iterators such as a
8
+ generator or ``csv.DictReader`` must be wrapped first: ``list(csv.DictReader(f))``.
9
+ Each check returns a single ``Finding`` ready to hand to ``compute_verdict``.
10
+
11
+ Severity is a parameter, not a hardcode, *you* decide whether a high null rate
12
+ is a CRITICAL stop or a WARNING. Pure logic, standard library only, no pandas
13
+ required.
14
+
15
+ Typical use::
16
+
17
+ from recusal import compute_verdict
18
+ from recusal.checks import row_count, null_rate, referential_integrity
19
+
20
+ findings = [
21
+ row_count(users, min_rows=1),
22
+ null_rate(users, "email", max_rate=0.10),
23
+ referential_integrity(orders, users, fk="user_id", pk="id"),
24
+ ]
25
+ verdict = compute_verdict(findings) # PASS / RETRY / FAIL
26
+ """
27
+
28
+ from typing import Any, Iterable, Sequence
29
+
30
+ from .evidence import Finding, RuleSeverity
31
+
32
+ Rows = Sequence[Any] # each row supports row["column"]
33
+
34
+
35
+ def _passed(check_type: str, severity: str, message: str, **context: Any) -> Finding:
36
+ return Finding.ok(check_type, severity=severity, message=message, **context)
37
+
38
+
39
+ def _failed(check_type: str, severity: str, message: str, **context: Any) -> Finding:
40
+ return Finding.fail(check_type, severity=severity, message=message, **context)
41
+
42
+
43
+ def row_count(
44
+ rows: Rows,
45
+ min_rows: int = 1,
46
+ severity: str = RuleSeverity.CRITICAL.value,
47
+ label: str = "dataset",
48
+ ) -> Finding:
49
+ """Fail if there are fewer than ``min_rows`` rows (empty data usually means
50
+ generation silently failed)."""
51
+ n = len(rows)
52
+ if n < min_rows:
53
+ return _failed(
54
+ "row_count",
55
+ severity,
56
+ f"{label}: {n} rows < required {min_rows}.",
57
+ actual=n,
58
+ min_rows=min_rows,
59
+ label=label,
60
+ )
61
+ return _passed("row_count", severity, f"{label}: {n} rows.", actual=n, label=label)
62
+
63
+
64
+ def null_rate(
65
+ rows: Rows,
66
+ column: str,
67
+ max_rate: float = 0.15,
68
+ severity: str = RuleSeverity.ERROR.value,
69
+ ) -> Finding:
70
+ """Fail if the fraction of null/empty values in ``column`` exceeds ``max_rate``."""
71
+ total = len(rows)
72
+ if total == 0:
73
+ return _passed("null_rate", severity, f"{column}: no rows to check.", column=column)
74
+ nulls = sum(1 for r in rows if _is_null(_get(r, column)))
75
+ rate = nulls / total
76
+ if rate > max_rate:
77
+ return _failed(
78
+ "null_rate",
79
+ severity,
80
+ f"{column}: null rate {rate:.1%} > max {max_rate:.1%} ({nulls}/{total}).",
81
+ column=column,
82
+ null_rate=rate,
83
+ max_rate=max_rate,
84
+ )
85
+ return _passed(
86
+ "null_rate",
87
+ severity,
88
+ f"{column}: null rate {rate:.1%} within {max_rate:.1%}.",
89
+ column=column,
90
+ null_rate=rate,
91
+ )
92
+
93
+
94
+ def referential_integrity(
95
+ child_rows: Rows,
96
+ parent_rows: Rows,
97
+ fk: str,
98
+ pk: str,
99
+ severity: str = RuleSeverity.CRITICAL.value,
100
+ ) -> Finding:
101
+ """Fail if any child row's foreign key has no matching parent primary key
102
+ (orphans = broken relationships)."""
103
+ parent_keys = {_get(r, pk) for r in parent_rows}
104
+ orphans = [
105
+ _get(r, fk)
106
+ for r in child_rows
107
+ if not _is_null(_get(r, fk)) and _get(r, fk) not in parent_keys
108
+ ]
109
+ if orphans:
110
+ sample = ", ".join(str(o) for o in orphans[:5])
111
+ return _failed(
112
+ "referential_integrity",
113
+ severity,
114
+ f"{len(orphans)} orphan(s) in {fk} -> {pk} (e.g. {sample}).",
115
+ fk=fk,
116
+ pk=pk,
117
+ orphan_count=len(orphans),
118
+ )
119
+ return _passed(
120
+ "referential_integrity",
121
+ severity,
122
+ f"{fk} -> {pk}: no orphans.",
123
+ fk=fk,
124
+ pk=pk,
125
+ orphan_count=0,
126
+ )
127
+
128
+
129
+ def in_set(
130
+ rows: Rows,
131
+ column: str,
132
+ allowed: Iterable[Any],
133
+ severity: str = RuleSeverity.ERROR.value,
134
+ ) -> Finding:
135
+ """Fail if any non-null value in ``column`` is outside the ``allowed`` set."""
136
+ allowed_set = set(allowed)
137
+ bad = [
138
+ _get(r, column)
139
+ for r in rows
140
+ if not _is_null(_get(r, column)) and _get(r, column) not in allowed_set
141
+ ]
142
+ if bad:
143
+ sample = ", ".join(str(b) for b in sorted(set(map(str, bad)))[:5])
144
+ return _failed(
145
+ "in_set",
146
+ severity,
147
+ f"{column}: {len(bad)} value(s) not in approved set (e.g. {sample}).",
148
+ column=column,
149
+ violation_count=len(bad),
150
+ )
151
+ return _passed("in_set", severity, f"{column}: all values in approved set.", column=column)
152
+
153
+
154
+ def in_range(
155
+ rows: Rows,
156
+ column: str,
157
+ min_value: float,
158
+ max_value: float,
159
+ severity: str = RuleSeverity.ERROR.value,
160
+ ) -> Finding:
161
+ """Fail if any numeric value in ``column`` falls outside [min_value, max_value]."""
162
+ violations = 0
163
+ for r in rows:
164
+ v = _get(r, column)
165
+ if _is_null(v):
166
+ continue
167
+ try:
168
+ fv = float(v)
169
+ except (TypeError, ValueError):
170
+ violations += 1
171
+ continue
172
+ if fv < min_value or fv > max_value:
173
+ violations += 1
174
+ if violations:
175
+ return _failed(
176
+ "in_range",
177
+ severity,
178
+ f"{column}: {violations} value(s) outside [{min_value}, {max_value}].",
179
+ column=column,
180
+ violation_count=violations,
181
+ )
182
+ return _passed(
183
+ "in_range",
184
+ severity,
185
+ f"{column}: all values within [{min_value}, {max_value}].",
186
+ column=column,
187
+ )
188
+
189
+
190
+ def required_keys(
191
+ rows: Rows,
192
+ keys: Iterable[str],
193
+ severity: str = RuleSeverity.CRITICAL.value,
194
+ ) -> Finding:
195
+ """Fail if any row is missing one of the required ``keys`` (schema drift)."""
196
+ required = list(keys)
197
+ missing_rows = 0
198
+ seen_missing: set = set()
199
+ for r in rows:
200
+ absent = [k for k in required if not _has(r, k)]
201
+ if absent:
202
+ missing_rows += 1
203
+ seen_missing.update(absent)
204
+ if missing_rows:
205
+ cols = ", ".join(sorted(seen_missing))
206
+ return _failed(
207
+ "required_keys",
208
+ severity,
209
+ f"{missing_rows} row(s) missing required key(s): {cols}.",
210
+ missing_keys=sorted(seen_missing),
211
+ affected_rows=missing_rows,
212
+ )
213
+ return _passed(
214
+ "required_keys", severity, f"all rows have required keys: {', '.join(required)}."
215
+ )
216
+
217
+
218
+ # ── tiny accessors that work for dicts and pandas-like rows ────────────────────
219
+
220
+
221
+ def _get(row: Any, key: str) -> Any:
222
+ try:
223
+ return row[key]
224
+ except (KeyError, IndexError, TypeError, AttributeError):
225
+ return getattr(row, key, None)
226
+
227
+
228
+ def _has(row: Any, key: str) -> bool:
229
+ try:
230
+ row[key]
231
+ return True
232
+ except (KeyError, IndexError, TypeError, AttributeError):
233
+ return hasattr(row, key)
234
+
235
+
236
+ def _is_null(value: Any) -> bool:
237
+ if value is None:
238
+ return True
239
+ if isinstance(value, str) and value.strip() == "":
240
+ return True
241
+ # NaN is the only value not equal to itself.
242
+ return value != value
recusal/classify.py ADDED
@@ -0,0 +1,207 @@
1
+ """
2
+ Deterministic failure classification + routing.
3
+
4
+ When an agent's action or output fails, the next move depends on *what kind* of
5
+ failure it is: a transient hiccup (retry), a policy refusal (don't retry as-is),
6
+ injected instructions in tool output (quarantine), a code bug (fix the code), the
7
+ wrong data shape (re-shape), missing data (fetch), or an ambiguous request
8
+ (ask a human). Guessing wastes a retry; asking a model is non-deterministic.
9
+
10
+ ``classify_failure`` decides by explicit marker rules, same input, same class,
11
+ same route, every time. It ships a default taxonomy (extracted from a real
12
+ autonomous build system and generalized) that you can extend or replace.
13
+
14
+ Pure logic, standard library only.
15
+ """
16
+
17
+ from dataclasses import dataclass
18
+ from typing import Optional, Sequence
19
+
20
+ from .evidence import Verdict
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class FailureClass:
25
+ """One category of failure and where it routes.
26
+
27
+ ``markers`` are case-insensitive substrings that signal this class; ``route`` is
28
+ the remediation channel/action a downstream system acts on (e.g. ``"retry"``,
29
+ ``"fix-code"``, ``"ask-human"``).
30
+ """
31
+
32
+ name: str
33
+ route: str
34
+ markers: Sequence[str]
35
+ description: str = ""
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Classification:
40
+ """The class a failure was assigned and how it routes."""
41
+
42
+ failure_class: str
43
+ route: str
44
+ marker: Optional[str] # the marker that matched, or None if it fell through
45
+ message: str
46
+
47
+ @property
48
+ def matched(self) -> bool:
49
+ return self.marker is not None
50
+
51
+
52
+ # Order matters: the most specific / security-critical classes come first so a
53
+ # policy refusal or an injection is never misread as a generic code/data error.
54
+ DEFAULT_TAXONOMY: Sequence[FailureClass] = (
55
+ FailureClass(
56
+ "policy_violation",
57
+ "refuse",
58
+ (
59
+ "refused by the gate",
60
+ "recusal refused",
61
+ "subject guard",
62
+ "blocked by policy",
63
+ "permission denied",
64
+ ),
65
+ "An action a deterministic policy refused, do not retry as-is; escalate or change the plan.",
66
+ ),
67
+ FailureClass(
68
+ "prompt_injection",
69
+ "quarantine",
70
+ (
71
+ "ignore previous instructions",
72
+ "disregard the above",
73
+ "ignore the system prompt",
74
+ "exfiltrate",
75
+ "send the api key",
76
+ ),
77
+ "Tool output appears to carry injected instructions, quarantine, don't act on it.",
78
+ ),
79
+ FailureClass(
80
+ "transient",
81
+ "retry",
82
+ (
83
+ "timed out",
84
+ "timeout",
85
+ "rate limit",
86
+ "too many requests",
87
+ "connection reset",
88
+ "econnreset",
89
+ "temporarily unavailable",
90
+ "service unavailable",
91
+ ),
92
+ "A recoverable infrastructure hiccup, retry with backoff.",
93
+ ),
94
+ FailureClass(
95
+ "code_bug",
96
+ "fix-code",
97
+ (
98
+ "traceback",
99
+ "syntaxerror",
100
+ "nameerror",
101
+ "typeerror",
102
+ "attributeerror",
103
+ "assertionerror",
104
+ "modulenotfounderror",
105
+ "importerror",
106
+ "no module named",
107
+ "command not found",
108
+ "compile error",
109
+ "exit code 1",
110
+ "test failed",
111
+ "tests failed",
112
+ ),
113
+ "The generated code is wrong, route to the builder/coding agent.",
114
+ ),
115
+ FailureClass(
116
+ "data_shape",
117
+ "fix-data",
118
+ (
119
+ "schema mismatch",
120
+ "invalid type",
121
+ "column not found",
122
+ "unexpected field",
123
+ "validation error",
124
+ "null rate",
125
+ "keyerror",
126
+ ),
127
+ "Data is the wrong shape, re-synthesize or re-shape it.",
128
+ ),
129
+ FailureClass(
130
+ "data_missing",
131
+ "fetch-data",
132
+ (
133
+ "0 rows",
134
+ "no rows",
135
+ "empty result",
136
+ "not found",
137
+ "does not exist",
138
+ "orphan",
139
+ "missing data",
140
+ ),
141
+ "Expected data is absent, fetch or re-migrate it.",
142
+ ),
143
+ FailureClass(
144
+ "spec_ambiguity",
145
+ "ask-human",
146
+ (
147
+ "ambiguous",
148
+ "unclear",
149
+ "underspecified",
150
+ "which record",
151
+ "did you mean",
152
+ "need clarification",
153
+ "acceptance criteria",
154
+ ),
155
+ "The request is ambiguous, escalate to a human for refinement.",
156
+ ),
157
+ )
158
+
159
+
160
+ def classify_failure(
161
+ text: str,
162
+ *,
163
+ taxonomy: Sequence[FailureClass] = DEFAULT_TAXONOMY,
164
+ fallback_class: str = "unknown",
165
+ fallback_route: str = "ask-human",
166
+ ) -> Classification:
167
+ """Classify a failure (an error string, a verdict reason, a log line) and route it.
168
+
169
+ First matching class wins (taxonomy order is precedence). If nothing matches,
170
+ returns the fallback class/route, never guesses. Non-string input is coerced with
171
+ ``str()`` so the router never raises on the wrong type.
172
+ """
173
+ haystack = str(text or "").lower()
174
+ for fc in taxonomy:
175
+ for marker in fc.markers:
176
+ if marker.lower() in haystack:
177
+ return Classification(
178
+ fc.name,
179
+ fc.route,
180
+ marker,
181
+ f"{fc.name} -> {fc.route} (matched '{marker}')",
182
+ )
183
+ return Classification(
184
+ fallback_class,
185
+ fallback_route,
186
+ None,
187
+ f"unclassified -> {fallback_route}",
188
+ )
189
+
190
+
191
+ def classify_verdict(
192
+ verdict: Verdict,
193
+ *,
194
+ taxonomy: Sequence[FailureClass] = DEFAULT_TAXONOMY,
195
+ fallback_class: str = "unknown",
196
+ fallback_route: str = "ask-human",
197
+ ) -> Classification:
198
+ """Classify a non-PASS ``Verdict`` from its reasons, what kind of failure, and where it
199
+ goes. A PASS verdict has nothing to route, so it returns ``pass -> proceed``."""
200
+ if verdict.passed:
201
+ return Classification("pass", "proceed", None, "verdict passed; nothing to route")
202
+ return classify_failure(
203
+ verdict.reasons(),
204
+ taxonomy=taxonomy,
205
+ fallback_class=fallback_class,
206
+ fallback_route=fallback_route,
207
+ )