# Recall sweep (#24) — band-1 COVERAGE per class, merge default-on (2026-06-17)

audience: AI coding agents first. MEASURED log; tags [MEASURED]/[INFERRED]. Do not prettify. Growing doc —
one section per class as it reaches n≥3. A class at n=1 is a POINT ESTIMATE, not a rate; never report it as one.

## BLUF
The recall sweep validates the COVERAGE knob: does the band-1 generalist-LLM catch each S-class on real
git-pinned cells. Runs on UN-distorted roll-counts now that #17a (semantic-merge) is default-on. Pilot-first:
take ONE class to n≥3 cells (each k≥3 rolls), confirm the method on the first added cell BEFORE fanning out.
**S6 (SSRF) is MEASURED at n=3, 100% caught** (all 3 sub-shapes, each 3/3 rolls). The #17a multi-cell merge
validation folds in here (planned): the merge fired conservatively on real paraphrase clusters across every
cell, zero over-merge, exit 0. **S3 (tenant isolation) is MEASURED at n=5:** band-1 CATCHES the three RECOGNIZABLE
tenant-isolation/IDOR patterns 3/3 (a tenant-unscoped lookup; an FK that builds a cross-tenant path; a route-param
FK→query — the last is cross-file-confirmed yet caught because its IDOR shape is textbook). Band-1 UNDER-DETECTS
the one canonical that is NOT an access pattern — a body REFERENCE (`assignee_id`) that must be validated as a
legitimate in-tenant MEMBER via an *absent* `getMembership` call — measured at **1/3, n=2 CONSISTENT** across two
independent codebases. **Attribution [advisor-reconciled]: this is a band-1 PROMPT-RECOGNITION gap (#16/#22 track),
NOT band-2 resolver territory (#36).** The load-bearing argument is STRUCTURAL: the defect is an *absent* call —
there is no imported symbol for #36's resolver to pull, and the whole defect is visible in the importer file, so
detecting it is a single-file/prompt problem, categorically UNLIKE the Shape-B/C02 case where you must resolve a
*called* import's insecure-default body. (A within-file `project_id` 3/3 vs `assignee_id` 1/3 observation SUPPORTS
this but is CONFOUNDED — pre-fix the POST handler validates `project_id`/`status_id` while PATCH validates none, so
the 3/3 is PATCH and the 1/3 is not handler-attributed; supporting-with-caveat, not load-bearing.) This REMOVES a
mis-attributed item from #36's pile; #36 stays motivated SOLELY by the separate Shape-B/C02 **0/3** miss.
**S2 (RBAC) n=1 — supporting-with-caveat, NOT a clean isolation [CORRECTED 2026-06-17]:** the S2-004
permission-CEILING canonical (no `permissions ⊆ session.permissions` check) named at high/crit in only **1/3 rolls**
in THIS sweep — BUT the SAME git blob + SAME canonical was measured **CAUGHT at crit 2/3** in the S2 band-1 doc
(`#38`, `docs/validation/2026-06-17-S2-rbac-band1.md`, fix `1b19ed1`; its recall bar — "names the create/update
escalation as a real defect" — judges the SAME canonical, not a looser bar). The binary "named by ≥1 roll" is
actually CONSISTENT (both ≥1/3); what DISAGREES is the RATE — 2/3 vs 1/3 on the same blob, two un-persisted k=3
batches → the cell is **variance-prone** (consistent with #38's own soft "crit 2/3 + 1/3"; merge-default may also
differ between the two runs). So 1/3 is NOT a clean point estimate and does NOT "kill the resolution-gap hypothesis
by construction". It is supporting-with-caveat; the **STRUCTURAL argument** (an *absent* call has no symbol to
resolve) carries the recognition-not-resolution conclusion on its own. Cross-CLASS generalization is **n=1, NOT
established** anyway. Other classes remain n=1 point-estimates until swept the same way.

## Method [MEASURED]
- Cells = pre-fix files reconstructed via RAW git (`command git --no-pager show <fix>~1:<path>`) from the
  zync.is audit fix commits (ground-truth-from-git). No fabricated cells.
- Each cell: `gate.py <cell> --k 3 --config-dir /tmp/sg_cfg` (clean unseeded config, refreshed creds,
  merge default-on). Hand-judge the canonical's roll-count (`bench.is_flagged` is a weak lexical proxy).
- Recall is judged on the CANONICAL (the planted-then-fixed bug), not the full finding set. Other real
  findings the LLM surfaces are true positives (canonical-clean ≠ globally-clean) but are not the rate.

## S6 — SSRF [MEASURED, n=3, all 3/3 rolls, merge-on]
Fix commit `bec8bc8` (SSRF-guard consolidation, 5 sub-cells). Three distinct sub-shapes measured:

| cell (pre-fix `bec8bc8~1`) | sub-shape | canonical surfaced | rolls |
|---|---|---|---|
| `apps/zync-api/.../endpoints.ts` (S6-001) | store-URL on PATCH (no guard call) | PATCH updates endpoint URL without SSRF validation | 3/3 |
| `packages/integrations/src/tasks/jira.ts` (S6-002) | in-file outbound `fetch(baseUrl…)` | SSRF via unvalidated `baseUrl` in both fetch calls | 3/3 (2 paraphrases merged) |
| `packages/public-api/src/routes/webhooks.ts` (S6-003) | store-URL on POST (HTTPS-only check present) | no private/reserved IP blocklist on `target_url` | 3/3 |

- **All three sub-shapes caught at the max roll-count.** The store-URL shape (canonical is a MISSING guard
  call on a route that persists an attacker-URL) and the in-file-fetch shape (canonical is a dangerous
  outbound `fetch` with no guard) both surface reliably — band-1 does not need the guard import resolved to
  flag the absence (Shape-A).
- **webhooks.ts is the strongest negative-control sub-case:** pre-fix it DOES validate HTTPS
  (`target_url.startsWith('https://')`) — a partial check. Band-1 still flagged the SSRF gap 3/3, i.e. it
  distinguishes "validates scheme" from "validates destination," not fooled by the present-but-insufficient check.
- **Oracle SILENT on all three (correct):** SSRF is not the complete-mediation class; no unresolved-import
  false-clean was raised (no identity-guard predicate in these files).

### #17a multi-cell merge validation [MEASURED — folds in here per plan]
Across the three S6 cells the conservative merge fired on real paraphrase clusters and never over-merged:
- jira.ts: SSRF canonical (2 paraphrases → one 3/3 group); missing-credential-check (3 paraphrases → 3/3).
- webhooks.ts: subscription-dedup (2→2/3); `listWebhooks` truncation (2→3/3); `meta.total` misreport (2→2/3);
  `createdAt.toISOString` null-crash (2→2/3).
- endpoints.ts (this session's #17a end-to-end run): JSON.parse (3→3/3); retry no-state-guard (2→3/3).
- **No distinct-bug pair was collapsed** in any run (the SSRF canonical stayed separate from secret-storage,
  dedup, DoS, etc.). Exit 0, no `degraded` warning on any cell — merge succeeded every time.
- This promotes #17a from n=1 (the single S6-001 fixture) to a multi-cell MEASURED result: the instrument-fix
  is active and conservative across 3 independent cells.

## S3 — tenant isolation [MEASURED, n=5 — gap = MEMBERSHIP-of-a-body-reference, n=2 CONSISTENT @1/3; STRUCTURAL argument → band-1 RECOGNITION (#16/#22), NOT resolver (#36)]
Five cells. The first framing (n=3) read the split as Shape-A/Shape-B; a 4th cell (task-messages, a route-param
IDOR that is *technically* cross-file-confirmed yet CAUGHT) sharpened it to: band-1 catches RECOGNIZABLE
tenant-isolation/IDOR patterns even when confirmation is cross-file; the residual gap is the SUBTLE case — a
body reference that must be validated as an in-tenant MEMBER via a query. A 5th cell (public-api `tasks.ts`,
an INDEPENDENT codebase, same `assignee_id` sub-shape) firmed the gap to **n=2 CONSISTENT** (1/3 in BOTH cells).
**Attribution [advisor-reconciled]: a band-1 PROMPT-RECOGNITION gap (#16/#22), NOT band-2 resolver territory (#36),
on a STRUCTURAL argument:** the canonical is an *absent* `getMembership` call, so there is no imported symbol for
#36's resolver to pull and the whole defect is visible in the importer file; detecting it is single-file/prompt
work, categorically UNLIKE Shape-B/C02 (resolve a *called* import's insecure-default body). The within-file
`project_id` 3/3 vs `assignee_id` 1/3 observation SUPPORTS this but is CONFOUNDED — pre-fix the POST handler
validates `project_id`/`status_id` while PATCH validates none, so the 3/3 is a PATCH-handler catch and the 1/3
`assignee_id` is not handler-attributed (gate.py does not persist per-roll handler info; a fresh k=3 is stochastic).
Treat the contrast as supporting-with-caveat; the structural argument carries the conclusion.

| cell (pre-fix) | sub-shape | pattern band-1 must see | result |
|---|---|---|---|
| `routes/time.ts` (S3-003 P1, `4d4c401`) | magic-link recipient via `getUserByEmail`, no tenant scope | tenant-unscoped lookup (call shape) | CAUGHT 3/3 CRITICAL (2 merged) |
| `routes/attachments.ts` (S3-004 P2, `1307f50`) | POST `taskId` → R2 key `${tid}/tasks/${taskId}/…`, no `getTask` | FK builds a cross-tenant path (in-file) | CAUGHT 3/3 (2 merged) |
| `routes/task-messages.ts` (S3-006 P2, `1307f50`) | route-param `taskId` → DB query, no `getTask` | param→query-without-ownership = textbook IDOR | CAUGHT 3/3 (taskId-not-enforced) + 1/3 explicit membership-check-missing |
| `routes/tasks.ts` (body-FK sweep, `efa7486`) | body FK `assignee_id` → DB write, no `getMembership` | "is this reference a legit in-tenant member" — NOT an access pattern | **band-1 MISS** (1/3 generic) → designed band-2 escalation |
| `zync-public-api/routes/tasks.ts` (`7fb0c02`, INDEPENDENT codebase) | body FK `assignee_id` → `createTask`, no `getMembership`; sibling `project_id`/`status_id` ARE validated in POST | same membership-of-reference sub-shape, n=2 | **band-1 MISS** (1/3) — same-file `project_id` 3/3 / `status_id` 2/3 are PATCH-handler catches, NOT handler-matched to the 1/3 (confounded contrast — see prose) |

- **The gap is sharper than "Shape-B."** Band-1 catches the three RECOGNIZABLE patterns 3/3 — a tenant-unscoped
  lookup, an FK that builds a cross-tenant path, and a route-param FK→query (classic IDOR) — including
  task-messages, whose cross-tenant confirmation is technically downstream yet whose IDOR *shape* is textbook.
  The single NEAR-MISS is the one canonical that is NOT an access pattern: `assignee_id` is a body REFERENCE
  that must be validated as a legitimate tenant member (via `getMembership`). Band-1 surfaced a
  related-but-distinct bug (`reporter_id` forgery 3/3) + raised only a generic 1/3 cross-file flag — it
  UNDER-DETECTS "validate this reference is in-tenant" (1/3) relative to "this access is unscoped" (3/3).
- **This gap is PROMPT-track (#16/#22), NOT resolver-track (#36) [advisor-reconciled — STRUCTURAL argument].**
  Earlier framing put the membership miss in #36's territory; that was an over-loose read, now corrected. The
  canonical is an *absent* `getMembership` call — #36's resolver pulls and analyzes the *bodies* of *called*
  imports, and has no entry point on a call that isn't there; the whole defect is visible in the importer, so
  catching it is a single-file/prompt problem. This therefore **REMOVES a mis-attributed item from #36's pile**
  rather than sharpening it. #36 stays motivated SOLELY by the separate Shape-B/C02 0/3 miss (resolve a *called*
  import's insecure default). Per DESIGN the 1/3 flag is a REQUIRED ESCALATION not a clean pass — scored a band-1
  MISS (what the sweep measures); do NOT round the escalation up into "caught." The lever choice (improve the
  #16/#22 prompt vs other) is the USER's call — surface it; do not autonomously rewrite #16 (advisor-forbidden)
  and do not start #36 (USER-GATED).
- **Why recognition, not resolution [advisor-reconciled — the discriminator is structural, not roll-count].** A
  resolution gap (Shape-B/C02) is where a *called* import's body hides an insecure default you must resolve to see;
  band-1 is silent 0/3 there. This miss is the OPPOSITE shape — the dangerous thing is a call that is ABSENT, fully
  visible in the importer, with no symbol to resolve — so #36's machinery has no purchase and the lever is band-1's
  prompt. The within-file `project_id` 3/3 vs `assignee_id` 1/3 observation is consistent with this but does NOT
  establish it: pre-fix the POST handler already validates `project_id`/`status_id` (band-1 correctly stays silent
  on them there) and the 3/3 catch is the PATCH handler where all FKs are unvalidated, while the 1/3 `assignee_id`
  is not handler-attributed (gate.py does not persist per-roll handler info; a fresh k=3 re-run is stochastic, so
  the split was not upgraded). Supporting-with-caveat; the structural argument carries the conclusion.
- The membership-of-a-body-reference sub-shape is now **n=2 CONSISTENT** (1/3 in both `efa7486` zync-api and
  `7fb0c02` public-api `assignee_id` cells), across two independent codebases. The 3 access-IDOR catches are
  independent cells. Oracle SILENT on all five (correct — not the identity-collision class).

## S2 — RBAC / privilege escalation [n=1 POINT ESTIMATE, RATE-CONTRADICTED — same-blob batch disagreement (this sweep 1/3 rolls vs #38 crit 2/3) → supporting-with-caveat, NOT a clean isolation; structural argument carries recognition-not-resolution; cross-class generalization NOT established (n=1)]
One cell so far — **S2 is NOT a measured rate (n=1), and the one point estimate is CONTRADICTED** (see the
batch-disagreement bullet). Originally reported as a clean no-resolution replication of the S3 recognition gap in a
different class; now demoted to supporting-with-caveat. Cell = pre-fix `roles.ts` (`1b19ed1~1`), canonical S2-004: POST `/`
and PATCH `/:id` write a custom role from `parsed.data.permissions` with NO check that
`permissions ⊆ session.permissions` (the fix `1b19ed1` adds `permissionsExceedActor()`). A `users:manage` holder
can mint/escalate a role beyond their own set.

| cell (pre-fix) | canonical | cross-file resolution needed? | result |
|---|---|---|---|
| `routes/roles.ts` (S2-004, `1b19ed1`) | POST/PATCH role write, no `permissions ⊆ session.permissions` ceiling | NONE — `session.permissions` is the actor's in-scope JWT set | **1/3 rolls THIS sweep — RATE-CONTRADICTED** (#38 measured crit 2/3, same blob; variance-prone; binary caught is consistent) |

- **Supporting-with-caveat, NOT a clean isolation [CORRECTED 2026-06-17 — same-blob batch disagreement].** S2-004
  needs ZERO cross-file resolution (`session.permissions` is in-scope; the fix is a local set-comparison). In THIS
  sweep band-1 named it at high/crit in 1/3 rolls — BUT the SAME git blob + SAME canonical was measured **CAUGHT at
  crit 2/3** by the S2 band-1 doc (`#38`, `docs/validation/2026-06-17-S2-rbac-band1.md`, fix `1b19ed1`; its recall
  bar — "does a roll name the create/update escalation as a real defect" — judges the SAME canonical, not a looser
  bar). The binary "named by ≥1 roll" is CONSISTENT (both ≥1/3); the RATE disagrees — 2/3 vs 1/3 on the same blob,
  two un-persisted k=3 batches → **variance-prone** (consistent with #38's own soft "crit 2/3 + 1/3";
  merge-default-on may also differ between the two runs; rolls not retained, cannot re-judge). So the 1/3 is
  CONTRADICTED, not a clean datapoint — it does NOT "kill the resolution-gap hypothesis by construction." It is
  supporting-with-caveat; the STRUCTURAL argument (an *absent*/local-set-comparison defect has no symbol for #36 to
  resolve) independently carries recognition-not-resolution. A re-run at k=3/k=6 would only add another noisy
  straddling point — NOT done (advisor-steered; it changes nothing actionable on the USER-GATED #16-vs-#36 lever).
  (Also NOT unconfounded in every dimension — see the within-file/salience caveat below.)
- **Candidate defect family: "authorization-CEILING on writes / anti-escalation subset check" — n=1 cross-class, NOT
  a law.** Band-1 reliably catches "this ACCESS is unscoped" (read/path IDOR — S3 access-IDOR 3/3 ×3; in THIS very
  file the `reassign_to_role_id` cross-tenant IDOR caught 3/3) but UNDER-DETECTS "this WRITE grants/references
  something beyond the actor's own authority" (S3 tenant-membership 1/3 ×2; S2 permission-ceiling 1/3-but-RATE-CONTRADICTED
  — #38 measured the SAME cell at crit 2/3). The robust leg is S3 membership n=2; the S2 leg is now a rate-contradicted
  single point, so cross-CLASS is **n=1 AND shaky**, an even weaker CANDIDATE generalization, not established. An
  equally live reading is **SALIENCE**: a relational subset/membership check is subtler to spot than a
  pattern-matchable unscoped access — `reassign_to_role_id` is itself a privilege grant yet was caught 3/3, so the
  split may track "pattern-matchable vs relational-subtle" as much as "access vs authority-write." Do not harden the
  dichotomy into a law from one file.
- **Within-file contrast, again — same caveat.** Same file: `reassign_to_role_id` access-IDOR 3/3 vs
  `permissions`-ceiling escalation 1/3. Suggestive of the access-vs-authority (or salience) split, but the two sit on
  different routes (DELETE vs POST/PATCH) → same route-attribution confound as S3; supporting, not load-bearing. The
  ABSOLUTE 1/3 on this cell is RATE-CONTRADICTED (#38 measured it at crit 2/3) → NO longer the load-bearing part; the
  STRUCTURAL argument carries the conclusion alone.
- Oracle SILENT (correct — not the identity-collision class).

## NOT established (honest)
- **S6 (n=3, all recognizable-pattern) and S3 (n=5, 3 access-IDOR caught 3/3 + membership MISS n=2 @1/3) are the
  measured classes.** S1/S4/S5/S7/S8/S10 are prior n=1 point-estimates (all-caught, but not rates); S2 (RBAC) cells
  exist (task #38) but recall is UNMEASURED in THIS sweep. The sweep is incomplete — do not read "S6 = 100%"
  or "S3 access-IDOR caught" as a band-1 coverage claim for the whole corpus.
- **The S3 membership MISS is the load-bearing result, not a footnote.** It is the first MEASURED band-1 coverage
  gap of the sweep, now n=2 CONSISTENT (1/3 in two independent codebases). [advisor-reconciled] Attribution: a
  band-1 PROMPT-RECOGNITION gap (#16/#22), NOT cross-file RESOLUTION (#36), on the STRUCTURAL argument (an *absent*
  call has no symbol for #36 to resolve; the defect is in-file). The within-file `project_id` 3/3 vs `assignee_id`
  1/3 observation is CONFOUNDED (POST validates the siblings, PATCH validates none → the 3/3 is PATCH, the 1/3
  unattributed) and is supporting-only. The lever decision (#16/#22 prompt) is the USER's; no autonomous #16
  rewrite (advisor-forbidden), no starting #36 (USER-GATED).
- **The recognition attribution rests on the STRUCTURAL argument — BOTH empirical legs are supporting-with-caveat
  [corrected 2026-06-17].** (i) The S3 within-file contrast (`project_id` 3/3 vs `assignee_id` 1/3 in `7fb0c02`) is
  CONFOUNDED by handler (POST vs PATCH) → supporting-only. (ii) The S2-004 no-resolution replication is RATE-CONTRADICTED
  — the SAME blob+canonical measured at crit 2/3 in #38 vs 1/3 here (binary caught is consistent, the RATE disagrees →
  variance-prone, rolls un-persisted), so it is NOT the "clean empirical isolation" the earlier draft claimed. NEITHER leg is load-bearing; the STRUCTURAL
  argument (an *absent*/local-check defect has no symbol for #36 to resolve; the defect is in-file) carries the
  conclusion alone. Remaining cheap upgrades, none load-bearing, NONE the next action (advisor-steered — do not
  auto-spawn a marginal re-run): (a) persist per-roll handler attribution in gate.py + re-run to split the S3 1/3 by
  handler; (b) take S2 to n≥3 to turn the contradicted point estimate into a measured RATE (only if the lever
  decision ever needs it); (c) a Shape-B imported-insecure-DEFAULT cell (the true resolution gap, 0/3) to contrast
  against the S3 1/3 recognition miss. Oracle SILENT on all S3+S2 cells (correct — not the C02 identity-collision
  class).
- **All S6 cells are Shape-A** (visible missing/insufficient in-file check). Shape-B (imported insecure
  DEFAULT — the C02 founding shape) is the MEASURED band-1 silent miss (0/3) and is #36's territory (USER-GATED) —
  distinct from this S3 membership miss (1/3 recognition, not 0/3 resolution).
- **Under-merge rate still uncharacterized** — conservative bias means borderline pairs occasionally stay
  split (safe direction). Frequency across cells unmeasured. No over-merge observed in any cell.
- **Cost not benchmarked** — each cell = 3 rolls + 1 merge `claude -p` calls.

## Next [ordered]
1. ✅ DONE — membership-of-a-reference firmed to n=2 (public-api `tasks.ts` `7fb0c02`, 1/3, with within-file
   `project_id` 3/3 contrast). The contrast surfaced a NEW reading (recognition, not resolution).
2. ✅ DONE — ADVISOR RECONCILE. Verdict: the miss is a band-1 PROMPT-RECOGNITION gap (#16/#22), NOT resolution
   (#36), on the STRUCTURAL argument — an *absent* `getMembership` has no symbol for #36's resolver to pull and is
   fully visible in the importer. The within-file `project_id`/`assignee_id` contrast is CONFOUNDED (POST vs PATCH
   handler) → demoted to supporting-with-caveat. This REMOVES a mis-attributed item from #36's pile; #36 stays
   motivated SOLELY by the Shape-B/C02 0/3 miss.
3. **HAND THE LEVER CALL TO THE USER.** The one measured S3 band-1 gap is #16/#22-prompt territory and the
   productive sweep runway is spent (advisor). The #16-vs-#36 priority is a USER decision — surface it; do not
   autonomously rewrite #16 (advisor-forbidden), do not start #36 (USER-GATED). **THIRD FACET — same frontier
   (held-out #13):** the oracle's field-name lexicon (`*UserId`/`amount…`/`type` + `key:value` form) is a THIRD
   instance of the ONE payments-overfit defect, alongside #16 (band-1 payments-FRAMED prompt) and #36 (band-2
   payments CRITICAL regex). All three are the SAME generalization boundary (overfit to the multideal payments
   donor), not three problems — steer oracle-lexicon-generalization in the SAME user decision. The #13 off-lexicon
   "UNRELIABLE" notice (finding §"NOT established") is NOT a standalone build: it's a sub-option of the oracle
   facet, synthetic-motivated (no real git-pinned off-lexicon miss), FP-heavy (fires on every amount-ish object
   literal), and band-1 is a co-miss backstop here (no LLM config ever caught the founding C02 self-deal) → do NOT
   build it ahead of the frontier decision.
4. S2 (RBAC) — cells built (#38), but the roles.ts ceiling cell is a RATE-CONTRADICTED point estimate (this sweep
   1/3 rolls vs #38 crit 2/3 on the same blob → variance-prone; binary caught is consistent). NOT the next action:
   re-measuring at n≥3 only earns its tokens if the lever decision needs an S2 rate (advisor: don't auto-spawn a
   marginal re-run).
5. Then promote the remaining n=1 classes (S1/S4/S5/S7/S8/S10) to n≥3 ONLY if value justifies (re-proving easy
   classes adds little — advisor's prior steer).
6. #36 (band-2 cross-file resolver, Shape-B 0/3) remains the existential #1 and is USER-GATED — and is NO LONGER
   motivated by the S3 membership miss (that miss is prompt-track). Its sole motivation is the Shape-B 0/3 result.

## Pointers
- Code: `orchestrator/gate.py` (`--k`, `--config-dir`, merge default-on), `orchestrator/semantic_merge.py`.
- #17a spike (instrument-fix rationale): `docs/validation/2026-06-17-semantic-merge-17a-spike.md`.
- Ground truth: zync.is audit at `~/Projects/zync.is/docs/plans/audit/security/`; S6 fix `bec8bc8`.
