# S2 RBAC / authorization — band-1 recall MEASURED (2026-06-17)

audience: AI coding agents first. Measured log. Tags [MEASURED] vs [DESIGN]. Do not prettify.

## BLUF

S2 (RBAC / privilege escalation) was a band-1 BLIND SPOT (0 cells) — the last named one after S3 closed. Now
**band-1 recall MEASURED: 3 git-canonical cells, all CAUGHT in RED at [critical] (hand-judged).** Recall CLOSED.

**The GREEN residuals are TRUE POSITIVES, not precision noise** (advisor-reconciled 2026-06-17 — corrects an
earlier "precision degrades with subtlety" framing). The git-fix `safe.ts` is **canonical-clean, not
globally-clean**: once the planted canonical is removed, the detector correctly surfaces DISTINCT real issues that
were always present — and surfaces MORE of them on the subtler cells. That is the gate WORKING, not imprecision. A
real issue correctly flagged is a TP. Verified by reading the fixed code:
- **invite safe.ts** → `[high] stale-JWT permissions`: the new ceiling check reads `session.permissions` (JWT/
  session-cached); a post-mint role downgrade leaves a stale ceiling → genuine stale-perms exposure. **TP.**
- **team safe.ts** → `[critical] TOCTOU`: owner guards ARE present; the flagged race (read members at T0,
  `updateMemberRole` at T1, needs a concurrent owner-gated transfer in the window) is real-but-narrow. **TP, but
  `critical` is likely over-rated** for a sole-owner demotion requiring a concurrent privileged request.

So the ONE legitimate precision thread is **severity calibration** (team TOCTOU over-rated), NOT a "precision
gradient" and NOT task #17 (which is paraphrase-dedup, ~N findings : 1 canonical — a DIFFERENT phenomenon; these
residuals are distinct issues, not paraphrases).

Headline finding (extends S3): the band-1 generalist prompt is payments-domain-FRAMED yet its AuthZ / IDOR passes
GENERALIZE to NON-payments RBAC routes — every cell here is role/invite/team-management code, zero payments surface.
Fork C ("close a named blind spot") executed for S2.

## Cells [MEASURED] — all `domains/security/corpus/S2-*`

Each cell = one planted canonical (the real pre-fix escalation) in an otherwise-correct, authorized + zod-validated
route; siblings + the already-present checks are the discriminator pressure. RED = vuln.ts (canonical present),
GREEN = safe.ts (canonical fixed). k=3 rolls, `gate.py --config-dir /tmp/sg_cfg` (blind clean config), v2@sonnet/MED
band-1 leg. Cells are literal git blobs (`git show <fix>:path` → safe.ts, `<fix>~1:path` → vuln.ts), zero hand-edit.
Verdict HAND-JUDGED on the rolls (does a roll name the *canonical* escalation as a real defect), not substring match.

| cell | fix SHA | canonical | sub-shape (rising subtlety) | RED (vuln) | GREEN (safe) |
|---|---|---|---|---|---|
| S2-role-privilege-escalation | 1b19ed1 | `permissionsExceedActor` missing in POST+PATCH /roles | inline permission-array, escalate-within-authorized-action | **CAUGHT [critical]** (crit 2/3 + 1/3 rolls) | **clean** — canonical not re-flagged (one PATCH merge-vs-replace note = sharp residual, not a re-flag) |
| S2-invite-privilege-escalation | 2a70b38 | attacker `roleId` → `createInvitation` with no permission ceiling check | roleId indirection (capability handle, perms behind `getPermissionsForRole`) | **CAUGHT [critical] 3/3** (dominant finding) | **canonical clean** — residual `[high] stale-JWT permissions` 3/3 = a DISTINCT real finding (TP): the new ceiling reads JWT-cached `session.permissions`, stale after a role downgrade. NOT a re-flag of the canonical. |
| S2-team-owner-invariant | 43d8e10 | missing owner-role INVARIANT in PATCH /team/:id/role (demote/promote owner, bypass owner-gated /transfer) | named-role invariant — needs cross-endpoint + domain reasoning (subset-check already present in vuln) | **CAUGHT [critical] but THIN — variance-prone** (2/3 union: "PATCH can demote owner" 1/3 + "owner demotable by any users:invite holder" 1/3; was 5th-ranked; a re-roll could MISS — the most fragile recall datapoint, like S8/F3, do not equal-weight with roles/invite) | **canonical clean** — the "no guard" defect DISAPPEARED (detector saw the added owner guards). Residual `[critical] TOCTOU race ... demoting the sole owner` 3/3 = a DIFFERENT, real-but-narrow concern (TP), NOT a re-flag. `critical` likely over-rated (needs a concurrent owner-gated transfer in the window) → severity-calibration, see below. |

