# Attack-surface mapper (#41) — shipped + MEASURED end-to-end

audience: AI coding agents first. BLUF-ordered. Tag [MEASURED]/[INFERRED].

## BLUF [MEASURED 2026-06-17]
The "map attack surface, delegate accordingly" phase is BUILT and measured end-to-end through `orchestrator/mapper.py`
(deterministic, no LLM in enumeration) calling the UNCHANGED `gate.py` per target. On the real `~/Projects/multideal`
tree, after an advisor review forced corrections (see "What verifying the claims caught"):
- **389 real entry points** (was an inflated 417, itself down from a pre-dedup 908 — both inflations were build/copy
  trees walked as source; see below). `kind_coverage_by_volume = 0.99` (385 / 389 detected entry points are of a kind
  the table has a discovery row for). READ THIS PRECISELY — it is NOT validation that any kind's signal finds all its
  instances. `enumerate` and `actual` gate on the SAME `KIND_SIGNALS` regex per kind, so **per-rowed-kind recall is
  1.0 BY CONSTRUCTION**; a file is a miss IFF it matches solely a kind with NO `CONVENTIONS` row. So 0.99 measures
  *what share of the surface is of a rowed kind*, not signal correctness.
- **Within-kind signal completeness (the filesystem oracle) is the only number that can fail, and it did its job:**
  http-file-route **1.0 (376/376)** — MEANINGFUL: every Astro `pages/api` file (a filesystem fact) is caught by the
  content signal, measured over the COMPLETE route set. edge-function **0.0 (0/2)** — `functions/api/subscribe.{ts,js}`
  are real CF Pages functions the `onRequest` signal MISSES (they use `export default async (request) =>`). A genuine
  signal gap the by-construction number hid → handed to #40.
- **The non-tautology rests SOLELY on missing-KIND signals** (NOT on off-path detection — the path-glob is not a gate,
  an off-path hit is still enumerated, so it cannot drive recall < 1.0). The 4 misses are ALL declared-but-no-row
  kinds (webhook-receiver 2, cron-scheduled 1, queue-consumer 1), reported by KIND — every miss auditable, none silent.
- **Real delegation proven [MEASURED, post-fix]:** a real dispatch (`--max-targets 1 --k 1`, fresh creds) ran gate.py
  on the top-priority target and wrote+linked a report. Coverage map: `scanned 1 / budget-dropped 384 / not-enumerated
  4` (1+384+4 = 389, consistent). Top target = `apps/web/src/pages/api/admin/affiliates/[id]/payouts.ts` — the
  `payout` CRITICAL token routed it first. The 384 un-dispatched are NAMED budget-dropped, never "clean".
  `--max-targets` default 25 audits <7% of 389 — a DELIBERATE sample, not coverage; "find ALL bugs" needs the full
  ~385×k fan-out, and budget-dropped names exactly what a sample skipped.

## What verifying the claims caught [MEASURED — the advisor review paid off FOUR times]
The advisor flagged that the headline "recall" was kind-coverage-by-volume, not validation. Building the
genuinely-independent within-kind denominator (the filesystem oracle), then VERIFYING the edge-function detections
the advisor would not bless on faith, surfaced FOUR real defects a green-tests-only pass missed:
1. **Count inflation from agent worktrees ~2.2× — FIXED.** `PRUNE_DIRS` excluded `.claude/worktrees` but NOT
   multideal's `.opencode/worktrees`, so 4 agent worktree COPIES were walked (+3610 files / +473 duplicate
   `pages/api`) → "908 entry points" was ~2.2× the truth.
2. **Denominator undercount — FIXED.** The first oracle used fnmatch globs (`**/pages/api/**/*.ts`); fnmatch `*`
   crosses `/`, and a middle `**` then REQUIRES an intermediate dir, silently dropping direct-child endpoints
   (`pages/api/foo.ts`). 11 routes were invisible (365 vs the true 376). Fix: PATH-SEGMENT matching, not globs.
