# Modular hierarchy — security-gate component classification

audience: AI coding agents first. BLUF-ordered, imperative. Tag [MEASURED]/[INFERRED]. Testing-native vocab.

## BLUF

The gate is **already modular** — manifest-declared detectors, a frozen finding contract, a registry, a runner.
This doc does not redesign it. It **classifies every component** (the user's question: *which are modules, which
are adapters?*) and **earns each boundary with the delete test**, then names the **3 refactors** the classification
implies. We port the *platform modularity philosophy* (delete test, adapter minimalism, dissolve-don't-manufacture,
three axes) to this project's **Python path-loaded, stdlib-only, no-`__init__.py`** idiom — NOT to slopgate's
monolithic Rust engine and NOT to npm `@mod/*` packaging.

**The one-line answer:** the project *already has* the philosophy's vocabulary. A **detector** IS the philosophy's
*capability*; a **domain** IS its *domain* axis; a **band** IS its *tier* axis. The **only genuinely new axis the
philosophy surfaces is the `adapter`** — the swappable LLM backend the dice-rolling plan needs. Everything else is
naming what is already here and earning it.

## Vocabulary — decide once, never drift

The platform philosophy names three axes (adapter / capability / domain). Map them to this project's native terms;
do not invent a parallel vocabulary. Banned (project rule): `module`*-as-coined-architecture-noun*, `layer`, `tier`,
`seam`. Licensed: the testing-native set + `adapter` (+ `module`/`adapter` used loosely because the user asked in
those words).

| Philosophy term | This project's native term | Rule |
|---|---|---|
| capability (opt-in, manifest-declared unit) | **detector** | use "detector"; never "capability" |
| domain axis (product knowledge) | **domain** (`domains/<x>/`) | same word — keep |
| tier / layer (detection strength) | **band** (band-1/2/3) | use "band"; never "tier/layer" |
| adapter (swappable external service/runtime) | **adapter** | THE one new term — a backend behind one direct call |
| seam / core | **the shared plumbing** (contract + registry + runner + resolver) | name the parts; never "seam" |

