# Complete-Mediation Scan

Review procedure for missing-authorization / broken-access-control defects — a guard present on ONE path/party but MISSING on a sibling party, call-site, or route (IDOR, self-deal). The manual counterpart of the oracle leg in `security-gate`. Loaded from the `security-gate` skill.

audience: AI coding agents — optimize for activation, not prose. Future editor: do not "prettify" this into narrative; the lists ARE the tool.

## What this kills (BLUF)

Run a MECHANICAL principal enumeration before judging any authorization. The failure this prevents has a name: **welding** — the reviewer names ONE party (the obvious caller), checks its guard, declares the path safe, and silently never asks whether a SIBLING party reaching the same sink is also guarded. Pure-LLM review welds by default because it reasons about the salient actor, not the full set.

The fix is **universal quantification**: build a matrix with one row PER principal. A matrix structurally cannot skip a cell — every principal gets a verdict on the record, so "I forgot the sibling" becomes impossible.

**Property enforced (complete mediation):** for ALL principals that can reach a value-moving or authority-exercising sink, a required check against the resource-owner (the anchor) must dominate that sink.

- Apply this skill to: code at a trust boundary that moves value (ledger/balance writes, transfers, payouts), exercises authority (role/privilege writes, ownership/attribution writes), or mutates state on an owned resource.
- Do NOT apply to: pure read paths with no owner-scoped data, pure computation with no persisted effect, code with no trust boundary.

## The ladder — MAP → ENUMERATE → ANCHOR → MATRIX → REPORT

Run all five rungs in order. Do not skip a rung. Do not stop early because the first principal looked checked.

### 1. MAP sinks

List every call in scope that moves value or exercises authority. A sink is any of:

- a ledger / balance / wallet write (credit or debit),
- a money transfer / payout / charge / refund / clawback,
- a role / privilege / permission write,
- an ownership / attribution write (set `owner_id`, `*_attribution_id`, reassign a resource),
- a state mutation on a resource someone owns (status flip, soft-delete, publish).

Record each sink as `SINK-id: <call> @ line N`. One sink per credited/debited/mutated effect, even if two effects share a function.

### 2. ENUMERATE principals — MECHANICALLY, NOT by judgment

This rung is the whole point. Do NOT "decide who the actor is." LIST every principal-bearing identifier in scope at each sink. Two branches by id type:

**Branded / nominal id types** (e.g. a `UserId` brand, a typed column): enumerate every variable, parameter, and column of that type that is in scope at the sink. This is a sound type query — it cannot miss one.

**Plain-string ids** (the common case): apply the NAME-HEURISTIC. A principal-bearing identifier is any in-scope identifier matching:

- `userId`, `*UserId` (e.g. `buyerUserId`, `sellerUserId`, `referrerUserId`, `refereeUserId`, `ownerUserId`, `recipientUserId`),
- `actorId`, `callerId`, `subjectId`, `principalId`, `*OwnerId`, `*AccountId`,
- every principal-bearing FK column referenced in THIS function's own selects/joins (e.g. an `owner_user_id` column the function reads).

Scope rule (load-bearing): a column counts as a row ONLY if THIS function references it (selects it, joins on it, or reads it). Do not invent rows for columns the function never touches. Conversely, do not stop at the first match — list ALL of them.

When no external schema file is available, derive principals from identifiers and join columns visible IN the file itself.

Output: for each sink, the full set of in-scope principals.

### 3. ANCHORS — derive a SET, mechanically; never collapse to one

Derive ALL anchors the transaction touches. An anchor is any resource-owner identity a principal could illegitimately equal. There is almost never exactly one. Take the UNION of three mechanical rules:

