# Attack-surface mapping + delegation — design (task #41)

audience: AI coding agents first. BLUF-ordered, imperative. Tag [MEASURED]/[INFERRED]/[DECISION].
SoT for the mapper layer. Points to the project SoT (`docs/specs/2026-06-17-security-gate-design.md`)
and to `orchestrator/gate.py`; does NOT re-inline them.

## BLUF — what this builds
A **surface mapper** that sits ABOVE the per-target gate: given a target repo, it ENUMERATES the
attack surface (entry-point files), PRIORITIZES, DISPATCHES `gate.py` per entry point (with
deps-as-context), and emits a **coverage map** that distinguishes *scanned* vs *budget-dropped* vs
*not-enumerated*. The gate (`gate.py`) audits ONE file well; the mapper decides WHICH files and
proves WHAT it covered. v1 mapper is **fully deterministic** — no model-guessed routes.

```
repo ──[enumerate]──▶ entry-point list ──[prioritize]──▶ ordered targets
                                                              │
                                              per target ──[dispatch gate.py --report]──▶ findings
                                                              │
                                                       [coverage map] scanned / dropped / not-enumerated
```

## The one decision rule (read first)
**Enumeration is deterministic and repo-grounded. NEVER let a model invent the route/entry-point list.**
A hallucinated entry point is worse than a short real one: it fabricates coverage. The LLM's only role
stays INSIDE `gate.py` (per-file review). The mapper itself reads the filesystem + parses real source.

## Two routing layers — do NOT conflate
The mapper answers TWO distinct questions, each with its OWN recall measure:
1. **Enumeration** — *which files are attack-surface targets?* (the original scope of this spec; recall =
   `enumerated / actual`, below).
