# Modular domain detectors + the DETECT orchestrator — design

audience: AI coding agents first. BLUF-ordered, imperative. Tag [MEASURED]/[INFERRED]. Testing-native vocab +
licensed `adapter`/loose `module`. SoT for the build; extends `docs/specs/2026-06-18-modular-hierarchy-design.md`
(component classification) and `docs/specs/2026-06-17-attack-surface-mapping-design.md` (the map). Does not redesign
those — builds the piece they deferred: per-domain LLM detectors + the Opus orchestrator that routes the map to them.

## BLUF — what this builds and why

**This tool is a SECURITY gate. Finance is ONE domain module; payments is a sub-concern INSIDE finance.** Today the
LLM band is a single prompt titled *"Payments & Financial Module"* (`detectors/security-generalist.prompt.txt`) that
`gate.py` runs on EVERY target — so the gate reviews all code as if it were finance. That is the defect. The fix:

1. **Per-domain detectors.** Decompose the LLM band into one detector per security domain (auth, access-control,
   injection, xss, ssrf, webhook, finance, crypto, secrets, request-integrity, file-io, resource) + an always-on
   multi-class **baseline**. Each is a `domains/security/detectors/<domain>/` unit with a manifest + prompt.
2. **The Opus DETECT orchestrator.** Reuse-or-create the attack-surface map (`mapper.py`), then per target TRIAGE to
   the applicable detector set and dispatch (k=1 per detector), union → semantic-merge → report. Never a false clean.
3. **Scrub the finance-tool identity** everywhere it claims the TOOL is a payments/finance tool.

**Goal:** 99% REAL-CATCH coverage across all domains (real catch = a true defect flagged with right-reason, k≥3
ROBUST). Defer the hard ~1% (elusive C02-class) to the deferred dice-rolling phase. Coverage anchored on OWASP ASVS
breadth + CWE Top-25 + the S1–S11 worked set (`docs/taxonomy/security.md`) — cover EVERYTHING, not the finance slice.

## Identity correction — scrap "finance tool" framing

The gate's identity is **security**, decomposed by domain. Scrub every place that frames the TOOL (not the finance
detector) as a payments/finance tool:
- `detectors/security-generalist.prompt.txt` title/preamble → SPLIT: its money-specific passes move into the new
  `finance` detector; its domain-agnostic adversarial method (map module → trust boundary → input validation →
  authz → error/atomicity) becomes the `baseline` detector, re-headed as general security review.
- `CLAUDE.md`, `docs/**` — any line presenting the gate as payments-first → reframe as security-first, finance as
  one domain. (Enumerate via `grep -rniE "payments? (and|&) financial|finance tool|payments tool" docs CLAUDE.md`
  during the scrub task; reframe, never silently delete a MEASURED finding — payments-overfit RESULTS stay as
  evidence, only the IDENTITY claim changes.)

## The domain detector set — the coverage contract

Each row = one detector unit `domains/security/detectors/<id>/`. `kind: llm` = a prompt; `kind: deterministic` = a
`.py` leg (already built). `applies_to` = the attack-surface KINDS (`conventions.KIND_SIGNALS`) + content triggers
that route it. A domain with 0 detector = a BLIND SPOT (never reported covered).

