custos-code 0.0.1__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.
custos_code/scope.py ADDED
@@ -0,0 +1,471 @@
1
+ """The second checker: did the agent do what it was ASKED, not just what it said?
2
+
3
+ `rules.py` and `review.py` answer integrity -- did the claim happen. This answers scope -- was the
4
+ action inside the boundary the user granted. Design and rationale: docs/SCOPE.md.
5
+
6
+ **Scope is a blast radius, not a task description.** The design constraint is a real session
7
+ (cart-service, 2026-09-19): asked to fix failing tests, the agent also built a scratchpad venv, a
8
+ repro directory, and ran a mutation test. Good work, none of it requested. A rule keyed on "you
9
+ touched a file outside tests/" would have blocked it four times and been wrong every time.
10
+
11
+ What made that fine was not proximity to the request -- the mutation test was nowhere near it. It
12
+ was that every step was RECOVERABLE: temp dirs and uncommitted working-tree changes, all undoable
13
+ with `git checkout` or `rm -rf /tmp/...`.
14
+
15
+ Recoverability is the wiggle room. If git or a scratch directory can undo it, the agent may be
16
+ as creative as it likes and we do not spend a token looking.
17
+
18
+ Three bands (docs/SCOPE.md §4):
19
+ GREEN never gate, zero cost. Reads; writes git can revert; scratch dirs; tests/builds/linters.
20
+ YELLOW pause and ask. Writes outside cwd, unrecoverable edits, dependency installs, egress.
21
+ RED block always, attended or not. Irreversible AND outside the radius.
22
+
23
+ Everything here is PURE and DETERMINISTIC: no model call, no network, no subprocess beyond the git
24
+ queries `RepoState` already caches. That is what keeps GREEN free and keeps alpha at zero for the
25
+ bands that block -- and per docs/SCOPE.md §5, only grounded scope may block. The moment a block
26
+ rests on a model's judgement, alpha climbs and the stopping arithmetic in §2 turns against us.
27
+
28
+ Thresholds here are DEFAULTS, not findings. Issue #57 calibrates them against ~400 local sessions
29
+ of accepted work, where every YELLOW or RED is a false positive by construction. Until that lands,
30
+ treat the magnitude rules as unset.
31
+
32
+ Owner: Oliver.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import fnmatch
37
+ import os
38
+ import re
39
+ import shlex
40
+ import tomllib
41
+ from collections.abc import Sequence
42
+ from dataclasses import dataclass
43
+ from enum import StrEnum
44
+ from typing import Any
45
+
46
+ from .models import Claim, ClaimType, EventKind, LedgerEvent, Verdict, VerdictRecord
47
+ from .rules import RepoState
48
+
49
+
50
+ class Band(StrEnum):
51
+ GREEN = "green" # no gate, no cost
52
+ YELLOW = "yellow" # pause and ask (attended) / block (unattended)
53
+ RED = "red" # block always
54
+
55
+
56
+ # Irreversible by nature. These are matched against the command text, so they fire whether or not
57
+ # we can resolve the target path -- an `rm -rf` we cannot localise is exactly the case to stop.
58
+ _RED_COMMAND = [
59
+ (re.compile(r"\brm\s+(-[a-zA-Z]*[rR][a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*[rR])\b"), "rm-recursive-force"),
60
+ (re.compile(r"\bgit\s+push\b[^|;&]*(--force\b|(?<!-)-f\b)"), "git-force-push"),
61
+ (re.compile(r"\bgit\s+reset\s+--hard\b"), "git-reset-hard"),
62
+ (re.compile(r"\bgit\s+clean\b[^|;&]*-[a-zA-Z]*f"), "git-clean-force"),
63
+ (re.compile(r"(^|[|;&]\s*)sudo\b"), "sudo"),
64
+ (re.compile(r"\b(npm|yarn|pnpm)\s+publish\b|\btwine\s+upload\b|\bcargo\s+publish\b"), "package-publish"),
65
+ (re.compile(r"\bgh\s+(release|repo)\s+(create|delete)\b|\bgit\s+push\b[^|;&]*--delete\b"), "remote-mutation"),
66
+ (re.compile(r"\bshutdown\b|\breboot\b|\bdiskutil\b|\bmkfs\b"), "system-level"),
67
+ ]
68
+
69
+ # Paths that are never in the blast radius, wherever they sit. A write here is RED even under $HOME.
70
+ _PROTECTED = (
71
+ "~/.ssh", "~/.aws", "~/.gnupg", "~/.kube", "~/.docker/config.json",
72
+ "~/.netrc", "~/.npmrc", "~/.pypirc", "~/.git-credentials",
73
+ )
74
+ _PROTECTED_SUFFIX = (".env", ".pem", ".key", "id_rsa", "id_ed25519", "credentials")
75
+
76
+ # Reaches the network or mutates the environment. Recoverable, but the user should know.
77
+ _YELLOW_COMMAND = [
78
+ (re.compile(r"\b(pip|pip3|uv|npm|yarn|pnpm|cargo|go|gem|brew|apt|apt-get)\s+(install|add|get)\b"),
79
+ "dependency-install"),
80
+ (re.compile(r"\b(curl|wget|nc|ncat|ssh|scp|rsync|sftp)\b"), "network-egress"),
81
+ (re.compile(r"\bgit\s+push\b"), "git-push"),
82
+ (re.compile(r"\bcrontab\b|\blaunchctl\b|\bsystemctl\b"), "scheduling"),
83
+ ]
84
+
85
+ # Reads and inspections. Free, always, regardless of where they point.
86
+ _READ_TOOLS = frozenset({"Read", "Glob", "Grep", "NotebookRead", "WebFetch", "WebSearch", "TodoWrite"})
87
+ _WRITE_TOOLS = frozenset({"Edit", "Write", "MultiEdit", "NotebookEdit", "str_replace_based_edit_tool"})
88
+
89
+
90
+ _POLICY_PATH = os.path.expanduser("~/.custos-code/policy.toml")
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class Policy:
95
+ """User-widened or -narrowed scope rules, from `~/.custos-code/policy.toml` (SCOPE.md §6.4).
96
+
97
+ Every rule in this module keeps working with no file at all: `Policy()` adds nothing and
98
+ narrows nothing. Everything here is ADDITIVE -- a policy file can widen what counts as scratch
99
+ or protected, but it cannot silently drop a built-in RED rule out from under a user who never
100
+ asked for that; narrowing a built-in would need a code change and review, same as any other
101
+ safety-critical default.
102
+
103
+ `max_files_changed` is carried through but not yet consulted by `classify` -- SCOPE.md §7's
104
+ calibration against the real corpus reported thresholds as UNSET at this sample size (9
105
+ sessions vs. the ~400 the design calls for), and a magnitude rule that fires on a guess is the
106
+ same mistake the whole scope gate ships OFF to avoid.
107
+ """
108
+ scratch: tuple[str, ...] = ()
109
+ red: tuple[str, ...] = ()
110
+ protect: tuple[str, ...] = ()
111
+ max_files_changed: int = 0
112
+
113
+ @staticmethod
114
+ def load(path: str | None = None) -> Policy:
115
+ p = path or _POLICY_PATH
116
+ if not os.path.exists(p):
117
+ return Policy()
118
+ try:
119
+ with open(p, "rb") as fh:
120
+ data = tomllib.load(fh)
121
+ except (OSError, tomllib.TOMLDecodeError):
122
+ return Policy()
123
+ scope = data.get("scope")
124
+ scope = scope if isinstance(scope, dict) else {}
125
+
126
+ def _tup(key: str) -> tuple[str, ...]:
127
+ v = scope.get(key)
128
+ return tuple(str(x) for x in v) if isinstance(v, list) else ()
129
+
130
+ max_files = scope.get("max_files_changed")
131
+ return Policy(
132
+ scratch=_tup("scratch"),
133
+ red=_tup("red"),
134
+ protect=_tup("protect"),
135
+ max_files_changed=max_files if isinstance(max_files, int) else 0,
136
+ )
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class Grant:
141
+ """The blast radius the user allowed, explicitly and implicitly.
142
+
143
+ `cwd` is the implicit grant: invoking an agent in a directory grants that directory. `named`
144
+ is what the request mentioned. `approved` is ratcheted -- once the user approves a path this
145
+ session we never ask again, because a gate that re-asks is a gate people turn off (docs/SCOPE.md
146
+ §5), and a disabled gate verifies nothing.
147
+ """
148
+ cwd: str
149
+ scratch: tuple[str, ...] = ()
150
+ named: tuple[str, ...] = ()
151
+ approved: tuple[str, ...] = ()
152
+
153
+ @staticmethod
154
+ def for_session(cwd: str, named: tuple[str, ...] = (), approved: tuple[str, ...] = (),
155
+ policy: Policy | None = None) -> Grant:
156
+ extra = [os.path.realpath(os.path.expandvars(os.path.expanduser(p)))
157
+ for p in (policy.scratch if policy else ())]
158
+ scratch = [os.path.realpath(p) for p in
159
+ (os.environ.get("TMPDIR", "/tmp"), "/tmp", "/private/tmp",
160
+ os.path.expanduser("~/.custos-code"), *extra) if p]
161
+ root = os.path.realpath(os.path.expanduser(cwd)) if cwd else ""
162
+ # Scratch roots are kept whole. An earlier version dropped any root that CONTAINED the
163
+ # project -- which deleted /tmp from the list whenever cwd was anywhere beneath it, so
164
+ # /tmp/scratch/build stopped being scratch and a routine cleanup became RED. It broke CI
165
+ # (pytest's tmp_path lives under /tmp on Linux) and, worse, would have silently removed
166
+ # scratch protection from any real session running in a container or sandbox under /tmp.
167
+ # Caught by Anush in review on #60.
168
+ #
169
+ # The grant still wins, but only where it actually applies: `_in_scratch` excludes paths
170
+ # inside cwd, so a project living in /tmp is banded normally while its siblings under /tmp
171
+ # remain disposable.
172
+ return Grant(cwd=root, scratch=tuple(dict.fromkeys(scratch)), named=named, approved=approved)
173
+
174
+
175
+ @dataclass(frozen=True)
176
+ class Finding:
177
+ band: Band
178
+ rule: str # stable id, so calibration (#57) can bucket by cause
179
+ detail: str # one line naming the path or command
180
+ recoverable: bool
181
+
182
+ @property
183
+ def gates(self) -> bool:
184
+ return self.band is not Band.GREEN
185
+
186
+
187
+ GREEN_OK = Finding(Band.GREEN, "in-radius", "", recoverable=True)
188
+
189
+
190
+ def _under(path: str, root: str) -> bool:
191
+ """True when `path` is inside `root`.
192
+
193
+ `os.path.commonpath`, never `startswith`: `/x/proj-evil` is not inside `/x/proj`, and a
194
+ prefix test says it is. Same bug class as the CUSTOS_CODE_ONLY_IN fence in hooks.py.
195
+ """
196
+ if not root:
197
+ return False
198
+ try:
199
+ return os.path.commonpath([os.path.realpath(root), os.path.realpath(path)]) == os.path.realpath(root)
200
+ except (ValueError, OSError):
201
+ return False
202
+
203
+
204
+ def _abs(path: str, cwd: str) -> str:
205
+ p = os.path.expanduser(path)
206
+ return p if os.path.isabs(p) else os.path.normpath(os.path.join(cwd or os.getcwd(), p))
207
+
208
+
209
+ def _protected(abs_path: str, extra: tuple[str, ...] = ()) -> str | None:
210
+ for p in _PROTECTED:
211
+ if _under(abs_path, os.path.expanduser(p)) or abs_path == os.path.expanduser(p):
212
+ return p
213
+ base = os.path.basename(abs_path)
214
+ for suf in _PROTECTED_SUFFIX:
215
+ if base == suf or base.endswith(suf):
216
+ return suf
217
+ for pat in extra:
218
+ expanded = os.path.expanduser(pat)
219
+ if fnmatch.fnmatch(abs_path, expanded) or fnmatch.fnmatch(base, expanded):
220
+ return pat
221
+ return None
222
+
223
+
224
+ def _in_scratch(abs_path: str, grant: Grant) -> bool:
225
+ """Inside a scratch directory -- but never a scratch root itself.
226
+
227
+ `rm -rf /tmp/scratch/build` is an agent tidying up. `rm -rf /tmp` is not, and a plain
228
+ "is it under a scratch root" test waves it through because a directory is trivially under
229
+ itself. The root belongs to the machine, not to the session.
230
+ """
231
+ rp = os.path.realpath(abs_path)
232
+ if grant.cwd and _under(rp, grant.cwd):
233
+ return False # inside the granted project: band it normally, wherever it lives
234
+ return any(_under(rp, s) and rp != os.path.realpath(s) for s in grant.scratch)
235
+
236
+
237
+ def recoverable(abs_path: str, grant: Grant, state: RepoState) -> bool:
238
+ """Can this write be undone without the user losing anything?
239
+
240
+ Scratch is free by definition. Inside cwd, git is the undo mechanism: in a work tree, both a
241
+ tracked edit and a new untracked file are revertible (`git checkout` / delete). Outside both,
242
+ assume not -- that is the conservative direction, and it is only ever used to escalate a
243
+ YELLOW, never to justify a RED on its own.
244
+ """
245
+ if _in_scratch(abs_path, grant):
246
+ return True
247
+ if _under(abs_path, grant.cwd) and state.is_git:
248
+ return True
249
+ return False
250
+
251
+
252
+ def _paths_in(tool: str, inp: dict[str, Any]) -> list[str]:
253
+ out = [v for k in ("file_path", "path", "notebook_path")
254
+ if isinstance(v := inp.get(k), str) and v]
255
+ if tool == "Bash" and isinstance(cmd := inp.get("command"), str):
256
+ try:
257
+ toks = shlex.split(cmd)
258
+ except ValueError:
259
+ toks = cmd.split()
260
+ out += [t for t in toks if ("/" in t or t.startswith("~")) and not t.startswith("-")]
261
+ return out
262
+
263
+
264
+ _CHAIN_OPS = frozenset({"&&", "||", ";", "|"})
265
+
266
+
267
+ def _cmd_segments(cmd: str) -> list[list[str]]:
268
+ """Split a shell command line into its separate simple commands' argv lists.
269
+
270
+ Tokenizes once with shlex (so quoting is respected), then partitions the token stream on
271
+ unquoted `&&`/`||`/`;`/`|`. This is a tokenizer, not a shell: an operator glued to its
272
+ neighbours with no surrounding whitespace (`foo&&bar`) stays inside one token, the same
273
+ limitation the single-command check this replaces already had.
274
+ """
275
+ try:
276
+ tokens = shlex.split(cmd)
277
+ except ValueError:
278
+ return []
279
+ segments: list[list[str]] = [[]]
280
+ for tok in tokens:
281
+ if tok in _CHAIN_OPS:
282
+ segments.append([])
283
+ else:
284
+ segments[-1].append(tok)
285
+ return [s for s in segments if s]
286
+
287
+
288
+ def classify(tool: str, tool_input: dict[str, Any], grant: Grant,
289
+ state: RepoState | None = None, policy: Policy | None = None) -> Finding:
290
+ """Band one tool call. Pure, deterministic, no model call.
291
+
292
+ Order matters: RED first (an irreversible action is RED wherever it points), then reads (free),
293
+ then writes and the YELLOW command families. The first match wins, so a `sudo rm -rf` reports
294
+ as `rm-recursive-force` rather than as whichever rule happens to be checked last.
295
+
296
+ `policy` widens the built-in bands (SCOPE.md §6.4); it never narrows one, so an empty or
297
+ missing policy file classifies identically to no policy at all.
298
+ """
299
+ st = state if state is not None else RepoState(grant.cwd or None)
300
+ pol = policy or Policy()
301
+ inp = tool_input or {}
302
+ raw_command = inp.get("command")
303
+ cmd = raw_command if isinstance(raw_command, str) else ""
304
+
305
+ # --- RED: irreversible, wherever it points -------------------------------------------------
306
+ if tool == "Bash" and cmd:
307
+ for pat, rule in _RED_COMMAND:
308
+ if pat.search(cmd):
309
+ # An rm -rf confined to scratch is how agents clean up after themselves.
310
+ if rule == "rm-recursive-force":
311
+ targets = [_abs(p, grant.cwd) for p in _paths_in(tool, inp)]
312
+ if targets and all(_in_scratch(t, grant) for t in targets):
313
+ return GREEN_OK
314
+ return Finding(Band.RED, rule, f"{rule}: {cmd[:160]}", recoverable=False)
315
+ for phrase in pol.red:
316
+ if phrase and phrase.lower() in cmd.lower():
317
+ return Finding(Band.RED, "policy-red", f"policy-red: {cmd[:160]}", recoverable=False)
318
+
319
+ if tool in _WRITE_TOOLS or (tool == "Bash" and cmd):
320
+ for raw in _write_relevant_paths(tool, inp, cmd):
321
+ p = _abs(raw, grant.cwd)
322
+ if hit := _protected(p, pol.protect):
323
+ return Finding(Band.RED, "protected-path", f"writes {hit}: {raw}", recoverable=False)
324
+
325
+ # --- GREEN: reads are free, everywhere -----------------------------------------------------
326
+ if tool in _READ_TOOLS:
327
+ return GREEN_OK
328
+ if tool == "Bash" and cmd and _is_read_only_cmd(cmd):
329
+ return GREEN_OK
330
+
331
+ # --- writes: banded by recoverability, not by distance from the request --------------------
332
+ if tool in _WRITE_TOOLS or (tool == "Bash" and cmd):
333
+ for raw in _write_relevant_paths(tool, inp, cmd):
334
+ p = _abs(raw, grant.cwd)
335
+ if _in_scratch(p, grant):
336
+ continue
337
+ if any(_under(p, _abs(a, grant.cwd)) for a in grant.approved):
338
+ continue # ratcheted: never ask twice
339
+ if not _under(p, grant.cwd):
340
+ return Finding(Band.YELLOW, "write-outside-cwd",
341
+ f"writes outside the granted directory: {raw}",
342
+ recoverable=recoverable(p, grant, st))
343
+ if not recoverable(p, grant, st):
344
+ return Finding(Band.YELLOW, "unrecoverable-write",
345
+ f"not revertible (no git work tree here): {raw}",
346
+ recoverable=False)
347
+
348
+ # --- YELLOW command families ---------------------------------------------------------------
349
+ if tool == "Bash" and cmd:
350
+ for pat, rule in _YELLOW_COMMAND:
351
+ if pat.search(cmd):
352
+ return Finding(Band.YELLOW, rule, f"{rule}: {cmd[:160]}", recoverable=True)
353
+
354
+ return GREEN_OK
355
+
356
+
357
+ _READ_ONLY_FIRST = frozenset({
358
+ "ls", "cat", "head", "tail", "grep", "rg", "find", "wc", "diff", "stat", "file", "which",
359
+ "pwd", "echo", "printf", "date", "env", "tree", "du", "df", "ps", "sed", "awk", "sort", "uniq",
360
+ "pytest", "npx", "node", "python", "python3", "go", "cargo", "make", "ruff", "mypy", "tsc",
361
+ "eslint", "jest", "vitest",
362
+ # Shell scaffolding: these never touch a file on disk themselves, so they cost nothing to
363
+ # allow -- and without them, ANY multi-step command wrapped in `cd project && ...` or
364
+ # `source ~/.env && ...` fell through as "unrecognised" on the first segment alone (the old,
365
+ # single-token check), which is what let a plain `source ~/.env` get reported as "writes .env".
366
+ "cd", "source", ".", "set", "export", "unset",
367
+ })
368
+ _GIT_READ_ONLY = frozenset({"status", "log", "diff", "show", "branch", "remote", "rev-parse",
369
+ "ls-files", "blame", "describe", "config", "stash"})
370
+
371
+
372
+ def _segment_is_read_only(parts: list[str]) -> bool:
373
+ """One simple command's argv, non-mutating and without a redirect.
374
+
375
+ A redirect turns any of these into a write (`ls > file`), so a token containing `>` (`>`,
376
+ `>>`, or one glued to its target like `2>/dev/null`) disqualifies this segment. Being wrong
377
+ here costs a needless YELLOW, not a missed RED.
378
+ """
379
+ if not parts or any(">" in t for t in parts):
380
+ return False
381
+ head = os.path.basename(parts[0])
382
+ if head == "git":
383
+ return len(parts) > 1 and parts[1] in _GIT_READ_ONLY
384
+ return head in _READ_ONLY_FIRST
385
+
386
+
387
+ def _is_read_only_cmd(cmd: str) -> bool:
388
+ """Conservative: true only when EVERY step of a `&&`/`;`/`|`-chained line is non-mutating.
389
+
390
+ Checking just the line's first word (as this used to) meant `cd proj && pytest -q` was never
391
+ recognised as read-only at all, because "cd" wasn't even in the allowlist -- the compound
392
+ form fell through as if it were unrecognised, not as if it were safe.
393
+ """
394
+ segments = _cmd_segments(cmd)
395
+ return bool(segments) and all(_segment_is_read_only(s) for s in segments)
396
+
397
+
398
+ def _write_relevant_paths(tool: str, inp: dict[str, Any], cmd: str) -> list[str]:
399
+ """Path-like arguments worth banding as a potential write.
400
+
401
+ For every tool but Bash this is just `_paths_in` -- an Edit/Write call's `file_path` is
402
+ always the thing being written. For Bash, a path mentioned only inside a segment recognised
403
+ as read-only is not a write candidate at all: `source ~/.env && uv run ...` does not write
404
+ `.env`, whatever the rest of the line goes on to do, and treating "the line has an
405
+ unrecognised step somewhere" as "every path in the line might be written" is what made that
406
+ read get reported as a write in the first place. A path inside a segment we don't recognise
407
+ still counts, same as before -- this narrows false positives, it does not loosen real ones.
408
+ """
409
+ if tool != "Bash":
410
+ return _paths_in(tool, inp)
411
+ out: list[str] = []
412
+ for parts in _cmd_segments(cmd):
413
+ if _segment_is_read_only(parts):
414
+ continue
415
+ out += [t for t in parts if ("/" in t or t.startswith("~")) and not t.startswith("-")]
416
+ return out
417
+
418
+
419
+ # ---------------------------------------------------------------------------------------------
420
+ # Reaching the receipt (issue #64). `PreToolUse` (hooks.py) decides ask/deny before an action
421
+ # runs and never itself writes to the ledger -- a denied call leaves no CALL event to report on,
422
+ # by design. But plenty of gated actions DO end up in the ledger anyway: warn mode never blocks,
423
+ # an attended "ask" the user approved still ran, and a class-R bundle (Copilot, Devin) has no live
424
+ # gate in front of it at all. `scan` is how those reach a receipt post-hoc, by re-running the same
425
+ # pure `classify` over the CALL events a session already recorded.
426
+ # ---------------------------------------------------------------------------------------------
427
+
428
+ def scan(ledger: Sequence[LedgerEvent], grant: Grant, state: RepoState | None = None,
429
+ policy: Policy | None = None) -> list[tuple[LedgerEvent, Finding]]:
430
+ """Every top-level tool call that would have gated, paired with the event that made it.
431
+
432
+ Sidechain (sub-agent) calls are excluded -- the same rule integrity already applies to
433
+ evidence: a sub-agent's actions have no write path either checker treats as the top-level
434
+ agent's own.
435
+ """
436
+ st = state if state is not None else RepoState(grant.cwd or None)
437
+ hits: list[tuple[LedgerEvent, Finding]] = []
438
+ for event in ledger:
439
+ if event.kind is not EventKind.CALL or event.flags.sidechain or not event.tool:
440
+ continue
441
+ finding = classify(event.tool, event.input or {}, grant, st, policy)
442
+ if finding.gates:
443
+ hits.append((event, finding))
444
+ return hits
445
+
446
+
447
+ def to_verdict(event: LedgerEvent, finding: Finding) -> tuple[Claim, VerdictRecord]:
448
+ """One scope hit as a (Claim, VerdictRecord) pair, so report.py renders it like any other row.
449
+
450
+ Deliberately `out_of_scope`, never `contradicted` -- SCOPE.md §4: this is an action against a
451
+ boundary, not positive evidence a claim is false, and `contradicted` is reserved for that.
452
+ """
453
+ claim = Claim(
454
+ id=f"scope-{event.seq}",
455
+ session_id=event.session_id,
456
+ text=f"{event.tool} at #{event.seq}: {finding.detail}",
457
+ type=ClaimType.OTHER,
458
+ polarity="did",
459
+ source="scope",
460
+ )
461
+ record = VerdictRecord(
462
+ claim_id=claim.id,
463
+ verdict=Verdict.OUT_OF_SCOPE,
464
+ tier=2,
465
+ method="rule",
466
+ confidence=1.0,
467
+ evidence=[event.seq],
468
+ rationale=f"[{finding.band.value}] {finding.rule}: {finding.detail}",
469
+ band=finding.band.value,
470
+ )
471
+ return claim, record