- **A1 — sink-beneficiary anchor.** The owner field the sink credits/debits/acts-for (the `userId`/`ownerId` argument passed to the write). Trace to a line.
- **A2 — resource-owner-read anchor.** EVERY owner-bearing identifier the function READS — every `*ownerUserId`/`*OwnerId`/owner-role column it selects or joins (e.g. `vendors.ownerUserId` selected at line N). A counterparty whose resource is involved is an anchor EVEN IF the sink does not credit them.
- **A3 — guard-revealed anchor (Chesterton's Fence for guards).** For EVERY existing identity/equality guard comparing `(A, B)` in the function, BOTH operands are anchors. A guard someone bothered to write proves the domain protects that relation; complete mediation requires it hold for ALL principals, not just the one party the author checked.

Dedup the union. Output: `anchors = { <field> @ line N, ... }`.

**The anchor-axis anti-weld rule (load-bearing — DO NOT skip).** Mechanical principal enumeration (rung 2) defends only the ROW axis. The weld MIGRATES to the column axis: if you derive only the sink-beneficiary as anchor, a self-deal between two NON-beneficiary parties (e.g. buyer === resource-owner, while the sink credits a third-party referrer) has NO cell and SHIPS SILENTLY. Every resource-owner in scope AND every operand of an existing guard is a column. Deriving one anchor is the welded mistake.

### 4. MATRIX — one row per principal, no row skipped

Build a GRID per sink (or one combined grid tagging the sink per row):

- **Rows** = every enumerated principal from rung 2. Every one. None dropped.
- **Columns** = every anchor in the SET from rung 3. Every one. Not just the beneficiary.
- **Cells** = the full cross product. Fill every off-diagonal cell (skip only the diagonal where the principal IS that anchor's own identity — trivial). A grid with one column is a welded matrix; reject it unless rung 3 truly produced one anchor.
- **Each cell** = exactly one verdict:
  - **CHECKED** — a guard compares this principal against the anchor AND that guard dominates the sink (executes on every path that reaches the sink). Cite the guard line.
  - **IMPOSSIBLE** — this principal structurally cannot equal the anchor on the path to the sink. MUST carry a STRUCTURAL reason that is quotable code in THIS file (a WHERE clause, an equality branch, an enum constraint that forces inequality). Quote it.
  - **UNCHECKED-BUT-POSSIBLE** — this principal CAN equal the anchor and NO guard dominates the sink. **This is a defect.**

**The anti-weld rule, stated loudly:** An IMPOSSIBLE verdict with no quotable in-file structural reason is FORBIDDEN. When in doubt, the verdict is UNCHECKED-BUT-POSSIBLE, not IMPOSSIBLE. Enumeration earned every principal a row; you may NOT then dismiss a row by hand-wave.

**Interprocedural guards do not earn CHECKED or IMPOSSIBLE.** If the only defense is "a check in another function already ran" (e.g. a status that implies an earlier validation), that is state-machine reasoning across functions — not a dominating in-file guard. Mark it UNCHECKED-BUT-POSSIBLE and note the assumed upstream invariant as the residual risk.

#### Before/after example (tiny)

Sink: `credit(ledger, payeeId, amount)` @ line 40. Function also reads `vendorOwnerId` (selected @ line 22) and has guard `if (payerId === vendorOwnerId) amount = 0` @ line 35.
Anchors (rung 3 union): `payeeId` (A1 beneficiary), `vendorOwnerId` (A2 owner read @22; A3 guard operand @35). In-scope principals: `payeeId`, `payerId`, `vendorOwnerId`, `buyerId`.

DO NOT (anchor-axis weld — one column = beneficiary only; off-beneficiary self-deal has no cell):

```
principal       anchor    verdict
payerId         payeeId   CHECKED — line 30: if (payerId === payeeId) throw
vendorOwnerId   payeeId   CHECKED — line 35... (mis-filed; guard is vs vendorOwnerId, not payeeId)
```

DO (full grid — every principal × every anchor):

```
principal     payeeId                          vendorOwnerId
payerId       CHECKED @30 (payerId===payeeId)  CHECKED @35 (payerId===vendorOwnerId)
buyerId       UNCHECKED-BUT-POSSIBLE           UNCHECKED-BUT-POSSIBLE ← off-beneficiary self-deal
vendorOwnerId UNCHECKED-BUT-POSSIBLE           — (diagonal, skip)
```

The single-column version SHIPS the `buyerId × vendorOwnerId` self-deal: buyer owns the store, sink credits a third party, no guard compares buyer to vendor-owner. Only the grid — beneficiary AND resource-owner as columns — surfaces it.

### 5. REPORT

For every UNCHECKED-BUT-POSSIBLE cell, emit one finding:

- principal (the row),
- anchor (the column),
- sink location (`SINK-id @ line N`),
- why no guard dominates (and, if the only defense was interprocedural, name the assumed upstream invariant).

Report ALL such cells RAW. Do NOT adjudicate which one is "the" bug — that is not this skill's job and picking one re-introduces the weld. List every cell honestly.

## Honest limits — state these, do not overclaim

**Decidable / mechanizable by this procedure:** equality/identity self-deal between a principal and the anchor; presence-or-absence of a dominating guard. These are what the matrix proves.

**Hardening path (hybrid: LLM labels, static query enumerates):** the LLM labels sources, sinks, and anchors; a static tool then enumerates principals soundly. Use **Semgrep** for a pilot / CI gate (intraprocedural, fast). Use **CodeQL** for interprocedural dataflow (does an upstream guard actually dominate this sink across calls). The matrix is the spec these tools encode.

**NOT decidable here — say so, do not pretend the matrix covers them:**

- fuzzy collusion (shared device / household / payment instrument) — identity-equality cannot see it,
- sybil / multi-account rings,
- gross-vs-net or wrong-base arithmetic errors (right principal, wrong amount),
- maturation / hold / state-machine timing logic (is the value released at the right time),
- present-but-WRONG guard logic — the matrix proves a guard EXISTS and dominates, NOT that its computed answer is correct.

A CHECKED cell means "a dominating guard is present," not "the guard is correct." Flag suspicious guard logic separately; it is out of scope for the completeness proof.

## Done-check

- [ ] Every sink mapped with a line.
- [ ] Every in-scope principal enumerated by type-query or name-heuristic — none dropped, none invented out of scope.
- [ ] Anchor SET derived per sink by the A1∪A2∪A3 rules (beneficiary + every owner-column read + both operands of every existing guard), each traced to a line — not a single narrative-chosen anchor.
- [ ] Matrix is the full principal × anchor GRID (every principal row × every anchor column); a single-column matrix is rejected unless rung 3 truly produced one anchor.
- [ ] Every off-diagonal cell carries CHECKED (cite guard) / IMPOSSIBLE (quote in-file structural reason) / UNCHECKED-BUT-POSSIBLE.
- [ ] No IMPOSSIBLE verdict without quotable in-file code.
- [ ] Every UNCHECKED-BUT-POSSIBLE cell reported raw; none adjudicated away.
- [ ] Limits stated; CHECKED not overclaimed as "correct".