| id | kind | covers (S-class / OWASP-CWE) | applies_to (route trigger) |
|----|------|------------------------------|----------------------------|
| **baseline** | llm | ALL classes, domain-agnostic — the recall floor (A01–A10 breadth) | EVERY target (always-on) |
| **auth** | llm | S1 session/token/password-reset, S10 OAuth/code-replay (CWE-287/384/613/640) | auth-middleware, http routes touching login/session/token/oauth |
| **access-control** | llm | S2 RBAC, S3 tenant-isolation, S8 object-level IDOR (CWE-285/639/863/284) | every http/graphql/rpc route + queue/cron with a tenant/owner/role read |
| **injection** | llm | S4 SQLi + NoSQL/command/LDAP/template (CWE-89/77/78/943/1336) | any target that builds a query/command/template from input |
| **xss** | llm | S5 output encoding / HTML injection (CWE-79/116) | targets rendering/returning HTML or building markup from input |
| **ssrf** | llm | S6 SSRF, URL/host validation (CWE-918) | targets making outbound fetch/http from input-derived URL |
| **webhook** | llm | S7 signature verification + replay + raw-body (CWE-345/347) | webhook-receiver kind |
| **finance** | llm | S9 money math, ledger integrity, complete-mediation/self-deal; **payments submodule** (CWE-840/841) | finance routes (payment/payout/refund/commission/ledger signals) — pairs with `oracle` |
| **crypto** | llm | weak crypto, predictable randomness, timing oracle (CWE-326/327/338/208) | targets doing hashing/sign/compare/encrypt/random |
| **secrets** | llm | hardcoded secrets, secret logging, info disclosure (CWE-200/532/798) | every target (cheap regex pre-filter narrows LLM use) |
| **request-integrity** | llm | CSRF, idempotency, atomicity, race/TOCTOU, mass-assignment (CWE-352/362/799/915) | state-mutating http/rpc routes, queue/cron |
| **file-io** | llm | path traversal, upload, content-type confusion, deserialization (CWE-22/434/502) | targets reading/writing files or parsing uploads |
| **resource** | llm | DoS, unbounded resource, ReDoS, missing pagination (CWE-400/770/1333) | targets with loops/regex/queries over input-sized data |
| **oracle** | deterministic | S9 self-deal C02/C09 (built) | finance/value-sink targets |
| **deps** | deterministic | S11 CVE (built) | repo manifest (lockfile) — once per scan |
| **headers** | deterministic | S11 security headers (built) | header/config file |

**Recall strategy (no-false-clean + recall-max):** `baseline` runs on EVERY target → the recall FLOOR is multi-class,
never finance-only. Matched domain specialists ADD depth on their surface. When triage is unsure → run the specialist
(over-cover), never skip. An un-dispatched target is `budget-dropped` in the report, NEVER `clean`.

## Detector contract (manifest) — the boundary that makes a detector drop-in

Each `domains/security/detectors/<id>/detector.json` (data, loaded by path):
```json
{
  "id": "access-control",
  "kind": "llm",                       // llm | deterministic
  "prompt": "access-control.prompt.txt",// llm: prompt file in this dir; deterministic: omit
  "entry": "run.py",                    // deterministic: the leg; llm: omit
  "covers": ["S2","S3","S8"],          // taxonomy classes (coverage ledger join)
  "cwe": ["285","639","863"],
  "applies_to": {                       // routing: union of kind match OR content signal
    "kinds": ["http-defn-call","http-file-route","graphql-resolver","queue-consumer","cron-scheduled"],
    "signal": "\\b(userId|tenantId|orgId|ownerId|role|membership|teamId)\\b",
    "always": false                    // baseline/secrets set true
  },
  "cross_file": true,                   // needs resolver dep-inlining (band-2)
  "severity_domain": "authorization"
}
```
A new detector = drop a dir with a manifest + prompt. The registry auto-discovers it. Adding/removing a domain MUST
NOT touch orchestrator plumbing (delete test: deleting `xss/` removes XSS depth and nothing else breaks).

## The Opus DETECT orchestrator (`orchestrator/detect.py`)

Replaces `mapper.py`'s subprocess-dispatch half (mapper KEEPS the deterministic enumerate half). Flow:
1. **Map: create-or-reuse.** If a fresh map JSON exists (`--map <path>`, or `mapper.coverage_map` cache), reuse it;
   else run `mapper.enumerate_surface` to build it. The map = `[(target, kind, why)]` + coverage buckets.
2. **Triage (the Opus reasoning step).** Per target: load the detector registry; select `baseline` + every detector
   whose `applies_to` matches (kind ∈ kinds OR content signal hits OR `always`). This is JUDGMENT-capable (an Opus
   pass MAY widen selection on ambiguous targets) but defaults to the deterministic manifest match — never narrower.
