|
| 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. |
0 commit comments