3. **Build-artifact pollution — FIXED (caught by the advisor's verify-the-claim pass).** Listing the 14 "edge-function"
   detections to triage them exposed that 12 of 14 were COMPILED BUILD OUTPUT (`tmp/wasm-probe/`, `tmp/dryrun/`,
   `apps/web/.dist-stack/server/` — `.mjs` chunks). Both dirs are gitignored (`.gitignore:48 tmp/`, `:129
   apps/web/.dist-stack/`) but were absent from the hardcoded `PRUNE_DIRS` and carry no `.git` marker, so the walk ate
   them. 28 build artifacts (17 `tmp/`, 10 `.dist-stack/`, spread across kinds) inflated "417" → real surface **389**.
   Fix: the walk now drives off git (`ls-files` ∪ untracked-not-ignored) in a git tree, so `.gitignore` is the single
   source of truth (subsumes the name-list AND nested-`.git` prunes); os.walk + PRUNE_DIRS + nested-`.git` is the
   non-git fallback. (A hardcoded `PRUNE_DIRS` ignore-list ALWAYS drifts — this is the same defect class as #1.)
4. **edge-function signal is DOUBLY broken — NAMED, handed to #40.** Not just a recall miss. Its only 2 real-source
   detections are NOT CF functions: `apps/web/src/middleware.ts` (`export const onRequest = sequence(...)` = Astro
   MIDDLEWARE, wrong category) and `apps/web/src/features/deal-detail/DealCTA.tsx` (`onRequestPersonalDeal();` = a JSX
   handler CALL, a pure false-positive). So edge-function has BOTH zero precision (0/2 detections are real CF functions,
   polluting the 389 denominator by 2) AND zero recall (0/2 real `functions/` routes found). The correct fix is
   file-routed enumeration by LOCATION for `functions/`, NOT a broadened content regex (which would false-positive on
   every default export) — #40 per-kind row.

## What it does (the orchestrator core)
1. **enumerate** [deterministic] — walk the repo (git-driven where possible so `.gitignore` excludes build output,
   node_modules, and nested worktrees/submodules; else os.walk pruning by name AND nested git markers), match file
   CONTENT against the convention table's kind-signals. A `defineApi(` outside a conventional dir is still enumerated
   (signal is primary, path is a hint, NOT a gate).
2. **prioritize** [deterministic] — order by `gate.py`'s `CRITICAL` token set (single source) then mutation-ish kinds.
   ORDERS, never excludes. [MEASURED: multideal top target = `admin/affiliates/[id]/payouts.ts`, `payout` routed it first.]
3. **dispatch** — subprocess `gate.py <target> --report <out>` per target up to `--max-targets`, passing through
   `--alias/--k/--depth/--config-dir`. gate.py is UNCHANGED.
4. **coverage_map** — 3 DISJOINT buckets (`enumerated_scanned` / `enumerated_budget_dropped` / `not_enumerated`).
   The hard invariant: a surface that omits an entry point silently reads as "covered"; these buckets make every
   omission auditable.

## The two denominators (cross-kind + within-kind) — and what NEITHER validates
- **Cross-kind denominator [advisor #1 — partial fix].** `kind_coverage_by_volume = enumerated / actual` where `actual`
  = a permissive grep over the FULL kind-taxonomy signals (superset), NOT the canonical sample. Scoring against the
  canonical `file` set was near-tautological. The grep denominator fixes that ACROSS kinds (a no-row kind shows as a
  miss) — but NOT WITHIN a rowed kind: `enumerate` and `actual` share the same `KIND_SIGNALS` regex, so per-rowed-kind
  recall stays 1.0 by construction. A too-narrow signal inside a rowed kind is invisible to this number. canonical-`file`
  routing is kept ONLY as a SECONDARY confirmation, GATED on a corpus-source repo being checked out (zync trio is not →
  gated, never a false 100%).
- **Filesystem oracle [advisor 2nd pass — within-kind recall, file-routed only].** For FILE-ROUTED kinds a route is a
  FILESYSTEM fact (Astro/Next: every non-`_` file under `pages/api/`, plus app-router `app/**/route.ts`; CF Pages:
  every file under `functions/`), matched by PATH SEGMENTS. `actual_file_routed` = that filesystem set (content-
  independent); `enumerate` = the content signal. Their ratio CAN drop below 1.0 — a route whose registration idiom
  the regex misses is a VISIBLE miss (`missed_sample` names it). This is the genuinely-independent within-kind
  denominator the cross-kind grep cannot be. CAVEAT: clean for Astro/Next pages-routes (every non-`_` file IS an
  endpoint); for CF Pages `functions/` a non-route helper module can live under the dir, so an edge-function miss is a
  CANDIDATE (helper OR missed route) to TRIAGE, not auto a signal gap.
- **THE LARGEST KIND IS UNVALIDATED [advisor 2nd pass — record, do not paper over].** Call-registered kinds
  (`http-defn-call` = `defineApi|createRoute|defineEventHandler`) have NO filesystem oracle — a route is registered by
  a CALL, not a file location, so there is no content-independent denominator. `http-defn-call` is the **largest kind
  (185, ~48% of the 389 surface)** and has ZERO within-kind validation: if the signal misses a 4th registration idiom,
  those routes are invisible AND unmeasurable. Its ~1.0 is best-available-truth, STRUCTURAL, not validation. So 0.99 +
  "http-file-route within-kind 1.0" does NOT mean "the surface is validated" — ~half of it is unvalidated by
  construction. A `server/api/` dir-membership oracle MIGHT validate it later if that dir is route-by-convention →
  flagged for #40, not built now.
- **Kind taxonomy, not just dirs [advisor #3 — fixed].** `not_enumerated` reports a missing KIND (a whole class the
  table can't find), not merely an unmatched directory. [MEASURED: the 4 multideal misses are 3 distinct KINDS —
  webhook receivers/crons/queue consumers are scattered, not a single unmatched dir. NOTE: the pre-fix "cli-command 2 +
  auth-middleware 1" misses were ALL build artifacts — they have zero real-source instances; the content signal finding
  none in real source is correct, not a gap.]

## Decision-gate result [MEASURED]
The cross-kind number (rowed kinds at 100% by construction) is NOT a pass — a file matching a rowed signal is always
enumerated (shared regex), so a rowed kind can never appear in the cross-kind miss set. The MEANINGFUL within-kind
gate is the filesystem oracle, and it covers FILE-ROUTED kinds only: http-file-route PASSES (376/376 over the complete
route set); edge-function FAILS (0/2) and is the named #40 gap; http-defn-call (~half the surface) is UNVALIDATED.
Coverage across kinds grows by ADDING `CONVENTIONS` rows for the named no-row kinds; signal QUALITY within a
file-routed kind is now measurable and MUST be re-checked when a signal changes.

## Honest scope — do NOT overclaim
- **n=1 real dispatch.** The delegation chain is proven on ONE target. Multi-target real runs (cost, cross-target
  dedup noise) are unmeasured at scale.
- **edge-function signal is DOUBLY broken — OPEN (#40).** Precision 0/2 (middleware + a JSX false-positive) AND recall
  0/2 (`functions/api/subscribe.{ts,js}` use `export default async (request) =>`). Fix = file-routed enumeration by
  LOCATION for `functions/` (not a broadened content regex) — #40 per-kind row.
- **~Half the surface (http-defn-call, 185) is within-kind UNVALIDATED** — no filesystem oracle for call-registered
  kinds. 0.99 is kind-coverage-by-volume, NOT signal validation of the bulk kind. #40 may add a `server/api/`
  dir-membership oracle if that dir is route-by-convention.
- **Cross-target dedup is NOT built [advisor #4].** A helper imported by N enumerated routes is reviewed N times;
  `semantic_merge` dedups WITHIN a target, not across. v1 SURFACES the duplication (no-false-coverage), defers it.
- **2 entry-point KINDS with real instances have no discovery row yet** (webhook, plus cron/queue with 1 each) — NAMED
  blind classes, not silent gaps. Adding a `CONVENTIONS` row + a recall cell per kind is the #40 expansion path.
- **Cross-file resolution still 1-hop/alias-bound (#40).** The real dispatch's report flagged "[low] Authorization
  entirely delegated to unverifiable defineApi contract" — the gate honestly noticing it can't see the auth helper
  without a tsconfig-paths alias. That is #40, not a mapper defect.
- **Submodule caveat (fallback path only).** The non-git fallback's nested-`.git` prune also excludes a legitimate git
  submodule; in a git tree, `git ls-files` already omits submodule contents (gitlink). If a submodule is in-scope, map
  it as its own root.

## Reproduce
```
cd ~/Projects/security-gate
# surface measurement on the real tree (no LLM):
python3 bench.py --mapper /home/user/Projects/multideal
#   expect: kind_coverage_by_volume 0.99, actual 389; within_kind_recall http-file-route 1.0 (376/376),
#   edge-function 0.0 (0/2, subscribe.{ts,js}); not_enumerated_kinds = webhook/cron/queue (3 no-row kinds)
# real delegation (1 target, 1 roll):
rm -rf /tmp/sg_cfg && mkdir -p /tmp/sg_cfg && cp ~/.claude/.credentials.json /tmp/sg_cfg/.credentials.json && chmod 600 /tmp/sg_cfg/.credentials.json
python3 orchestrator/mapper.py /home/user/Projects/multideal --max-targets 1 --out-dir /tmp/mapper-rpt --map /tmp/m.json --config-dir /tmp/sg_cfg --k 1
#   expect: top target = admin/.../payouts.ts; coverage map scanned 1 / budget-dropped 384 / not-enumerated 4
python3 -m pytest tests/ -q                                  # 14 passed
```