2. **Class-routing** — *given a target/file, which detector CLASSES are ADDED on it?* The cost lever is
   ADD-only (ADD a specialist where its signal fires; keep the LLM off whole non-target files) — NEVER
   subtract a class from a scanned file (that is the false-clean risk: "skip payments where money is
   implicit"). Governed by `docs/ARCHITECTURE.md` §10; measured by `bench --routing` (below). NEW since
   task #41 — see "Class-routing — ADD depth, never subtract".

## Approaches considered

### Approach 1 — Deterministic enumeration + dispatch (RECOMMENDED for v1)
Filesystem + lightweight source parse discovers entry-point files (framework conventions + exported
handlers); deterministic heuristics order them; dispatch `gate.py` per file; emit coverage map.

| Dimension | Assessment |
|---|---|
| Robustness | High — no model in the enumeration path; output is reproducible byte-for-byte |
| Long-term | High — convention table is data; add a framework/kind = add rows, not rewrite |
| Scalability | Medium — file walk + parse is linear; dispatch cost bounded by `--max-targets`, BUT a helper imported by N targets is re-reviewed N times (cross-target dedup is unbuilt — see "Cross-target dedup") |
| Reversibility | two-way door — mapper is a new file calling an unchanged `gate.py`; delete to revert |
| Recall-measurable | YES — `enumerated / actual` against a grep-derived independent denominator on a real tree (see "Recall measurement") |

**Weakness:** framework-specific — a repo using an unrecognized router needs a new convention entry
(but it FAILS LOUD: an un-enumerated dir is reported `not-enumerated`, never silently dropped).

### Approach 2 — LLM-driven enumeration
Ask a model to list the repo's entry points.

| Dimension | Assessment |
|---|---|
| Robustness | Low — hallucinated/missed routes; non-reproducible |
| Recall-measurable | NO — can't separate "model missed it" from "model invented it" |
| Reversibility | two-way door, but unmeasurable output makes it untrustable |

**Weakness:** violates the one decision rule (model-guessed routes = fabricated coverage). Rejected.

### Approach 3 — Hybrid: deterministic discover + LLM classify/prioritize
Deterministic discovery (as A1) produces the real file list; an LLM only CLASSIFIES trust boundaries
and orders priority over that fixed list — it can reorder/annotate, never add a route.

| Dimension | Assessment |
|---|---|
| Robustness | Medium — enumeration stays grounded; priority gains trust-boundary judgment |
| Long-term | Medium — adds an LLM leg with its own validation burden |
| Reversibility | two-way door |

**Weakness:** adds a second LLM leg to validate BEFORE the mapper's core recall is even measured —
premature. **Defer to v2** (documented evolution), once A1's recall is measured.

**Recommended: Approach 1.** It is the only approach whose recall is measurable (the load-bearing
property for this project), keeps enumeration hallucination-free per the advisor's #1 constraint, and
reuses `gate.py` unchanged. Approach 3's LLM priority is a v2 refinement layered on A1's grounded list.

## Architecture — v1 (deterministic)

Three responsibilities, each independently testable. New file: `orchestrator/mapper.py` (+ a
convention table it reads). The gate is unchanged.

### 1. enumerate — repo → entry-point file list [deterministic]
- Walk the repo; match files against a **convention table** (data, not code): path-glob + an
  exported-handler signal per framework. v1 table targets the available real tree's actual stack
  (multideal TS, [MEASURED this session]: `defineApi(...)` call sites (199); file-route HTTP-method
  exports `export const GET|POST|PUT|PATCH|DELETE` (~200); CF Pages `onRequest*` functions).
- **Match on the route-DEFINITION call, not only the path-glob.** A `defineApi(...)` in a file OUTSIDE
  a conventional dir is STILL enumerated — the signal is primary, the path-glob is a secondary HINT, NOT
  a gate. (So off-path detection does NOT drive recall < 1.0; an off-path hit is enumerated. The
  cross-kind miss comes only from a kind with NO convention row; within-kind signal completeness is
  measured separately by the filesystem oracle — see Recall.)
- Output per candidate: `{path, why_enumerated (which convention matched), kind}`. `kind` is drawn from
  the **entry-point-kind taxonomy** (next bullet) — the no-false-coverage guarantee is measured against
  KINDS, not dirs.
- **Entry-point-kind taxonomy [DECISION].** The convention table is measured against a fixed kind list,
  so "not-enumerated" reports a missing *kind* (a whole class of entry point the table can't find),
  not merely an unmatched directory. v1 kinds (each maps to ≥1 taxonomy S-class):
  - `http-file-route` — file-route method exports (`export const GET/POST/...`).
  - `http-defn-call` — framework route-definition call (`defineApi`, `createRoute`, `defineEventHandler`).
  - `edge-function` — CF/edge handler (`onRequest*`).
  - `webhook-receiver` — inbound third-party callback (S7). Often NOT under `*/api/*`; needs a signal-grep,
    not a dir-glob.
  - `queue-consumer` / `cron-scheduled` — async/job entry points (`queue(`, `scheduled(`). Scattered.
  - `graphql-resolver`, `auth-middleware`, `server-action-rpc`, `cli-command` — declared, v1-optional;
    a kind with NO convention row is reported `not-enumerated:kind:<name>` (the blind class, named).
  - **A substring grep is NOT a kind signal** [MEASURED: a naive `webhook|queue|cron` grep on multideal
    returned mostly noise — `country-codes.ts`, an i18n store]. Each kind needs a precise call/decorator
    signal; an imprecise one is recorded as `kind-signal:unreliable`, never silently counted.
- **Exclude vendored / copy / BUILD trees — let git decide, do NOT hand-maintain a name list** [MEASURED hazards,
  all from name-list drift: (a) `.opencode/worktrees/agent-…` agent copies added +3610 files / +473 duplicate
  `pages/api`, ~2.2×; (b) gitignored `tmp/` + `apps/web/.dist-stack/` build output added 28 compiled `.mjs`, 417→389].
  The GENERIC defense: in a git work tree, DRIVE THE WALK FROM GIT — `git ls-files` ∪ untracked-not-ignored — so
  `.gitignore` is the single source of truth (excludes node_modules, build output, nested worktrees/submodules in one
  stroke; still scans uncommitted source). A hardcoded prune list ALWAYS misses the next tool/build dir (`.opencode`,
  `.dist-stack` both bit us). FALLBACK (non-git tree, the gate must still run there): os.walk + a `PRUNE_DIRS` name
  list + prune any nested dir carrying a `.git` marker. A double-counted/ build entry inflates coverage (a
  no-false-coverage violation in the OPPOSITE direction). CAVEAT (fallback only): nested-`.git` prune also drops a
  legit submodule — map an in-scope submodule as its own root (git path omits submodule contents as a gitlink).
- A directory OR kind that matches NO convention is recorded as `not-enumerated:<dir>` /
  `not-enumerated:kind:<name>` with the reason — surfaced in the map, NEVER dropped silently.

### 2. prioritize — order the list [deterministic]
- Order by deterministic risk heuristics over the REAL list: path/identifier tokens (auth, payment,
  admin, webhook, token) first; mutation handlers (POST/PUT/PATCH/DELETE) before reads. This is the
  same priority spirit as `gate.py`'s `CRITICAL` regex (a HINT, not a gate) — reuse that token set.
- Priority only ORDERS dispatch under a budget; it never EXCLUDES (an un-dispatched target is
  `budget-dropped`, reported, not "clean").

### 3. dispatch + map — run the gate per target, prove coverage
- For each target (in priority order, up to `--max-targets`): invoke `gate.py <target> --report
  <out>` as a subprocess (the only public entry; no importable fn — confirmed). Pass through
  `--alias`, `--k`, `--depth`. Deps-as-context (#36) handles cross-file inside the gate.
- Emit a **coverage map** with three disjoint buckets — the surface-scale analog of the gate's
  per-file dropped-imports discipline:
  - `enumerated + scanned` — gate ran; link its report.
  - `enumerated + budget-dropped` — over `--max-targets`; reason recorded; raise budget to cover.
  - `not-enumerated` — dirs/files no convention matched; the blind spots, named explicitly.
- A surface map that omits an entry point silently reads as "covered." The three buckets make the
  omission auditable. **This is the hard invariant of the mapper.**

## Recall measurement — independent denominator [the load-bearing validation]
The mapper's recall question is "does it enumerate the entry points that actually exist?" — NOT "does
the gate catch the bug" (that's the gate's already-measured recall). The ONLY failure mode worth
measuring is the entry point the mapper **misses**, so the denominator must be a set the mapper can
fall short of — an INDEPENDENT ground truth, never the sample it was tuned on.

- **TWO measures, do NOT conflate (advisor 2nd pass).**
  - **Cross-kind: `kind_coverage_by_volume = enumerated / actual`** where `actual` = a permissive grep over the FULL
    kind-taxonomy signals on the real tree (superset), pruned of copies AND build output (git-driven walk →
    `.gitignore` is the single truth; the hardcoded name-list missed gitignored `tmp/`+`.dist-stack/` = 28 compiled
    `.mjs`). [MEASURED: multideal = 389 entry points, 0.99.] This measures what SHARE of the surface is of a kind the
    table has a row for. It does NOT validate that a
    kind's signal finds all its instances: `enumerate` and `actual` share the SAME per-kind signal, so per-rowed-kind
    recall is **1.0 BY CONSTRUCTION**. The only way a file is a cross-kind miss is matching SOLELY a kind with no
    convention row → the non-tautology rests entirely on missing KINDS (NOT off-path; an off-path hit is enumerated).
  - **Within-kind: the filesystem oracle (file-routed kinds only)** is the ONLY genuinely-independent within-kind
    denominator. For a file-routed kind a route is a FILESYSTEM fact (Astro/Next `pages/api/` files + app-router
    `app/**/route.ts`; CF Pages `functions/` files), matched by PATH SEGMENTS (NOT fnmatch — fnmatch `*` crosses `/`
    and a middle `**` silently drops direct-child routes; MEASURED: that bug hid 11 routes). `within_kind_recall =
    signal_hit / fs_routes` CAN drop below 1.0 — a route whose registration idiom the signal misses is a VISIBLE miss
    (`missed_sample` names it). [MEASURED: http-file-route 1.0 (376/376); edge-function 0.0 (0/2) — `functions/api/
    subscribe.{ts,js}` use `export default async (request) =>`, which the `onRequest` signal misses → #40.]
    Call-registered kinds (`defineApi`) have NO filesystem oracle → grep is best-available truth → ~1.0 per-kind is
    structural, stated not claimed. This is the de-risk measure; multideal is the v1 real tree (zync trio NOT out).
- **Why NOT canonical-`file` recall as the primary measure.** Every `corpus/*/canonical.json.file` is,
  by construction, an entry-point/route file selected as an audit target — a sample pre-filtered to be
  glob-findable. Scoring `enumerated / canonical` against it reports ~100% and proves only that the glob
  runs; it CANNOT exhibit a miss. A green there is a FALSE green. [advisor-caught: near-tautological.]
- **The path-faithful fixture fallback is BANNED as a recall measure.** A fixture tree built only from
  canonical paths is a tree of only-entry-points → enumerates 100% → measures nothing (the
  near-tautology in its sharpest form). Use a REAL tree or do not measure surface-recall.
- **Canonical-`file` routing stays as a SECONDARY confirmation** — that the security-relevant files
  specifically are covered, not as the recall denominator. It runs only against a checked-out
  corpus-source repo; with the zync trio absent, it is GATED on checking one out (or planting the
  equivalent shapes into multideal), and is reported as confirmation, never as recall.
- Add `mapper` mode to `bench.py`: build `actual` from the grep denominator over the real tree root,
  run the mapper, score `enumerated / actual` per kind, and report every `not-enumerated` miss (dir AND
  kind). Reuses `bench.py`'s ledger pattern; deterministic, ground-truth-from-source.
- **Honest scope:** this measures ROUTING recall (did we enumerate + send the gate to the right file),
  composed with the gate's already-measured CATCH recall. The product is end-to-end recall; report them
  separately, never conflate.

## Class-routing — ADD depth, never subtract [NEW — implements ARCHITECTURE §10]
Enumeration finds the targets; class-routing decides WHICH detectors run on each file. The invariants
are canonical in `docs/ARCHITECTURE.md` §10 (the floor, ADD-only cost levers, programmatic-first, the
specialist-routing gate, the band-3 precision bar) — do NOT re-derive them here. This section is the HOW.

**[DECISION 2026-06-19 — no SUBTRACT.]** `bench --routing` step 0 proved a `sink_signal` SUBTRACT redundant
(it collapses into `applies_to.signal`) and unsafe (an imprecise sink-grep = oracle-class noise). Cost
control is ADD-only: file-level enumeration + opt-in specialists. There is NO class-exclusion mechanism. See
ARCHITECTURE §10 [DECISION]. The rest of this section is ADD-only.

### The floor vs the routed depth
- **Floor (every scanned file, unconditional):** the deterministic band-3 detectors (wrapped FOSS + oracle).
  Selected by `registry.applicable_per_file` on `scope_globs` (file-type) — already wired. The floor is
  NEVER subtracted on a scanned file; the only file-level cost lever is NOT scanning a whole non-target
  file (`bench --mapper` enumeration + `.gateignore`).
- **Routed depth (surface targets only):** the LLM bands, selected by `applies_to` (`kinds` ∪ `signal` ∪
  `always`) in `orchestrator/detect.py:select_detectors`. `baseline.always=true` is the LLM breadth on
  every enumerated target; `finance/auth/access-control` ADD depth where their `signal`/`kind` fires. Absence
  of the signal = the specialist is simply not ADDED — never a class subtracted from a scanned file.

### Specialist routing is ADD-only — `applies_to.signal`, no `sink_signal`
- `applies_to.signal` ADDS a specialist pass on signal-PRESENCE. There is no SUBTRACT counterpart; a
  detector that should run on sink-presence is signal-GATED, which already yields nothing on sink-absence.
- A class with NO specialist (S4/S5/S6/S7/S10 today) rides `baseline` + the floor. Building a specialist for
  it is band-2/band-3 work governed by `delete-test-vs-LLM` + the **precision bar** (ARCHITECTURE §10.8):
  ship deterministic ONLY if agent-trustable (deps-grade); an imprecise heuristic stays LLM work.

### Specialist-routing recall — the shipping gate [the load-bearing measure]
`bench --routing`: for EVERY corpus cell, run class-routing over the cell's `vuln` file and assert **the
cell's INTENDED detector** — the class specialist (or the deterministic detector that ships a cell of that
class), **NOT merely "some always-on detector caught it"** — is selected.
- DO NOT assert "a catching detector is selected." `baseline.always=true` catches the corpus cells, so a
  baseline-inclusive assertion is trivially green and **measures baseline, not routing**. Discriminating
  check the gate MUST pass: **disable the class specialist → `bench --routing` for that class goes RED**
  (proven: `tests/test_bench.py::test_routing_excludes_baseline_DISCRIMINATOR`).
- The intended detector per cell = the class specialist where one exists (`detectors/<class>/`), else the
  deterministic detector whose dir contains the cell. `baseline` is EXCLUDED from the assertion.
- Statuses: ROUTED · MISS (intended specialist not selected → fatal) · BLIND_SPOT (class has no specialist —
  reported, excluded from rate) · REPO_SCOPE (deps: change-triggered on manifests, not per-file) ·
  XFAIL (cell-flagged `routing_xfail` — a KNOWN dead detector e.g. `headers`; reported, non-fatal).
- A routing miss (intended specialist not selected) is DISTINCT from enumeration-miss and catch-miss.
  Report all three separately; never conflate. This gate guards `applies_to` drift.

### Measured baseline (2026-06-19)
`bench --routing`: **recall 1.0** over 13 per-file-routable cells (S1→auth, S2/S3/S8→access-control,
S9→oracle, all right-reason, hits are the specialist never baseline). 5 BLIND_SPOT (S4/S5/S6/S7/S10 — no
specialist), 1 REPO_SCOPE (deps), 1 XFAIL (headers dead). Discriminator proven. The gate is a live regression
guard on specialist routing; it gates `applies_to` correctness, NOT a (nonexistent) SUBTRACT.

## Cross-target dedup — named for the plan [advisor-flagged, non-blocking]
Deps-as-context (#36) × per-target dispatch means a helper imported by N enumerated routes is inlined
and reviewed N times: cost multiplies, and a bug living in that helper is reported under all N targets.
`semantic_merge` (#17a) dedups findings WITHIN one target's rolls — it does NOT dedup ACROSS targets.
Cross-report dedup (collapse the same finding reported under multiple targets to one, keyed by
file+line+class) is NEW work the plan must carry, or the coverage map reads noisy. v1 may ship without
it IF the duplication is logged (no-false-coverage: a duplicated finding is visible, not hidden); the
plan decides build-now vs log-and-defer. Do NOT claim "Scalability: High" until this is addressed.

## Build order (pilot-before-fan-out)
0. **De-risk: enumerate ONLY, no dispatch, against a REAL tree with an INDEPENDENT denominator.** Point
   the enumerate step at `~/Projects/multideal` (the checked-out real tree; zync trio is not checked
   out). Build `actual` from the grep denominator (`defineApi(` + method-export + `onRequest*`),
   excluding nested worktrees/vendored trees, and measure `enumerated / actual`. This tests the
   load-bearing assumption (the convention table finds the entry points that genuinely exist) on a
   denominator the mapper CAN fall short of. A path-faithful canonical-only fixture is BANNED here (it
   enumerates 100% and proves nothing). If recall < 1.0, read the misses → fix the table before
   building dispatch. [mirrors the §8 step-0 spike discipline]
1. Add prioritize + dispatch + coverage map over the enumerated list (gate.py unchanged).
2. Add `bench.py mapper` mode → regression-lock kind_coverage_by_volume + within-kind filesystem-oracle recall.
3. Only then consider Approach-3 LLM priority (separate spec, separate validation).

## What this reuses (point, don't re-inline)
- `orchestrator/gate.py` — per-target audit, UNCHANGED. Invoked as subprocess with `--report`.
- `gate.py`'s `CRITICAL` token set — the priority hint; import or mirror, single source.
- `bench.py` ledger pattern + `canonical.json.file` — the recall ground truth.
- Project SoT `docs/specs/2026-06-17-security-gate-design.md` — §3 coverage/routing, §7 structure.

## Open one-way-doors — flag for the user, do NOT silently fix
- **v1 framework scope.** v1 convention table targets the corpus's stack (zync TS web routes). Broader
  language/framework coverage is additive (more table rows) but each needs its own within-kind recall cell.
  Default: scope v1 to where the ground truth lives; expand per measured need. [DECISION, reversible]
- **Approach-3 LLM priority** is deferred, not rejected — revisit after A1 recall is measured.

## Architecture Decisions (deletion / single-adapter / seam audit)
- **enumerate** — passes deletion test (its complexity — convention matching + no-false-coverage
  bucketing — would scatter into the dispatcher). KEEP. Depth: medium.
- **prioritize** — borderline: a thin deterministic ordering. Does NOT earn a separate file in v1 →
  **collapse into mapper.py as a function**, not its own unit (single-adapter: only one ordering
  exists). Promote to a unit only if Approach-3 adds an LLM ordering adapter.
- **dispatch + map** — passes deletion test (subprocess orchestration + 3-bucket coverage proof is
  non-trivial). KEEP. Depth: medium.
- **mapper as a whole vs folding into gate.py** — KEEP separate: gate.py's contract is "one file";
  conflating surface enumeration into it would break its single responsibility and its byte-identical
  single-file path. Deletion test passes.
- Rejected: Approach 2 (unmeasurable). Deferred: Approach 3 (premature second LLM leg).
