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

> **For agentic workers:** REQUIRED SUB-SKILL: Use /ship (recommended) or /executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

audience: AI coding agents first. BLUF-ordered, imperative.

**Goal:** Build a deterministic surface mapper above `gate.py` that ENUMERATES a real repo's attack surface (entry-point files), PRIORITIZES, DISPATCHES `gate.py` per entry point, and emits a 3-bucket coverage map — proving WHAT it covered and naming what it didn't.

**Architecture:** New `orchestrator/mapper.py` + a data-only convention table `orchestrator/conventions.py`, calling the UNCHANGED `gate.py` as a subprocess (`--report`). Recall is measured `enumerated / actual` against an INDEPENDENT grep-derived denominator on a real tree (`~/Projects/multideal`), NOT against the canonical sample it was tuned on. SoT = `docs/specs/2026-06-17-attack-surface-mapping-design.md`.

**Tech Stack:** Python 3 (stdlib only: `os`, `re`, `glob`, `subprocess`, `json`, `argparse`, `dataclasses`), pytest. Reuses `gate.CRITICAL` regex and `bench.py`'s ledger pattern.

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | Task 1 | `orchestrator/conventions.py` | single task |
| 2 | Task 2 | `orchestrator/mapper.py`, `tests/test_mapper.py` | single task (create both) |
| 3 | Task 3 | `orchestrator/mapper.py`, `tests/test_mapper.py` | single task (modify) |
| 4 | Task 4 | `orchestrator/mapper.py`, `tests/test_mapper.py` | single task (modify) |
| 5 | Task 5 | `bench.py`, `tests/test_mapper.py` | single task |

