# deps false-trigger fix — trigger-gate + workspace-scope design

audience: AI coding agents first. BLUF-ordered, imperative. Points to SoT, never re-inlines.

## Problem (measured)
`deps` (S11 pnpm-audit, band-3, precise → block-authorized) wedges legit commits two ways:
1. **False trigger.** `scope:"repo"` + `trigger_globs:["**/package.json","**/pnpm-lock.yaml"]` → ANY staged `package.json` fires a whole-tree audit. A 4-line exports-only edit (zero deps changed) blocks on the pre-existing CVE backlog. THIS is the commit the user hit.
2. **Whole-monorepo scope.** `pnpm audit` runs at repo_root → audits the WHOLE workspace (29 advisories on platform) → a pre-existing CVE in an UNTOUCHED workspace blocks a commit that only touched another.

## Non-goals (user directive 2026-06-21, verbatim)
"i dont want to redesign deps to flag only newly introduced CVEs. The blocking of the CVEs is justified." → **DELTA-only (new-CVE-vs-baseline) is REJECTED.** All real CVE blocking STAYS: pre-existing CVEs, dev-dep CVEs, everything pnpm flags. Fix ONLY the two false TRIGGERS the user named (AskUserQuestion): "Non-dependency edits" + "Whole-monorepo audit".

## Verified capability (measured `pnpm 11.5.2`, platform: 29 real advisories)
- NO per-workspace audit flag (only `--audit-level/--dev/--prod/--no-optional/--ignore`).
- `pnpm audit --json` `advisories[].findings[].paths` NAME the workspace: `packages/helpdesk > esbuild@0.23.1`, `. > esbuild@0.23.1` (root). So workspace-scoping = POST-FILTER advisories by the path's workspace segment (token before first " > ").
- **Segment-format measured across ALL 29 advisories (`/tmp/sg_audit.json`):** 1254 dir-style segments (`packages/*`, `apps/*`), 28 root (`.`), **0 package-name (`@scope/*`) and 0 bare** → the dir-segment filter is correct; no name-vs-path hole. The path segment IS the package.json directory, rel repo_root.
- **Hoisted-only edge (measured 1/29):** one advisory appears ONLY under root `.` (no workspace path). A vuln a touched workspace pulls but pnpm reports only under `.` would NOT match that workspace's scope → omitted at pre-commit. Narrow (1/29), report-only, and CI full-tree is the backstop. Honest because `coverage.scanned` declares exactly which workspaces were assessed.

## Design — two fixes, three layers

### Layer split (advisor-mandated: git-stateful trigger/scope decision stays OUT of the hermetic cell)
- **deps_audit.py** — stays HERMETIC (parse audit JSON + threshold). Gains a PURE `--scope <dir>` (repeatable) filter param. NO git. Fixture-testable as today.
- **deps_changed.py** (NEW, co-located in the deps dir) — the git-stateful part. Input (stdin JSON): `{repo_root, changed:[matched trigger files]}`. Decides run-or-not + computes scope. Output (stdout JSON): `{"run": bool, "args": ["--scope","packages/x", ...]}`.
- **runner.py** — a THIN GENERIC `trigger_filter` hook (mirrors the existing manifest-driven `needs_context` pattern; the dispatcher stays domain-agnostic — NO hardcoded pnpm/package.json semantics). Runs the filter first; if `run:false` → skip; else append `args` VERBATIM to the detector exec. The runner never parses `--scope` — the filter+detector own that vocabulary together.