## canonical-clean ≠ globally-clean [MEASURED — the corrected finding]

Recall held flat (3/3 CAUGHT). The git-fix `safe.ts` files are **canonical-clean** (the planted defect is not
re-flagged) but NOT **globally-clean** — they contain other real issues, and the detector correctly finds more of
them on the subtler cells. These residuals are TRUE POSITIVES, not "precision noise":

1. **roles (inline array, easiest)** → GREEN clean. The detector reads the added `permissionsExceedActor` guard and
   does not re-raise; no notable residual. Textbook discrimination.
2. **invite (roleId indirection, medium)** → canonical clean; residual `[high] stale-JWT permissions` is a **TP** —
   the new ceiling reads JWT-cached `session.permissions`, which is stale after a post-mint role downgrade. The gate
   is correctly surfacing a real, distinct issue. (`high` severity is defensible.)
3. **team (named-role invariant, hardest)** → canonical clean (the "no guard" defect is gone — detector saw the
   added owner guards); residual `[critical] TOCTOU` is a **TP but narrow** — a real race needing a concurrent
   owner-gated transfer in the read-then-write window. `critical` is **likely over-rated** for that.

**Implication:** recall is a per-class RATE (good for S2: 3/3). The residuals are the gate WORKING (distinct TPs on
canonical-clean-but-not-globally-clean files), NOT a precision failure. The ONE genuine precision thread is
**severity calibration** (team TOCTOU rated `critical`) — NOT a "gradient" and NOT task #17 (paraphrase-dedup is a
different phenomenon; see below).

## The team-cell GREEN — RECONCILED [MEASURED + advisor 2026-06-17]

The earlier "is it noise or a discrimination failure?" framing was a FALSE dichotomy. Advisor-reconciled answer:
**the team safe.ts residual is a TRUE POSITIVE — discrimination on the canonical HOLDS, and the TP is the gate
working.** A real issue correctly surfaced is not "noise"; filing a TP under "precision" would steer dedup (#17)
toward suppressing real bugs — exactly wrong for a security gate.

- **Discrimination on the canonical HOLDS:** the planted "missing owner-invariant guard" defect is GONE — the
  detector explicitly saw the two added owner guards (`target.role === 'owner'` → 403, `role.name === 'owner'` →
  403). The new `[critical] TOCTOU` is a DIFFERENT concern (read-then-write race), not a re-flag.
- **The TOCTOU is a real-but-narrow TP:** between `listTeamMembers` (T0) and `updateMemberRole` (T1) a concurrent
  owner-gated `/team/transfer` could promote the target, so the later update demotes the now-owner. Real, but it
  needs a concurrent privileged request in a tight window → **`critical` is over-rated.** The legit fix here is
  severity calibration, not suppression.

**bench tooling note (still true, kept):** bench's mechanical `is_flagged` substring-matches the DESCRIPTIVE
`canonical_symbol` "owner role invariant", which never lexically appears in the model's real titles ("demote
owner", "TOCTOU"). Measured (`bench --findings` on recorded titles): team RED recall=0 (matcher MISSES the caught
canonical) and all 3 GREEN recall=0. So bench cannot adjudicate this cell — hand-judge is required (as
canonical.json declares). Do NOT `--require S2-team-owner-invariant` as a regression gate (false-negative). Do NOT
raise the symbol to "owner" — the TOCTOU title contains "owner" → a FALSE re-flag on safe.ts.

The taxonomy entry is written true: S2 recall [MEASURED] CAUGHT 3/3 (hand-judged); GREEN is canonical-clean on all
3 cells, with real TP residuals on invite + team. No "clean GREEN" claim and no "precision-noisy" claim is made —
the residuals are TPs.

## Why these cells are honest, not gimmes [DESIGN]

Discriminator pressure was built in so a trigger-happy detector would FALSE-POSITIVE on the safe siblings — the
recall passes are escalation WITHIN authorized, validated actions, not missing-auth IDOR:

- **roles:** `requirePermission('users:manage')` on every handler, zod permission-vocabulary validation, tenant
  scoping, and system-role immutability are all present and correct in BOTH vuln and safe. A detector flagging
  "coarse RBAC" would false-positive safe.ts too — it did not; it flagged the *escalation* specifically.