All tasks touch `mapper.py` or its test sequentially → no parallel wave (file overlap). Single-task waves throughout; this is the pilot-before-fan-out shape (each wave's acceptance gates the next).

## File Structure

- `orchestrator/conventions.py` — DATA: `KIND_SIGNALS` (full entry-point-kind taxonomy → permissive grep regex, the denominator superset) + `CONVENTIONS` (the SUBSET the mapper actually discovers: path-glob + defn-signal per kind). One responsibility: declare what an entry point looks like. No logic.
- `orchestrator/mapper.py` — LOGIC: `enumerate_surface()` (walk + match + bucket), `actual_surface()` (grep denominator), `prioritize()` (order, reuse `gate.CRITICAL`), `dispatch()` (subprocess `gate.py`), `coverage_map()` (3 buckets), `main()` (CLI). Calls gate.py unchanged.
- `bench.py` — add `--mapper REPO_ROOT` mode: score `enumerated / actual` per kind + canonical-`file` secondary confirmation.
- `tests/test_mapper.py` — pytest, mirrors `tests/test_bench.py` style.

## The one decision rule (encode in every task)
**Enumeration is deterministic and repo-grounded. NEVER let a model invent the route/entry-point list.** No LLM in the mapper's enumeration/priority path in v1. The LLM stays inside `gate.py`.

---

### Task 1: Convention table (data) — kind taxonomy + denominator signals

**Wave:** 1
**Blocks:** Task 2, Task 3, Task 4, Task 5
**Blocked by:** —

**Files:**
- Create: `orchestrator/conventions.py`

- [ ] **Step 1: Write the data module**

The denominator (`KIND_SIGNALS`) is a PERMISSIVE superset — every entry-point kind's call/decorator signal, grep-able repo-wide. `CONVENTIONS` is the SUBSET the v1 mapper actually discovers (path-glob + the same signal). cross-kind `kind_coverage_by_volume` < 1.0 exactly when a kind has a denominator signal but no `CONVENTIONS` row. [CORRECTION, advisor 2nd pass: the original "OR a signal fires outside the row's glob" was WRONG — the path-glob is a HINT not a gate, so an off-path hit is still enumerated and CANNOT drive recall below 1.0; per-rowed-kind recall is 1.0 by construction. Within-kind signal completeness is measured separately by the filesystem oracle, file-routed kinds only — see the corrections section at the end.] A kind in `KIND_SIGNALS` with NO `CONVENTIONS` row is the named blind class.

```python
"""Entry-point convention table — DATA, not logic. SoT: docs/specs/2026-06-17-attack-surface-mapping-design.md.
KIND_SIGNALS = the independent DENOMINATOR (permissive superset of what counts as an entry point, grep repo-wide).
CONVENTIONS  = the SUBSET the v1 mapper discovers (path hint + the same signal). A kind in KIND_SIGNALS with no
CONVENTIONS row is a named blind class -> reported `not-enumerated:kind:<name>`, never silently dropped.
A substring grep is NOT a signal (a naive webhook|queue|cron grep on multideal matched country-codes.ts / an i18n
store). Each signal is a precise call/decorator/export pattern; mark imprecise ones reliable=False."""
import re

# kind -> (precise signal regex, reliable). reliable=False => counted but flagged kind-signal:unreliable.
KIND_SIGNALS = {
    "http-defn-call":   (re.compile(r"\bdefineApi\s*\(|\bcreateRoute\s*\(|\bdefineEventHandler\s*\("), True),
    "http-file-route":  (re.compile(r"^export\s+(?:const|async\s+function)\s+(?:GET|POST|PUT|PATCH|DELETE)\b", re.M), True),
    "edge-function":    (re.compile(r"\bonRequest[A-Za-z]*\s*[=(]"), True),
    "webhook-receiver": (re.compile(r"verif(?:y|ied)Signature|constructEvent|x-hub-signature|stripe-signature", re.I), True),
    "queue-consumer":   (re.compile(r"\.queue\s*\(|export\s+(?:const|default)\s+\{?\s*queue\b|consumer\s*:"), True),
    "cron-scheduled":   (re.compile(r"\bscheduled\s*\(|export\s+(?:const|default)\s+\{?\s*scheduled\b"), True),
    # declared, v1 has NO discovery row -> these surface as not-enumerated:kind:* until a CONVENTIONS row is added
    "graphql-resolver": (re.compile(r"@Resolver\b|createResolver\s*\(|Query\s*:\s*\{"), False),
    "auth-middleware":  (re.compile(r"\buse(?:Auth|Guard)\b|requireAuth\s*\(|authMiddleware\b"), False),
    "server-action-rpc":(re.compile(r"['\"]use server['\"]|createServerFn\s*\("), False),
    "cli-command":      (re.compile(r"\.command\s*\(|defineCommand\s*\("), False),
}

# the SUBSET the v1 mapper discovers. path_globs is a HINT for priority + reporting, NOT a gate: a defn-signal
# hit OUTSIDE these globs is still enumerated (the signal is primary). kind must key into KIND_SIGNALS.
CONVENTIONS = [
    {"kind": "http-defn-call",  "path_globs": ["**/server/api/**", "**/api/**"]},
    {"kind": "http-file-route", "path_globs": ["**/pages/api/**", "**/routes/**"]},
    {"kind": "edge-function",   "path_globs": ["**/functions/**"]},
]

# trees that are COPIES/vendored -> excluded from BOTH enumerate and denominator (double-count = false coverage).
PRUNE_DIRS = ("node_modules", ".git", ".claude", ".worktrees", "dist", "build", ".next", ".astro")
```

- [ ] **Step 2: Verify it imports and the signals compile**

Run: `cd ~/Projects/security-gate && python3 -c "from orchestrator import conventions as c; print(len(c.KIND_SIGNALS), len(c.CONVENTIONS), c.KIND_SIGNALS['http-defn-call'][0].search('x = defineApi({')  is not None)"`
Expected: `10 3 True`

- [ ] **Step 3: Commit**

```bash
cd ~/Projects/security-gate && git add orchestrator/conventions.py && command git commit -m "mapper: convention table — kind taxonomy + denominator signals"
```

---

### Task 2: `enumerate_surface` + `actual_surface` + the enumerate-only de-risk spike

**Wave:** 2
**Blocks:** Task 3, Task 4, Task 5
**Blocked by:** Task 1

**Files:**
- Create: `orchestrator/mapper.py`
- Test: `tests/test_mapper.py`

- [ ] **Step 1: Write the failing test (fixture tree with a deliberate miss)**

The fixture plants one entry point a glob would catch (under `api/`) and one the glob MISSES but the denominator catches (a `defineApi` in `lib/`), so recall MUST be < 1.0 — the test would be tautological otherwise.

```python
# tests/test_mapper.py
import os, tempfile, textwrap
from orchestrator import mapper

def _tree():
    d = tempfile.mkdtemp()
    os.makedirs(os.path.join(d, "src/server/api"), exist_ok=True)
    os.makedirs(os.path.join(d, "src/lib"), exist_ok=True)
    os.makedirs(os.path.join(d, "node_modules/pkg"), exist_ok=True)
    open(os.path.join(d, "src/server/api/orders.ts"), "w").write("export const x = defineApi({ handler() {} })")
    open(os.path.join(d, "src/lib/hidden.ts"), "w").write("export const y = defineApi({ handler() {} })")  # glob-missed
    open(os.path.join(d, "node_modules/pkg/route.ts"), "w").write("export const z = defineApi({})")  # pruned
    return d

def test_actual_excludes_pruned_trees():
    d = _tree()
    actual = mapper.actual_surface(d)
    paths = {os.path.relpath(p, d) for p, _kind in actual}
    assert "node_modules/pkg/route.ts" not in paths  # pruned, never double-counts
    assert "src/server/api/orders.ts" in paths
    assert "src/lib/hidden.ts" in paths  # denominator catches the glob-missed one

def test_enumerate_recall_below_one_when_table_misses():
    d = _tree()
    enumerated = {os.path.relpath(p, d) for p, _ in mapper.enumerate_surface(d)}
    actual = {os.path.relpath(p, d) for p, _ in mapper.actual_surface(d)}
    # [SUPERSEDED — off-path mechanism is WRONG: an off-path defineApi IS enumerated. The SHIPPED test instead plants
    #  a queue-consumer (no-row KIND) so it is genuinely not enumerated → recall < 1.0. See corrections #4.]
    assert actual - enumerated == {"src/lib/hidden.ts"} or "src/lib/hidden.ts" not in enumerated
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd ~/Projects/security-gate && python3 -m pytest tests/test_mapper.py -x -q`
Expected: FAIL with `ModuleNotFoundError` / `AttributeError: module 'orchestrator.mapper' has no attribute`.

- [ ] **Step 3: Write `mapper.py` enumerate + denominator**

```python
"""Surface mapper — deterministic. Enumerate the attack surface, prove coverage, dispatch gate.py per target.
SoT: docs/specs/2026-06-17-attack-surface-mapping-design.md. NEVER let a model invent the entry-point list."""
import os, fnmatch
from dataclasses import dataclass
from orchestrator.conventions import KIND_SIGNALS, CONVENTIONS, PRUNE_DIRS

@dataclass(frozen=True)
class Entry:
    path: str
    kind: str
    why: str  # which signal/glob matched — for the coverage map

def _walk_ts(root):
    for dp, dns, fns in os.walk(root):
        dns[:] = [d for d in dns if d not in PRUNE_DIRS]
        for fn in fns:
            if fn.endswith((".ts", ".tsx", ".js", ".mjs")):
                yield os.path.join(dp, fn)

def actual_surface(root):
    """INDEPENDENT denominator: every file whose CONTENT matches any kind signal (permissive superset),
    pruned trees excluded. Returns [(abspath, kind)]. A file may match >1 kind; first reliable kind wins."""
    out = []
    for p in _walk_ts(root):
        try:
            body = open(p, encoding="utf-8", errors="replace").read()
        except OSError:
            continue
        for kind, (rx, _reliable) in KIND_SIGNALS.items():
            if rx.search(body):
                out.append((os.path.abspath(p), kind))
                break
    return out

def enumerate_surface(root):
    """The mapper's ACTUAL discovery: a file is enumerated if a CONVENTIONS row's kind-signal hits in it
    (signal is primary; path_glob only sets `why`/priority). Subset of actual_surface -> recall measurable."""
    rows_by_kind = {}
    for c in CONVENTIONS:
        rows_by_kind.setdefault(c["kind"], []).extend(c["path_globs"])
    out = []
    for p in _walk_ts(root):
        rel = p.replace(os.sep, "/")
        try:
            body = open(p, encoding="utf-8", errors="replace").read()
        except OSError:
            continue
        for kind, globs in rows_by_kind.items():
            rx, _ = KIND_SIGNALS[kind]
            if rx.search(body):
                in_glob = any(fnmatch.fnmatch(rel, g) for g in globs)
                out.append(Entry(os.path.abspath(p), kind, f"signal:{kind}" + ("+glob" if in_glob else "+offpath")))
                break
    return out
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd ~/Projects/security-gate && python3 -m pytest tests/test_mapper.py -x -q`
Expected: PASS (2 passed).

- [ ] **Step 5: De-risk spike — enumerate-only against the REAL tree, measure `enumerated / actual`**

This is the load-bearing pilot (§Build order step 0). NO dispatch, NO LLM. A canonical-only fixture is BANNED here.

Run:
```bash
cd ~/Projects/security-gate && python3 -c "
from orchestrator import mapper
R='/home/user/Projects/multideal'
act=mapper.actual_surface(R); enr={e.path for e in mapper.enumerate_surface(R)}
ap={p for p,_ in act}
print('actual',len(ap),'enumerated',len(ap&enr),'recall',round(len(ap&enr)/max(1,len(ap)),3))
from collections import Counter
miss=Counter(k for p,k in act if p not in enr)
print('not-enumerated by kind:',dict(miss))
"
```
Expected: prints `actual N enumerated M recall R` with R < 1.0 and a `not-enumerated by kind:` breakdown dominated by kinds with NO `CONVENTIONS` row (`queue-consumer`, `cron-scheduled`, `webhook-receiver`, declared-only kinds). **Decision gate:** if a kind that SHOULD have a row (`http-defn-call`/`http-file-route`/`edge-function`) appears in the miss breakdown, the table glob/signal is wrong — fix `conventions.py` before Task 3. If misses are only rows-not-yet-added kinds, that is expected and correct (coverage grows by adding rows).

- [ ] **Step 6: Commit**

```bash
cd ~/Projects/security-gate && git add orchestrator/mapper.py tests/test_mapper.py && command git commit -m "mapper: enumerate + independent denominator + real-tree de-risk spike"
```

---

### Task 3: `prioritize` — order targets (reuse gate.CRITICAL, never exclude)

**Wave:** 3
**Blocks:** Task 4, Task 5
**Blocked by:** Task 2

**Files:**
- Modify: `orchestrator/mapper.py`
- Test: `tests/test_mapper.py`

- [ ] **Step 1: Write the failing test**

```python
def test_prioritize_orders_critical_first_never_drops():
    from orchestrator.mapper import Entry, prioritize
    es = [Entry("/r/feed.ts", "http-file-route", "x"),
          Entry("/r/payout.ts", "http-defn-call", "x"),   # CRITICAL token 'payout'
          Entry("/r/profile.ts", "http-file-route", "x")]
    ordered = prioritize(es)
    assert ordered[0].path == "/r/payout.ts"      # critical first
    assert len(ordered) == len(es)                # never excludes — only orders
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd ~/Projects/security-gate && python3 -m pytest tests/test_mapper.py::test_prioritize_orders_critical_first_never_drops -x -q`
Expected: FAIL (`cannot import name 'prioritize'`).

- [ ] **Step 3: Add `prioritize` to `mapper.py`**

```python
import sys, importlib.util
def _critical():
    """Reuse gate.py's CRITICAL regex — single source for the priority token set (NOT a gate, just ordering)."""
    spec = importlib.util.spec_from_file_location("_gate", os.path.join(os.path.dirname(__file__), "gate.py"))
    m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
    return m.CRITICAL

def prioritize(entries):
    """Order by deterministic risk: CRITICAL-token path first, then mutation-kinds, then the rest. ORDERS,
    NEVER EXCLUDES — an un-dispatched target becomes `budget-dropped` in the map, never silently `clean`."""
    crit = _critical()
    def rank(e):
        return (0 if crit.search(e.path) else 1, 0 if e.kind in ("http-defn-call", "edge-function") else 1, e.path)
    return sorted(entries, key=rank)
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd ~/Projects/security-gate && python3 -m pytest tests/test_mapper.py -x -q`
Expected: PASS (3 passed).

- [ ] **Step 5: Commit**

```bash
cd ~/Projects/security-gate && git add orchestrator/mapper.py tests/test_mapper.py && command git commit -m "mapper: prioritize — gate.CRITICAL ordering, never excludes"
```

---

### Task 4: `dispatch` + `coverage_map` + `main` — run gate.py per target, prove 3-bucket coverage

**Wave:** 4
**Blocks:** Task 5
**Blocked by:** Task 3

**Files:**
- Modify: `orchestrator/mapper.py`
- Test: `tests/test_mapper.py`

- [ ] **Step 1: Write the failing test (dispatch is mocked — no LLM in unit tests)**

```python
def test_coverage_map_three_disjoint_buckets():
    from orchestrator.mapper import Entry, coverage_map
    enumerated = [Entry("/r/a.ts", "http-defn-call", "x"), Entry("/r/b.ts", "http-file-route", "x")]
    actual = [("/r/a.ts", "http-defn-call"), ("/r/b.ts", "http-file-route"), ("/r/q.ts", "queue-consumer")]
    scanned = {"/r/a.ts": "reports/a.md"}  # only a.ts dispatched (budget)
    cmap = coverage_map(enumerated, actual, scanned, max_targets=1)
    assert cmap["enumerated_scanned"] == [{"path": "/r/a.ts", "report": "reports/a.md"}]
    assert cmap["enumerated_budget_dropped"] == ["/r/b.ts"]                      # enumerated, not dispatched
    assert cmap["not_enumerated"] == [{"path": "/r/q.ts", "kind": "queue-consumer"}]  # in actual, no table row
    # disjoint: no path appears in two buckets
    seen = [x["path"] if isinstance(x, dict) else x for b in cmap.values() for x in b]
    assert len(seen) == len(set(seen))
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd ~/Projects/security-gate && python3 -m pytest tests/test_mapper.py::test_coverage_map_three_disjoint_buckets -x -q`
Expected: FAIL (`cannot import name 'coverage_map'`).

- [ ] **Step 3: Add `dispatch`, `coverage_map`, `main` to `mapper.py`**

```python
import subprocess, json, argparse

def dispatch(entry, out_dir, gate_args):
    """Invoke the UNCHANGED gate.py as a subprocess: gate.py <target> --report <out> + passthrough flags.
    Returns the report path on success, None on gate failure (logged by caller, never silently 'clean')."""
    os.makedirs(out_dir, exist_ok=True)
    rep = os.path.join(out_dir, os.path.basename(entry.path) + ".report.md")
    cmd = [sys.executable, os.path.join(os.path.dirname(__file__), "gate.py"), entry.path, "--report", rep] + gate_args
    r = subprocess.run(cmd, capture_output=True, text=True)
    return rep if r.returncode == 0 and os.path.isfile(rep) else None

def coverage_map(enumerated, actual, scanned, max_targets):
    """3 DISJOINT buckets — the hard invariant. A surface that omits an entry point silently reads as 'covered';
    these buckets make every omission auditable. scanned = {path: report_path}. cross-target dedup is NOT done
    here (#advisor-4: a helper imported by N targets is reviewed N times) — v1 logs duplication, defers dedup."""
    enr_paths = {e.path for e in enumerated}
    return {
        "enumerated_scanned": [{"path": p, "report": r} for p, r in scanned.items()],
        "enumerated_budget_dropped": sorted(p for p in enr_paths if p not in scanned),
        "not_enumerated": sorted(({"path": p, "kind": k} for p, k in actual if p not in enr_paths),
                                 key=lambda d: d["path"]),
    }

def main():
    ap = argparse.ArgumentParser(description="deterministic attack-surface mapper over gate.py")
    ap.add_argument("repo_root")
    ap.add_argument("--max-targets", type=int, default=25)
    ap.add_argument("--out-dir", default="mapper-reports")
    ap.add_argument("--map", default=None, help="write coverage map JSON here")
    args, gate_args = ap.parse_known_args()  # unknown flags (--alias/--k/--depth/--config-dir) pass to gate.py
    enr = prioritize(enumerate_surface(args.repo_root))
    act = actual_surface(args.repo_root)
    scanned = {}
    for e in enr[:args.max_targets]:
        rep = dispatch(e, args.out_dir, gate_args)
        if rep:
            scanned[e.path] = rep
        else:
            print(f"[gate-failed] {e.path} — NOT clean, re-run", file=sys.stderr)
    cmap = coverage_map(enr, act, scanned, args.max_targets)
    js = json.dumps(cmap, indent=2)
    (open(args.map, "w").write(js) if args.map else print(js))

if __name__ == "__main__":
    main()
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd ~/Projects/security-gate && python3 -m pytest tests/test_mapper.py -x -q`
Expected: PASS (4 passed).

- [ ] **Step 5: Verify the full chain dry — enumerate→prioritize→map against multideal (dispatch with gate `--dry-run`, NO LLM)**

Run: `cd ~/Projects/security-gate && python3 orchestrator/mapper.py /home/user/Projects/multideal --max-targets 3 --dry-run --map /tmp/mapper-map.json && python3 -c "import json;m=json.load(open('/tmp/mapper-map.json'));print({k:len(v) for k,v in m.items()})"`
Expected: prints bucket sizes, e.g. `{'enumerated_scanned': 3, 'enumerated_budget_dropped': N, 'not_enumerated': M}` with M>0 (the named blind kinds). `--dry-run` passes through to gate.py so no LLM fires.

- [ ] **Step 6: Commit**

```bash
cd ~/Projects/security-gate && git add orchestrator/mapper.py tests/test_mapper.py && command git commit -m "mapper: dispatch + 3-bucket coverage map + CLI (gate.py subprocess, unchanged)"
```

---

### Task 5: `bench.py mapper` mode — regression-lock surface-recall + canonical confirmation

**Wave:** 5
**Blocks:** —
**Blocked by:** Task 4

**Files:**
- Modify: `bench.py`
- Test: `tests/test_mapper.py`

- [ ] **Step 1: Write the failing test**

```python
def test_bench_mapper_scores_recall_and_canonical_confirmation():
    import subprocess, sys, json, os
    root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))  # project root
    # multideal is the real tree; skip cleanly if not checked out (CI portability)
    if not os.path.isdir("/home/user/Projects/multideal"):
        import pytest; pytest.skip("real tree not checked out")
    r = subprocess.run([sys.executable, os.path.join(root, "bench.py"), "--mapper", "/home/user/Projects/multideal"],
                       capture_output=True, text=True)
    assert r.returncode == 0, r.stderr
    out = json.loads(r.stdout)
    assert 0.0 <= out["surface_recall"] <= 1.0
    assert "by_kind" in out and "not_enumerated_kinds" in out
    assert "canonical_confirmation" in out  # secondary; "gated: zync trio not checked out" is a valid value
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd ~/Projects/security-gate && python3 -m pytest tests/test_mapper.py::test_bench_mapper_scores_recall_and_canonical_confirmation -x -q`
Expected: FAIL (bench.py has no `--mapper` flag → non-zero exit / KeyError).

- [ ] **Step 3: Add `--mapper` mode to `bench.py`**

Add the flag in `main()` and a `cmd_mapper(repo_root)` function. Primary = `enumerated / actual` per kind. Secondary = canonical-`file` confirmation, GATED (the zync trio is not checked out → report the gate, never a false 100%).

```python
# in bench.py, add near the other cmd_* functions:
def cmd_mapper(repo_root):
    import os
    from collections import Counter
    sys.path.insert(0, os.path.join(ROOT, "orchestrator"))
    from orchestrator import mapper
    act = mapper.actual_surface(repo_root)
    enr = {e.path for e in mapper.enumerate_surface(repo_root)}
    ap = {p for p, _ in act}
    hit = ap & enr
    by_kind = {}
    for p, k in act:
        d = by_kind.setdefault(k, {"actual": 0, "enumerated": 0})
        d["actual"] += 1
        d["enumerated"] += 1 if p in enr else 0
    not_enum_kinds = sorted({k for p, k in act if p not in enr and k not in
                             {kk for kk, _ in [(c["kind"], 0) for c in __import__('orchestrator.conventions',
                              fromlist=['CONVENTIONS']).CONVENTIONS]}})
    # secondary: are the security-relevant canonical files enumerated? gated on the source repo being present.
    cells = load_cells()
    canon_files = {c.get("file") for c in cells if c.get("file")}
    present = {f for f in canon_files if os.path.isfile(os.path.join(repo_root, f))}
    if not present:
        canon_conf = "gated: no canonical source file present under repo_root (zync trio not checked out)"
    else:
        enr_rel = {os.path.relpath(p, repo_root) for p in enr}
        canon_conf = {"present": len(present), "enumerated": len(present & enr_rel)}
    return {
        "surface_recall": round(len(hit) / max(1, len(ap)), 3),
        "actual": len(ap), "enumerated": len(hit),
        "by_kind": by_kind,
        "not_enumerated_kinds": not_enum_kinds,
        "canonical_confirmation": canon_conf,
    }
```

Wire it in `main()`:

```python
    ap.add_argument("--mapper", help="REPO_ROOT: score enumerated/actual surface-recall on a real tree")
    # ... after parsing:
    if a.mapper:
        print(json.dumps(cmd_mapper(a.mapper), indent=2)); return
```

- [ ] **Step 4: Run tests to verify they pass + full suite green**

Run: `cd ~/Projects/security-gate && python3 -m pytest tests/ -q`
Expected: PASS — all `tests/test_mapper.py` + existing `test_bench.py` (2) + `test_s11_band3.py` (5) green, no regression.

- [ ] **Step 5: Commit**

```bash
cd ~/Projects/security-gate && git add bench.py tests/test_mapper.py && command git commit -m "bench: mapper mode — surface-recall per kind + gated canonical confirmation"
```

---

## Self-Review

**1. Spec coverage:** enumerate (Task 2), prioritize (Task 3), dispatch+3-bucket map (Task 4), recall=`enumerated/actual` independent denominator (Task 2 spike + Task 5 bench), kind taxonomy (Task 1), worktree/vendored exclusion (Task 1 `PRUNE_DIRS` + Task 2 test), cross-target dedup named + deferred (Task 4 docstring), canonical confirmation gated (Task 5). One-decision-rule (no model in enumeration) honored — no LLM in mapper. All spec sections map to a task.

**2. Placeholder scan:** no TBD/TODO; every code step is complete runnable code; every run step has an exact command + expected output. The declared-only kinds (`graphql-resolver` etc.) are intentional named blind classes, not placeholders — they are reported, not stubbed.

**3. Type consistency:** `Entry(path, kind, why)` dataclass defined Task 2, used identically in Tasks 3/4 tests. `actual_surface` returns `[(path, kind)]`, `enumerate_surface` returns `[Entry]` — consistent across `coverage_map`/`cmd_mapper`. `KIND_SIGNALS` values are `(regex, reliable)` tuples everywhere. `prioritize` in/out = `[Entry]`.

**4. Wave plan check:** every task has Wave/Blocks/Blocked-by. All single-task waves (every task touches `mapper.py` or `bench.py` sequentially) — zero parallel waves, so no file-overlap risk. Topological order holds: Task 1 → 2 → 3 → 4 → 5, each blocked-by the prior. The Task 5 `not_enum_kinds` line uses a convoluted inline import — implementer should simplify to `from orchestrator.conventions import CONVENTIONS` at function top (noted; functional as written).

## Build corrections (post-execution, 2026-06-17) — plan kept non-stale

Executed inline (5 single-task waves are sequential; the empirical multideal gates are the real correctness check).
Three corrections vs the plan-as-written, all caught by verification BEFORE/DURING build:

1. **Import convention.** The project has NO `__init__.py` and loads modules BY PATH (`importlib.util.spec_from_file_location`,
   mirrors `tests/test_s11_band3.py`). The plan's `from orchestrator import conventions` would fail + unilaterally
   restructure the project. CORRECTED: `mapper.py`/`tests`/`bench.py` load `conventions.py`/`mapper.py`/`gate.py` by
   absolute path. `conventions.py` imports no project code → loads identically everywhere.
2. **queue-consumer / cron-scheduled signals (TDD caught).** The plan's `.queue(` matched PRODUCERS (a sink, not an
   entry point) and missed the real Cloudflare Workers CONSUMER idiom `export default { async queue(batch, env) }`.
   CORRECTED to `\basync\s+queue\s*\(|\bqueue\s*\([^)]*\bbatch\b` (and the scheduled analog) — precise handler match,
   no producer false-positive.