When this doc says **module** it means loosely "a unit with its own boundary / change cadence" (the user's word),
not a packaging unit — there is no packaging here, only path-loaded files.

## The classification — the answer

| Component | File(s) | Role | Class | Delete-test verdict |
|---|---|---|---|---|
| Finding/report contract | `prevent/contract.py` | shared plumbing | **plumbing** | KEEP — deleting scatters the JSON shape + no-false-clean into every detector + both dispatchers |
| Detector registry | `prevent/registry.py` | shared plumbing | **plumbing** | KEEP — deleting hardcodes the detector list into the runner, loses manifest discovery |
| Cross-file resolver | **extract from** `orchestrator/gate.py` | shared plumbing | **plumbing** | KEEP — 2 real consumers (PREVENT runner + DETECT); today entangled in the LLM file (smell) |
| PREVENT dispatcher | `prevent/runner.py` | host-facing dispatcher | **dispatcher** | KEEP — deleting scatters block-policy + coverage-incomplete into every host |
| Detectors | `domains/security/detectors/{oracle,deps,headers}/` | the capability units | **detector** | each EARNS its boundary — add/remove without touching plumbing |
| LLM backend call | `claude -p` in `gate.py` + `semantic_merge.py` | swappable backend | **adapter** | EXTRACT — 2 hardcoded call sites; dice-rolling needs the swap |
| Attack-surface enumerate | `orchestrator/mapper.py` (enumerate half) | deterministic + domain conventions | **dispatcher (enumerate)** | KEEP enumerate; SPLIT off the dispatch half |
| LLM detection legs | `gate.py` (one_roll/union), `semantic_merge.py` | DETECT post-commit arm | **detector (LLM) + postprocess** | rides the `adapter`; the future Opus-orchestrator replaces mapper's dispatch |
| Attack-surface conventions | `orchestrator/conventions.py` (KIND_SIGNALS, FILE_ROUTE_RULES) | security routing knowledge | **domain** | stays in domain — not plumbing |
| Ratchet ledger | `prevent/confirmed.json` | human-confirmed find→block data | **domain data** | stays — it is content, not code |
| PREVENT host | `prevent/prevent.py` | git pre-commit/pre-edit entry | **host (trigger adapter)** | thin entry over the runner |
| Deferred syntactic engine | `engine/` | reserved detector home | **detector (deferred)** | YAGNI — build only when a class needs deterministic syntactic detection |
| Bench / measurement | `bench.py`, `tests/` | the consumer harness | **test tooling** | out of the runtime hierarchy |
| Operator skills | `skills/*` | authoring/investigation tooling | **operator tooling** | out of the runtime hierarchy |
| slopgate config | `.slopgate/*` | the global slopgate tool's footprint | **EXTERNAL** | [MEASURED] zero Python refs (`grep -rn slopgate --include=*.py` → none) — NOT our architecture |

## Delete test — run, not asserted

Apply the keystone gate to the shared plumbing AFTER the leaves leave. Calibrate to a **single tool** — there are no
N downstream apps here, so the force is intra-tool cohesion and the real risk is *manufacturing* boundaries, not
losing them.

- **Delete `contract`** → every detector + the runner independently re-agree the finding/report JSON and each
  re-implement the `unresolved→degraded` no-false-clean rule. Complexity scatters into N detectors + the runner.
  **KEEP** — genuine shared boundary on the PREVENT arm *today*; the DETECT arm joins this boundary at [TARGET] once
  refactor 3 maps its group-findings into the contract (see Two-way protection). The keep verdict holds either way.
- **Delete `registry`** → the runner hardcodes the detector list and re-implements glob/scope/trigger selection;
  adding a detector means editing the runner. Scatter + loss of manifest discovery. **KEEP.**
- **Delete `resolver`** (once extracted) → both the PREVENT runner and the DETECT arm re-implement
  workspace-alias + `collect_deps` + barrel-follow. Two copies of the cross-file logic. Already a smell today: the
  runner reaches into `gate.py` for `build_workspace_aliases`/`collect_deps`. **KEEP** — 2 real consumers.
- **Delete `runner`** → `prevent.py` and any future CI host re-implement dispatch + the block-policy ladder +
  coverage-incomplete fail-open. Scatter into every host. **KEEP.**

### The two-dispatcher question — earn it against the webhooks precedent

Should the PREVENT `runner` and the DETECT orchestrator be ONE dispatcher? Both "select targets → run a thing per
target → collect findings." Run the delete test on each AFTER the shared leaves (contract/registry/resolver) exit:

- **Left in `runner`:** block-policy ladder (`error`+authorized→BLOCK / ratcheted→BLOCK / else WARN) +
  coverage-incomplete fail-open + deterministic subprocess exec. Substantial. **Survives.**
- **Left in the DETECT orchestrator:** attack-surface triage + per-target subagent dispatch (k=1) + union +
  elusive-hammer + semantic-merge. Substantial. **Survives.**

Webhooks precedent (platform philosophy): the "deliver" leg was a *single thin step* → it dissolved into the
"verify-5/5" path. Here **both legs are substantial AND carry opposite harm-asymmetry**: PREVENT must never
false-BLOCK (precise-only, blocking); DETECT must never false-CLEAN (recall-max, non-blocking). One dispatcher would
have to carry two contradictory policies. → **TWO dispatchers, sharing the contract + resolver leaves.** Earned, not
asserted.

## Two-way protection — mapped onto the hierarchy

| Arm | Trigger | Host | Dispatcher | Runs | Guarantee |
|---|---|---|---|---|---|
| **PREVENT** | pre-commit / pre-edit | `prevent.py` | `runner` | deterministic detectors only (band-3: oracle/deps/headers) | 100% accuracy, **zero false positives** — only `error`-precise + ratcheted findings BLOCK |
| **DETECT** | post-commit | `mapper` enumerate → **Opus-orchestrator** | the DETECT orchestrator | LLM detectors (k=1 per subagent) + elusive-hammer | recall-max, **never a false clean** |

Both arms consume the **`resolver`** (cross-file scope) today. The **`contract` is shared by the PREVENT arm only**
right now: [MEASURED] the band-3 detectors emit `contract.finding()` via `emit()`, but the DETECT/LLM side emits
gate.py group dicts `{title, sev, rolls}` (`union_rolls`) and `semantic_merge` consumes/returns that same shape —
`grep -n "import contract\|finding(\|emit(" gate.py mapper.py semantic_merge.py` → none. So **"every finding, LLM or
deterministic, emits the same contract" is [TARGET], not current state.** Closing it is a real work item — *map the
DETECT group-findings into the contract shape* — folded into refactor 3 (the orchestrator is where the DETECT arm is
rebuilt, so the mapping lands there). The ratchet links the arms: a human-confirmed structurally-expressible DETECT
find becomes a deterministic detector that joins PREVENT.

## Adapter axes — build the seam now vs WATCH

Adapter-minimalism (HARD): an adapter STARTS as one direct function call; NO Service/Client/Provider classes unless
real multi-step behavior earns it.

- **`llm-runner` — BUILD THE DISPATCH FN NOW.** [MEASURED] `gate.py:one_roll` and `semantic_merge._call_llm` BOTH
  hardcode `["claude","-p","--model",model,"--effort",effort,...]`. Two in-tool call sites of the *same* external
  call → DRY alone justifies extracting one `run_llm(prompt, model, effort, config_dir) -> text`. Make it
  backend-parametrizable (the dispatch picks claude-cli today). **Do NOT build alt adapters (codex/cursor/agy/
  vibeflare-api) yet** — that is the deferred dice-rolling phase. Single-adapter test: one impl today, BUT a second
  is a *named near-term need* (the user's "which LLM is best/cheapest" plan) AND the extraction removes a real
  duplication → the dispatch fn is justified now; the alt adapters are not.
- **`deps-backend` (pnpm|npm|yarn) — WATCH.** 1 caller (`deps_audit.py`), pnpm only today. Single-adapter test
  FAILS (one plausible impl). Don't build the seam; npm/yarn = best-effort deferred (#40).
- **`workspace-backend` (pnpm-workspace|npm/yarn) — WATCH.** Inside the resolver; pnpm only, npm/yarn deferred.
  Don't pre-build.

## The 3 refactors the classification implies

These define the TARGET hierarchy. They are design output; the plan skill sequences them. **Pilot-before-fanout:**
do refactor 1 first (smallest, regression-locked by existing tests), measure, then 2, then 3.

1. **Extract `resolver` from `gate.py`** into its own path-loaded file (shared plumbing). [MEASURED — separability
   verified against `gate.py`, not assumed] The cluster = `build_workspace_aliases`, `find_repo_root`, `resolve`,
   `_barrel_named_source`, `_defines_local`, `resolve_through_barrel`, `first_party_value_imports`, `collect_deps`,
   plus its private helpers (`_read_pnpm_workspace_globs`, `_read_npm_workspaces`, `_exports_target`,
   `_existing_source`, `_dist_to_src`) and its parse regexes (`IMPORT`, `IDENT`, `REEXPORT_NAMED`, `REEXPORT_STAR`,
   `CRITICAL`). Every helper/regex is used **only inside the cluster** (grep-confirmed); the cluster has **zero
   outbound ref to gate.py runtime state** — no `build_bundle`, `run_oracle_set`, `DEP_OVERRIDE`, or roll/LLM path.
   So it is a **pure move**, not an untangle. Named rewires: `gate.py` (the bundle/roll path loads the resolver),
   `runner.py` (drops its reach into `gate.py` for `build_workspace_aliases`/`collect_deps`), and `mapper.py` (its
   `_load(...).CRITICAL` target moves from `gate.py` to the resolver file — `CRITICAL` is consumed only by the
   resolver within gate.py + by mapper externally). Locked by the existing pytest suite. Delete-test-justified
   (2 consumers).
2. **Extract the `claude -p` call into one `llm-runner` dispatch fn.** `gate.py:one_roll` and
   `semantic_merge._call_llm` both call it. DRY + adapter-ready. No new behavior.
3. **Split `mapper.py`:** keep the deterministic enumerate half (consumes `conventions` = domain); the dispatch half
   (today `subprocess gate.py` per target) becomes the **DETECT orchestrator** — target = 1 Opus orchestrator that
   triages the attack-surface map to per-target subagents (k=1), then hammers elusive bugs where the surface is
   found. **Includes the contract-unification work item:** map the DETECT group-findings `{title, sev, rolls}` →
   `contract.finding()` so both arms emit one shape ([TARGET] today — see Two-way protection). This is the large
   DETECT build — its own spec + plan, NOT this doc.

## Strategy placement — corrected, folded in (kills the stale k-fact)

The DETECT arm's production shape, stated here so the registry docs stop contradicting it:

- **Production scan = 1 Opus orchestrator → dedicated subagents, ONE pass (k=1) per target.** Running k=3 same-model
  union over an entire codebase per sweep is expensive and wrong for steady state.
- **k=3 same-model union is the FLAKINESS AUDIT** — a one-time full re-run (×3) AFTER we believe 99% is reached, to
  find flaky catches and relocate them to the elusive-set.
- **Elusive ~1% (C02-type, multi-LLM-pass)** = a separate cheap dice-rolling round, **deferred/record-only**: open
  question = which LLM gives best detection % and which is cheapest (the `llm-runner` alt adapters feed this).
- gate.py's current `--k 3` default and `union_rolls` are the *audit/elusive* mechanism, not the steady-state per-
  subagent pass. The Opus-orchestrator dispatches k=1 per target.

Update `docs/known-hard-classes.md` + `docs/validation/2026-06-18-recall-campaign-49.md` to point here for strategy;
remove their "k=3-union ∈ 1-run" framing.

## What this design does NOT do (honest no-ops)

- Does **not** adopt or extend slopgate's Rust engine — `.slopgate/*` is the global tool's footprint, not wired
  ([MEASURED] zero Python refs). Our `prevent/` IS the native modular replacement.
- Does **not** build `deps-backend` / `workspace-backend` adapters — WATCH, 1-donor each.
- Does **not** build `engine/` syntactic detectors — YAGNI until a class needs deterministic syntactic detection.
- Does **not** build the Opus-orchestrator (refactor 3's target) — placed in the hierarchy here; built in its own
  phase.
- Does **not** manufacture a collective "core" object — the shared plumbing is four path-loaded files, no wrapper.

## Architecture Decisions (accepted / rejected + delete test)

- **ACCEPTED — `resolver` is shared plumbing, extracted from `gate.py`.** Delete test: 2 consumers (PREVENT runner
  + DETECT); today's reach-into-`gate.py` is the smell that proves the boundary.
- **ACCEPTED — `llm-runner` dispatch fn extracted now; alt adapters deferred.** 2 duplicated call sites (DRY) +
  named near-term second adapter. Alt adapters fail the single-adapter test today → deferred.
- **ACCEPTED — two dispatchers (PREVENT runner, DETECT orchestrator), not one.** Both survive the post-leaf delete
  test; opposite harm-asymmetry forbids one shared policy. Webhooks-precedent-tested.
- **ACCEPTED — `mapper.py` split (enumerate stays, dispatch → orchestrator).** Same don't-keep-the-monolith logic
  applied to `gate.py`.
- **REJECTED — build `deps-backend`/`workspace-backend` adapters now.** Single-adapter test fails (1 impl each) →
  WATCH, don't manufacture.
- **REJECTED — a wrapper "core"/engine object over the four plumbing files.** No behavior to host; path-load
  directly. Manufacturing cohesion the philosophy warns against.
- **REJECTED — merge `semantic_merge` into `gate.py`.** It is a distinct LLM-backed postprocess with its own
  fail-loud/degraded contract; it rides the same `adapter` but is its own detector-side unit.
- **DEFERRED [TARGET] — unify the DETECT arm onto `contract.finding()`.** [MEASURED] the LLM side emits group dicts
  `{title, sev, rolls}` today, NOT the contract; only the band-3 detectors emit it. Single-shape-for-both-arms is
  target state, landed as part of refactor 3 (the orchestrator rebuild), not asserted as current.

## Pointers (do not re-inline)

- Platform modularity philosophy (canonical): `~/.claude/projects/-home-user/memory/project_modular_platform.md`;
  methodology in `~/.claude/skills/make-modules/SKILL.md`.
- Detector contract (the shared boundary): `prevent/contract.py`.
- Prevent-band design (the PREVENT arm): `docs/specs/2026-06-18-prevent-band-design.md`.
- Coverage ledger: `docs/taxonomy/security.md`. Detection registry: `docs/known-hard-classes.md`.