3. **Dispatch k=1 per selected detector.** Each detector → one `gate.py`/subagent roll on the target (LLM detectors
   via `run_llm` adapter through the detector's prompt; deterministic via its `run.py`). Production = k=1 per detector
   per target (the flakiness audit re-runs k=3 AFTER ~99%, per `2026-06-18-recall-campaign-49.md`).
4. **Union → semantic-merge → report.** Collect findings across detectors, `semantic_merge.merge_groups` collapses
   paraphrases, emit one report per target + a run-level coverage map. **Never a false clean:** degraded roll
   (empty/401) = COVERAGE-INCOMPLETE; un-dispatched = budget-dropped; both surfaced, neither a 0-finding clean.
5. **Elusive hammer (deferred).** Where a target's surface matches a known-hard class (`known-hard-classes.md`) and
   the single pass is empty, queue it for the deferred cross-model dice round — record-only now.

## gate.py change — per-target prompt selection (DRY, no new behavior)

Today `gate.py` hardcodes the one prompt. Change: accept `--prompt <path>` (and/or `--detector <id>`); default keeps
current behavior for back-compat. `one_roll` already takes a `template` arg and calls `run_llm` — wire the selected
detector's prompt as the template. No change to the roll/union/merge mechanics. The orchestrator picks the prompt;
`gate.py` stays the single-file detection engine.

## Prompt authoring discipline (this is #16 + #22, unified, ungated)

- **baseline = generalized multi-class** (NOT finance-framed): the domain-agnostic adversarial method — map module,
  trust boundary, input validation, authZ/object-ownership, injection/encoding, error/atomicity, secrets. This is the
  #16 "generalize beyond payments" deliverable.
- **Each specialist = focused depth** on its class's failure modes (e.g. `injection` enumerates SQLi/NoSQL/command/
  template sinks + safe-vs-unsafe parameterization).
- **Blind-author + held-out validate** (#22): author each prompt WITHOUT looking at the corpus cells / fix commits;
  validate recall on held-out cells the author never saw. The review PROMPT is the dominant catch lever (MEASURED).
- Generalize the resolver/oracle field lexicon (#13) inside the `finance` detector path — non-silent off-lexicon
  notice, never a false clean. (Tracked; finance-detector fast-follow.)

## Measurement — drive to 99% real-catch

- Denominator: the corpus (`bench.py --inventory`) + the 49 P0/P1 campaign (`2026-06-18-recall-campaign-49.md`),
  spanning S1–S11. Add cells for any OWASP class with 0 cells (no-false-coverage).
- Per detector: recall over its `covers` classes, k≥3 → RATE. A class CAUGHT 3/3 = ROBUST (counts to 99%); 1–2/3 =
  FLAKY → elusive set (deferred). Blind config (`--config-dir /tmp/sg_cfg`, refresh creds; empty/401 = INCOMPLETE).
- Headline = REAL CATCH across the union, not per-prompt. Defer the hard ~1% once the 99% floor holds, then run the
  k=3 flakiness audit and relocate flaky catches to the dice set.

## Architecture decisions

- **ACCEPTED — baseline always-on + matched specialists (breadth from baseline, depth from specialists).** Guarantees
  no-false-clean (baseline is multi-class) while specialists lift catch-rate. Rejected "specialists only" (a triage
  miss → an un-reviewed class → false clean) and "one mega-prompt" (the current finance-overfit failure mode).
- **ACCEPTED — detector = drop-in dir + manifest; registry auto-discovers.** Delete test passes per domain (remove a
  dir, lose only that depth). Earns the boundary: domains have independent change cadence + ownership.
- **ACCEPTED — DETECT orchestrator is a NEW file (`orchestrator/detect.py`), mapper keeps enumerate.** Per the
  modular-hierarchy design's refactor 3 split (enumerate = deterministic/domain; dispatch = the Opus orchestrator).
- **ACCEPTED — finance is a sibling detector, payments a concern inside it.** No special status; pairs with `oracle`.
- **REJECTED — rewrite mapper into the orchestrator.** mapper's enumerate half is sound + tested; only its dispatch
  half is replaced. Reuse, don't rewrite.