3. **bench `cmd_mapper` not_enum_kinds** — simplified per self-review note #4 (clean path-load of `CONVENTIONS`, no
   convoluted inline `__import__`).

**Post-execution advisor review (2026-06-17) — FOUR MORE corrections (the green tests could not see these):**
4. **The 0.982 "recall" was kind-coverage-by-volume, NOT signal validation.** `enumerate` and `actual` share the same
   per-kind regex → per-rowed-kind recall is 1.0 BY CONSTRUCTION; the metric only shows no-row kinds as misses. The
   "off-path → recall < 1.0" mechanism in this plan + the spec was non-operative (off-path hits are enumerated).
   CORRECTED: renamed to `kind_coverage_by_volume`; reframed the spec + validation doc; the off-path claim is deleted.
   Added the genuinely-independent within-kind FILESYSTEM ORACLE (file-routed kinds only). RECORDED residual: the
   largest kind (`http-defn-call`, 185, ~48%) is call-registered → NO filesystem oracle → within-kind UNVALIDATED.
5. **Count inflation ~2.2× (the within-kind oracle's missed_sample exposed it).** `PRUNE_DIRS` missed multideal's
   `.opencode/worktrees` (4 agent copies = +3610 files). The spec already required "any nested `.git` root" pruning;
   the CODE had drifted. CORRECTED: generic nested-`.git` prune in `_walk_ts` + `.opencode` in the name list.
6. **Filesystem-oracle glob undercount.** fnmatch `**/pages/api/**/*.ts` dropped direct-child endpoints (fnmatch `*`
   crosses `/`; a middle `**` requires an intermediate dir). 11 routes invisible. CORRECTED: path-SEGMENT matching.
7. **Build-artifact pollution (caught by the advisor's verify-the-claim pass).** Triaging the 14 "edge-function"
   detections exposed 12 as COMPILED BUILD OUTPUT (`tmp/`, `apps/web/.dist-stack/` — gitignored, but absent from the
   hardcoded `PRUNE_DIRS` and carrying no `.git` marker). 28 build artifacts inflated "417" → real surface **389**.
   CORRECTED: the walk now drives off git (`ls-files` ∪ untracked-not-ignored) so `.gitignore` is the single source of
   truth (subsumes the name-list + nested-`.git` prunes); os.walk fallback for non-git trees. ALSO confirmed
   edge-function is doubly broken (precision: its 2 real detections are Astro middleware + a JSX false-positive;
   recall: misses both real `functions/` routes) → #40.

**Measured result [MEASURED, post-advisor-2nd-pass]:** 14 tests pass; multideal `kind_coverage_by_volume` **0.99**
(385/389), all 4 misses = no-row kinds (webhook 2, cron 1, queue 1); within-kind filesystem oracle: http-file-route
**1.0 (376/376)**, edge-function **0.0 (0/2)** — `functions/api/subscribe.{ts,js}` are real CF Pages functions the
`onRequest` signal misses → #40; `http-defn-call` (~48%) within-kind UNVALIDATED → #40. Real n=1 dispatch (post-fix)
wrote+linked a report (`scanned 1 / budget-dropped 384 / not-enum 4`, top = payouts.ts). Evidence:
`docs/validation/2026-06-17-attack-surface-mapper-shipped.md`. Commits `bc2d259`, `9208c91`, `2c7d6c2`, + this pass.

## Execution Handoff

Plan complete and saved to `docs/plans/2026-06-17-attack-surface-mapping.md`. EXECUTED inline (see Build corrections);
shipped + measured. Remaining: cross-target dedup (advisor #4, deferred), per-kind discovery rows for the 5 named
blind kinds, #40 cross-file alias resolution.