- **invite:** the public accept endpoint is correctly public-by-design (the token hash IS the bearer credential) —
  a trigger-happy detector flags it missing-auth; it is not. The seat-cap 402 check is present (the route DOES
  validate some things), so the role-authorization gap is a genuine omission, not a blanket no-validation route.
- **team:** the permission-subset anti-escalation check is ALREADY present and correct in vuln.ts — the canonical is
  a SEPARATE missing invariant. Strong in-file tell: sibling DELETE /team/:id DOES guard the owner ("Cannot remove
  the owner") while PATCH-role did not — a sharp detector spots the asymmetry. Credit was given ONLY to a finding
  naming the owner demotion/promotion backdoor, not a generic "coarse RBAC" note.

## Implication [DESIGN]

- S2 single-file Shape-A RBAC escalation needs **no new band** — band-1 recall covers it (3/3). It does NOT need a
  prompt change for RECALL. The GREEN residuals are TPs (canonical-clean ≠ globally-clean), not a precision tax; the
  only precision thread is severity calibration (team TOCTOU over-rated) — NOT task #17.
- The payments-framing of `security-generalist.prompt.txt` did NOT block non-payments RBAC detection — confirming
  and extending the S3 finding. Narrows prompt-generalization (#16/#22): framing is payments, AuthZ passes transfer;
  measure per-class, do not assume blocked by the title. [now MEASURED for S2 RBAC + S3 IDOR.]
- The named-role-invariant sub-shape (team) is the per-class boundary: band-1 catches it but THINLY (cross-endpoint
  + domain reasoning, drowned in noise). A candidate band-2 / prompt-pass target if precision work alone doesn't lift
  it — recorded, not acted on (out of fork-C scope).

## Recall-counting [MEASURED — honesty]

Per-cell recall = canonical flagged at high/critical by ≥1 roll (hand-judged it names the canonical escalation, not
an adjacent finding). All 3 cells: CAUGHT. roles + invite caught strongly (crit, multi-roll); team caught thinly
(2/3 union, low rank — VARIANCE-PRONE, a re-roll could miss; the most fragile S2 datapoint).

**[CROSS-REF 2026-06-17 — roles is also variance-prone, not solid.]** A SEPARATE un-persisted k=3 batch
(`docs/validation/2026-06-17-recall-sweep-24.md`, S2-004, SAME blob `1b19ed1`) named this roles canonical at
high/crit in only **1/3 rolls** — vs the crit 2/3 here. The binary "caught by ≥1 roll" is consistent across both
(both ≥1/3); the RATE disagrees (2/3 vs 1/3) → the roles cell is variance-prone too, NOT a reliable 2/3. Rolls were
not persisted in either run, so this cannot be re-judged — only re-run. The recall-sweep over-claimed its 1/3 as a
"clean isolation"; that claim is demoted there. For S2 here: the binary CAUGHT stands; do NOT treat the roles RATE
as solid. GREEN discrimination =
the canonical symbol draws NO escalation finding once fixed; residual GREEN findings (invite stale-JWT, team TOCTOU)
are distinct genuine TRUE POSITIVES on canonical-clean-but-not-globally-clean files — the gate working, NOT counted
as canonical re-flags and NOT "precision noise" (advisor-reconciled).

## Next [DESIGN]

- (DONE) team-cell GREEN reconciled with advisor: residuals are TPs, discrimination holds — see section above.
- The one real precision thread is **severity calibration** (team TOCTOU rated `critical` is over-rated). This is
  NOT task #17 (paraphrase-dedup, ~N:1 same-issue restatement) — these residuals are distinct issues, a different
  phenomenon. Do NOT link this finding to #17.
- All named band-1 BLIND SPOTS are now closed (S2 + S3 measured). Remaining: S11 is structural non-LLM by design
  (pnpm-audit + headers, band-3, unbuilt). Recall RATE coverage: S2 + S3 are n=3; other MEASURED classes still n=1.

## Provenance

Source repo `~/Projects/zync.is`; all 3 fix SHAs raw-git verified (1b19ed1 roles, 2a70b38 invite, 43d8e10 team —
43d8e10 bundles an S3 exchange_rates hunk + the S2 team.ts hunk; only the team.ts hunk is this cell). Cells are
literal git blobs reconstructed with `command git --no-pager show` (RTK truncates routed git show), secret-scrubbed
(the `password` token in invite cells is a request-field variable name — the invitee sets their own password on
accept — not a literal credential; faithful to source, kept). No fabricated cells.
</content>
</invoke>
