Skip to content

Commit 0b9c9c2

Browse files
Juanpacolclaude
andcommitted
feat(reliability): add risk-adaptive verification with file-level tiering (Phase 5)
ADR-0026: Verification depth now scales with file risk using signals already available in the graph. Added `risk_tier: str = "low"` field to Rule model for gating metadata (not core logic). Implemented classify_file_risk() to tier files by path convention, blast radius, fan-in, and untested symbols; rules_for_tier() filters rules by risk ceiling. Backfilled missing sql-injection caveat explaining shape limitations. Builtin security rules tagged with appropriate tiers: sql-injection=high, check-then-act-race=medium. Added 21 unit tests covering all tiering signals, tier ordering, and rule filtering. CLI integration deferred as future work; hunk-level precision and effect measurement are documented as constraints, not limitations. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 7775c48 commit 0b9c9c2

7 files changed

Lines changed: 498 additions & 1 deletion

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# ADR-0026: Risk-Adaptive Verification — tier files and gate rule depth
2+
3+
- **Status**: Accepted
4+
- **Date**: 2026-08-10
5+
- **Context**: a change to an authentication module and a trivial docstring edit do
6+
not deserve the same verification depth, yet `Rule.severity` is a free-form string
7+
nothing branches on, and `get_applicable_rules` filters on exactly one axis
8+
(language). This decision adds a second axis, `risk_tier`, using only signals
9+
already available in the code graph — blast radius, fan-in, untested symbols, and
10+
path conventions — requiring no new AST facts and no change to `RuleEngine`.
11+
12+
## Decision
13+
14+
Add `risk_tier: str = "low"` field to `Rule` (gating metadata, never core logic).
15+
Implement `src/verityai/reliability/risk.py` with three functions:
16+
17+
1. **`classify_file_risk(path, query) -> (tier, reasons)`** — tiers one changed
18+
file as `"low"`, `"medium"`, or `"high"` with the reasons that produced the
19+
tier, following invariant 5's spirit: a tiered path always says why.
20+
21+
Signals, all available from `graph` with no new AST facts:
22+
- **Path convention** (`"auth"`, `"migrations"`, `"api"`, `"security"`,
23+
`"payment"`, `"billing"` in the path) → `high` unconditionally. A change
24+
to authentication code warrants deep scrutiny regardless of graph metrics.
25+
- **Blast radius** (any symbol in the file has 3+ callers) → at least
26+
`"medium"`.
27+
- **Fan-in** (2+ other files import this one) → at least `"medium"`.
28+
- **Untested public symbols** (any public symbol with no test edge) → at least
29+
`"medium"`. `GraphQuery.untested` over-reports by construction, but that
30+
signal never downgrades to `"low"` alone; it only upgrades.
31+
- Nothing matched → `"low"`.
32+
33+
2. **`rules_for_tier(tier, rules) -> list[Rule]`** — every rule whose `risk_tier`
34+
is at or below `tier`. The filter-then-fire shape `get_applicable_rules`
35+
already demonstrates for language, now applied to a second axis: a `"high"`
36+
tier runs all rules; a `"low"` tier runs only rules worth checking on a
37+
trivial change (most defaults to `"low"`).
38+
39+
3. **`classify_paths(paths, query) -> dict[str, (tier, reasons)]`** — batch entry
40+
point. CLI passes changed files here, receives a tier-per-path dict.
41+
42+
The two builtin rules in `security.py` are backfilled with tier annotations:
43+
- `sql-injection``risk_tier="high"` (database injection warrants deep
44+
scrutiny on any file)
45+
- `check-then-act-race``risk_tier="medium"` (concurrency is worth checking
46+
on elevated-risk files, but not mandatory on trivial ones)
47+
48+
**Backfilled caveats:** `sql-injection` previously had no caveat in `RULE_CAVEATS`,
49+
now receives one explaining the shape limitations — the rule detects syntactic
50+
patterns (string concatenation with `+` in SQL context) and cannot distinguish
51+
intended vs accidental use or guarantee the data reaches a query executor.
52+
53+
## Consequences
54+
55+
- `core/models.py` gains `Rule.risk_tier: str = "low"` field and the two builtin
56+
rules are tagged with tier values.
57+
- New module `reliability/risk.py` (~110 lines) — pure functions, no side effects,
58+
injectable `GraphQuery` for testability.
59+
- 21 new unit tests in `test_reliability_risk.py` covering path signals, blast
60+
radius, fan-in, untested symbols, tier ordering, rule filtering, and batch
61+
classification.
62+
- CLI integration (wiring changed paths to `classify_file_risk``rules_for_tier`
63+
for actual rule selection) is stated as **future work**, not done here. Same
64+
for a measured pilot comparing adaptive-depth verification vs flat-depth.
65+
- The blind spots are stated plainly in caveats:
66+
- Path convention heuristics are lexical patterns, not proof of risk (a file
67+
named `auth_utils.py` in `src/billing/` triggers high risk by path, even if
68+
it's truly low-risk).
69+
- Untested symbol detection over-reports: a symbol with no direct `test` edge
70+
may still be covered by integration tests outside the graph's scope.
71+
- Blast radius does not account for indirect call chains (a file with 5 callers
72+
via a single intermediary is flagged identically to 5 direct callers).
73+
- `analysis/facts.py` carries no line numbers, so this module works at file
74+
granularity, the same as existing `reliability/` findings. Hunk-level precision
75+
(only this function is risky, not the whole file) is real future work, not
76+
simulated here.
77+
78+
## What this does NOT do
79+
80+
- Hunk-level tiering ("this function is high-risk, that one is low") — facts emit
81+
no line numbers, and correlating diff hunks to AST nodes is deferred.
82+
- Automatic rule consequence tuning ("run only fast rules on trivial files") —
83+
tuning is orthogonal and belongs in a cost-aware scheduler, not here.
84+
- Measuring the effect of adaptive depth on verification quality — a pilot is
85+
needed (via `verity eval`) before any claim about this mechanism's impact can
86+
be published, per invariant 7 (Phase 0).
87+
88+
## Rationale
89+
90+
Risk tiers are already observable in the codebase (high-risk vs low-risk files are
91+
intuitively clear), and the signals that identify them are already in the graph
92+
with no new extraction work needed. Gating rule depth by these signals is
93+
conservative (high-risk files get all checks; low-risk files get a safe subset)
94+
and measurable: a pilot can compare adaptive-depth against flat-depth and quantify
95+
the savings (faster verification on trivial files) vs cost (thoroughness on
96+
high-risk files). The backfilled `sql-injection` caveat closes a documentation
97+
gap that Phase 0 (Truth Repair) identified but did not address.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ verdict was "this pilot could not have detected an effect."
4343
| [0023](0023-memory-surfacing-log.md) | Can this project measure *when* memory was surfaced, not just when it was written? | Yes, narrowly — a new `Surfacing` record, emitted from `build_handoff` and decision resurfacing; "was it used" stays honestly unresolved except one negative signal |
4444
| [0024](0024-reality-check-expansion.md) | Can Agent Reality Check widen its recall without repeating ADR-0021's mistake? | Yes — `imports`, negation, multi-target relations, and constraints-as-evidence, each narrow and each declining to guess where the graph has no adjudicating edge |
4545
| [0025](0025-adaptive-context-prepass.md) | Can Verity proactively surface context without breaking the prune pipeline's invariants? | Yes, as a pre-pass only — `context/adaptive.py` + `memory/surface.py` merge into `ContextPipeline.run` unchanged; wiring and a measured pilot are stated as future work, not done here |
46+
| [0026](0026-risk-adaptive-verification.md) | Can verification depth scale with file risk, using only signals already in the graph? | Yes — `classify_file_risk` tiers by path convention / blast radius / fan-in / untested symbols; `rules_for_tier` gates rule depth; both builtin rules tagged with risk tiers and sql-injection caveat backfilled |
4647

4748
## Pre-pivot (superseded)
4849

src/verityai/core/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,14 @@ class Rule(BaseModel):
666666
description: str = ""
667667
category: str = "" # "security" | "architecture" | ...
668668
severity: str = "medium"
669+
# Which risk tier a changed file needs to reach before this rule is
670+
# worth running against it -- "low" | "medium" | "high". Display/gating
671+
# metadata only, the same status `severity` already has: nothing in
672+
# `rule_engine.py` branches on it. The gate lives entirely in the
673+
# caller -- `reliability/risk.py::rules_for_tier` filters
674+
# `BUILTIN_SECURITY_RULES` by this field before scan_code/scan_file/
675+
# scan_repo's already-injectable `rules=` ever sees them (ADR-0026).
676+
risk_tier: str = "low"
669677
formal_spec: str
670678
applies_to: list[str] = Field(default_factory=lambda: ["python"])
671679

src/verityai/reliability/risk.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Risk-Adaptive Verification: tier a changed file, then gate which rules
2+
run against it.
3+
4+
A trivial change and a change to an authentication module do not deserve
5+
the same depth of scrutiny -- but nothing in `reliability/` had any notion
6+
of that before this module. `Rule.severity` is a free-form string nothing
7+
branches on; `get_applicable_rules` (`rule_engine.py`) filters on exactly
8+
one axis, language. This module adds the second axis, `risk_tier`, and
9+
does it entirely from signals already available in `graph` -- blast
10+
radius, test coverage, fan-in, and path conventions -- so it needs no new
11+
AST facts and no change to `RuleEngine` itself (ADR-0026).
12+
13+
What this module does NOT attempt: hunk-level precision ("only this
14+
function in this file is risky"). `analysis/facts.py`'s extractors carry
15+
no line numbers, and `GraphNode.line`/`end_line` exist but nothing here
16+
correlates a diff hunk to them -- that is real future work, not faked here
17+
to look more precise than it is. Tiering is per-file, the same granularity
18+
`reliability/security.py`'s findings already use.
19+
"""
20+
21+
from verityai.core.models import Rule
22+
from verityai.graph.query import GraphQuery
23+
24+
# Path fragments that, on their own, justify treating a file as high risk
25+
# regardless of what the graph says about it -- a deliberately small,
26+
# explicit list rather than a heuristic guess, the same discipline
27+
# `_RELATIONS` in `consistency/claims.py` uses for relation phrases.
28+
_HIGH_RISK_PATH_MARKERS = ("auth", "migrations", "api", "security", "payment", "billing")
29+
30+
_TIER_ORDER = {"low": 0, "medium": 1, "high": 2}
31+
32+
33+
def _path_signal(path: str) -> str | None:
34+
lowered = path.lower()
35+
return next((marker for marker in _HIGH_RISK_PATH_MARKERS if marker in lowered), None)
36+
37+
38+
def classify_file_risk(path: str, query: GraphQuery) -> tuple[str, list[str]]:
39+
"""Tier one changed file as "low", "medium", or "high", with the
40+
reasons that produced the tier -- never a bare label (invariant 5's
41+
spirit: a degraded/triggered/tiered path always says why).
42+
43+
Signals, all already available from `graph` with no new AST facts:
44+
- **Path convention**: a fragment like `auth/`, `migrations/`, `api/`
45+
matches -> high, unconditionally. A change to authentication code
46+
warrants deep verification regardless of what the graph measures.
47+
- **Blast radius**: any symbol defined in this file has 3+ callers
48+
across the codebase -> at least medium.
49+
- **Fan-in**: 2+ other files import this one -> at least medium.
50+
- **Untested public symbols**: any public symbol in this file has no
51+
test edge (`GraphQuery.untested`, which over-reports by construction
52+
-- see its own docstring) -> at least medium, never downgraded to
53+
low on this signal alone since the over-reporting cuts only one way
54+
(toward flagging more, not fewer, files).
55+
- Nothing matched -> low.
56+
"""
57+
reasons: list[str] = []
58+
tier = "low"
59+
60+
def _raise(new_tier: str, reason: str) -> None:
61+
nonlocal tier
62+
reasons.append(reason)
63+
if _TIER_ORDER[new_tier] > _TIER_ORDER[tier]:
64+
tier = new_tier
65+
66+
marker = _path_signal(path)
67+
if marker is not None:
68+
_raise("high", f"path contains {marker!r}, a high-risk convention")
69+
70+
nodes = query.store.nodes_in_file(path)
71+
if not nodes:
72+
if not reasons:
73+
reasons.append("no graph node found for this file (not ingested, or not Python)")
74+
return tier, reasons
75+
76+
max_callers = max((len(query.callers(node.id)) for node in nodes), default=0)
77+
if max_callers >= 3:
78+
_raise("medium", f"at least one symbol here has {max_callers} callers (blast radius)")
79+
80+
fan_in = len(query.file_dependencies(path)["imported_by"])
81+
if fan_in >= 2:
82+
_raise("medium", f"imported by {fan_in} other files (fan-in)")
83+
84+
untested_in_file = {n.id for n in query.untested()} & {n.id for n in nodes}
85+
if untested_in_file:
86+
_raise(
87+
"medium",
88+
f"{len(untested_in_file)} public symbol(s) here have no direct test edge "
89+
"(over-reports by construction -- see GraphQuery.untested_caveat())",
90+
)
91+
92+
if not reasons:
93+
reasons.append("no elevating signal found")
94+
95+
return tier, reasons
96+
97+
98+
def rules_for_tier(tier: str, rules: list[Rule]) -> list[Rule]:
99+
"""Every rule whose `risk_tier` is at or below `tier` -- the filter-
100+
then-fire shape `get_applicable_rules` already demonstrates for
101+
language, applied to a second axis. A "high" tier gets every rule;
102+
a "low" tier gets only rules that are worth running on a trivial
103+
change.
104+
"""
105+
ceiling = _TIER_ORDER.get(tier, 0)
106+
return [r for r in rules if _TIER_ORDER.get(r.risk_tier, 0) <= ceiling]
107+
108+
109+
def classify_paths(paths: list[str], query: GraphQuery) -> dict[str, tuple[str, list[str]]]:
110+
"""Tier every path in `paths` -- the entry point a CLI command uses to
111+
turn a list of changed files into a per-file tier."""
112+
return {path: classify_file_risk(path, query) for path in paths}

src/verityai/reliability/security.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
name="SQL Injection Prevention",
3636
category="security",
3737
severity="high",
38+
risk_tier="high",
3839
formal_spec="PRE: sql_query_built_dynamically; POST: uses_parameterized_query",
3940
description=(
4041
"A query string built by concatenation, f-string, %-format or .format() "
@@ -47,6 +48,7 @@
4748
name="No Check-Then-Act Race",
4849
category="security",
4950
severity="medium",
51+
risk_tier="medium",
5052
formal_spec="PRE: check_then_act_on_shared_resource; POST: check_and_act_combined_atomically",
5153
description=(
5254
"A containment check followed by a mutation of the same container, with "
@@ -66,6 +68,15 @@
6668
# this plainly ("treat a hit as worth a human look, never as proof"), but that
6769
# caveat lived only in a docstring nobody reading a scan's output would see.
6870
RULE_CAVEATS: dict[str, str] = {
71+
"sql-injection": (
72+
"This rule matches a syntactic shape (a dynamically built query string "
73+
"reaching execute()/executemany()/executescript() with no parameterized "
74+
"query alongside it in the same function) -- it has no data-flow analysis, "
75+
"so it cannot tell whether the dynamic portion actually originates from "
76+
"untrusted input or is built entirely from hardcoded, trusted values. A "
77+
"hit on a query built from constants is a false positive by this "
78+
"definition, not evidence of an injectable query."
79+
),
6980
"check-then-act-race": (
7081
"This rule matches a syntactic shape (check membership, then mutate the "
7182
"same container, unguarded) -- it cannot tell whether the container is "

0 commit comments

Comments
 (0)