### Mode 1 — trigger only on real dep change (kills false trigger)
`deps_changed.py` emits `{run, args}`; when `run:true`, `args` ALWAYS leads with `--commit-scope` (marks a commit-scoped audit, NEVER a full-tree one — see Mode 2). Run-decision:
- for each staged `package.json` in `changed`: compare staged dep maps vs HEAD — `command git --no-pager show :0:<path>` (staged) vs `HEAD:<path>` (or `{}` if new), compare ONLY `dependencies`/`devDependencies`/`peerDependencies`/`optionalDependencies`. Any map changed → **run**, add its workspace dir (rel repo_root, `.` for root) via `--scope <dir>`.
- lockfile (`pnpm-lock.yaml`) changed AND ≥1 package.json dep-map also changed (the `pnpm add X` case: manifest + lock both move) → run, scope = the changed workspaces.
- lockfile-only (lockfile changed, NO package.json dep-map change = `pnpm update`/`dedupe`/post-merge install) → **run with `--commit-scope` and ZERO `--scope`** → Mode 2 blocks NOTHING at pre-commit (no localizable workspace), coverage note emitted, full-tree audit deferred to CI. This is the user's no-false-block priority BY DEFAULT: a maintenance op must not whole-tree-block on the pre-existing backlog. CI is the durable home for lockfile-only/hoisted full-tree blocking.
- no lockfile change AND no dep-map change → **`{"run": false}`**. Exports/scripts/name/version edit → dep maps identical → deps does NOT run → no finding. [fixes the user's exact case]

### Mode 2 — scope audit to changed workspace (kills cross-workspace false block)
`deps_audit.py` — three behaviors keyed on `--commit-scope`:
- **`--commit-scope` ABSENT** (direct CLI / LIVE / CI / pre-push) → NO filter → ALL advisories ≥high → `error`. Full-tree authority. Backward-compatible (existing `tests/test_deps_emit.py` cells unchanged).
- **`--commit-scope` PRESENT + ≥1 `--scope <dir>`** → advisory IN-SCOPE iff some `findings[].paths` workspace segment ∈ scope dirs. In-scope → `error` (BLOCKS). Out-of-scope → **OMITTED from findings**; scope declared via `coverage.scanned=[the scope dirs]`. Touched workspace's CVEs block; untouched workspace's CVE not surfaced per-commit (CI full-tree owns it).
- **`--commit-scope` PRESENT + ZERO `--scope`** (lockfile-only) → in-scope set empty → ZERO findings; `coverage.scanned=[]`; coverage note "lockfile-only: no workspace localized; full-tree audit deferred to CI".

**no-false-clean guarantee (via the coverage contract, NOT warning-demotion):** a commit-scoped audit NEVER claims the tree is clean — it claims ONLY the scanned workspaces, declared in `coverage.scanned` (the existing `contract.emit` field, `prevent/contract.py:18`). Omitting out-of-scope advisories is honest SCOPING, not a false clean: "assessed workspace X, here is X; did not assess the rest." The whole-tree claim lives in pre-push/CI. **REJECTED: demote-out-of-scope-to-warning** — it surfaces ~all 29 advisories as warnings on every dep-changing commit, colliding head-on with the global CLAUDE.md "address every WARN" rule = recreates the friction the user is killing.

**Surfacing scope at the report — what is and isn't threaded (honest boundary):** `coverage.scanned` is carried in the detector EMIT (contract boundary, honest there), but `runner.run()` returns only `{blocking, warnings, incomplete, ran, exit_code}` — it does NOT thread `coverage.scanned` into the prevent report. Consequences, by case: (a) lockfile-only → the `unresolved` note auto-degrades status → surfaces as COVERAGE-INCOMPLETE end-to-end (honest, the doctrine-critical path); (b) scoped-WITH in-scope findings → those block/REPORT normally (honest); (c) scoped-AND-clean (touched workspace has zero CVEs ≥high) → renders as a plain no-block with the assessed-scope breadth NOT separately disclosed in the report. (c) is NOT a no-false-clean violation — it is a TRUE clean within a deliberately narrowed scope (not degraded input), and CI full-tree is the backstop; doctrine governs DEGRADED input (case a), which IS surfaced. Threading `coverage.scanned` into the report for case (c) is a deferred transparency nicety, not a correctness fix.
- **Hoisted-only residual (measured 1/29):** a vuln a touched workspace pulls but pnpm reports ONLY under root `.` is out-of-scope → omitted at pre-commit, caught at CI. Documented, narrow, report-only-acceptable. `coverage.scanned` keeps it honest (root `.` never silently claimed scanned).

## Files
- Modify: `domains/security/detectors/deps/deps_audit.py` — `audit_findings(audit, min_severity, commit_scope=False, scope_paths=())` returns in-scope finding rows only; add `--commit-scope` flag + repeatable `--scope <dir>`; `--emit json` sets `coverage.scanned` to the scope dirs when commit-scoped. `--commit-scope` ABSENT = full-tree all-error (backward compat).
- Create: `domains/security/detectors/deps/deps_changed.py` — git-stateful filter (Mode 1 + scope computation).
- Modify: `domains/security/detectors/deps/detector.json` — add `"trigger_filter":["python3","domains/security/detectors/deps/deps_changed.py"]`.
- Modify: `prevent/runner.py` — generic `trigger_filter` step for repo-scope detectors (run filter → skip on `run:false` → append `args`); `_run_detector` gains `extra_args`. FAIL-OPEN: filter crash/timeout/non-JSON → run UNSCOPED (never silently skip a precise detector = no-false-clean).
- Modify: `prevent/registry.py` — add `matched_trigger_files(detector, changed_files)` helper (the changed files matching `trigger_globs`, repo-relative) so the runner can hand the filter exactly what changed.
- Tests: `tests/test_deps_changed.py` (git-fixture: exports-only→run:false; dep-map change→run:true + `--commit-scope` + `--scope <ws>`; lockfile-only→run:true + `--commit-scope` + ZERO `--scope`), extend `tests/test_deps_emit.py` (commit-scope filter: in-scope=error + coverage.scanned set, out-of-scope=OMITTED, zero-scope=zero findings, `--commit-scope` absent=all-error backward compat), `tests/test_prevent_runner.py` (the REGRESSION test at the runner layer: a repo-scope detector with a `trigger_filter` returning run:false → does NOT run, no finding; run:true+args → args reach exec).

## Testing discipline
- Regression test FIRST (advisor), at the runner layer: reproduce the user case — exports-only `package.json` staged → deps does not trigger → no finding. That green test IS the "verified FP-free" precondition for any future enforce re-flip.
- deps_changed.py git tests use a TEMP git repo fixture (init, commit a package.json at HEAD, stage an edit) — hermetic, no network.
- deps_audit.py scope tests use the PINNED audit cells (`cells/audit_vuln.json`) + synthetic multi-workspace paths — hermetic.
- `./check.sh` (pytest + `ledger.py --check` + `bench.py --routing`) GREEN before merge. No new ledger gap (deps coverage unchanged — same class, same cells).

## Out of scope (separate decisions)
- **slopgate** (separate fail-closed gate, not security-gate) — measured firing on platform (`diff-shape-mixed-concerns`, opinionated) + zync (`tsc`, legit). Report-only flip does NOT cover it. Scope-to-fix is a user decision; see memory `slopgate-second-commit-gate`.
- **enforce re-flip** — BANNED until this ships + proven FP-free (user directive). Outward; needs explicit confirm.
- Full-tree audit at pre-push/CI (the durable home for untouched-workspace + lockfile-only CVE blocking) — recommended, not built here.
