@vantageos/backend-doctor 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
1
  {
2
- "sourceDigest": "ed2e814dc6a69cc3ee7ddbb354981dfc9a8fb66e36390f50f4b498ba62ca0c26",
3
- "builtAt": "2026-10-11T00:31:27.173Z"
2
+ "sourceDigest": "d45a7d3884b503e47f03d312079a95b864e185f1ea8e0e21d2b8b008f59d020b",
3
+ "builtAt": "2026-10-11T10:37:03.282Z"
4
4
  }
@@ -20,6 +20,7 @@ import { existsSync } from "node:fs";
20
20
  import { join } from "node:path";
21
21
  import { siteCount, withCount } from "../../violation-count.js";
22
22
  import { hasJustificationTokenInText } from "../oracle-axis2.js";
23
+ import { scanR11UnboundedReads } from "./isolation-read.js";
23
24
  /** The standard all four are pinned against (short hash a9d7877b). */
24
25
  export const SOURCE_CONTRACT_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15";
25
26
  function emptyScan() {
@@ -179,6 +180,13 @@ export function scanR11(files) {
179
180
  }
180
181
  }
181
182
  }
183
+ // The unbounded-read arm (isolation-read.ts): a `.collect()` / `for await` read
184
+ // of a tenant table in a door a non-master caller is served, with no index range
185
+ // on the tenant key. R-55 hands this shape to R-11 by name.
186
+ const unboundedArm = scanR11UnboundedReads(files);
187
+ scan.conform.push(...unboundedArm.conform);
188
+ scan.violate.push(...unboundedArm.violate);
189
+ scan.cnj.push(...unboundedArm.cnj);
182
190
  return scan;
183
191
  }
184
192
  // ---------------------------------------------------------------------------
@@ -375,7 +383,7 @@ export function scanR26(files) {
375
383
  function srcDetector(ruleId, id, check, scanFn, emptyReason) {
376
384
  return { id, run: (tree) => decide(ruleId, id, check, scanFn(tree.files), emptyReason) };
377
385
  }
378
- export const R11 = srcDetector("R-11", "R-11 tenant isolation is an in-query index predicate, not a post-query filter", "a .filter on a scope key is paired with an adjacent .withIndex/.withSearchIndex .eq on that key", scanR11, "R-11: zero scope-key .filter sites matched in the scanned convex tree — cannot certify the isolation locus. NOT a clean bill.");
386
+ export const R11 = srcDetector("R-11", "R-11 tenant isolation is an in-query index predicate, not a post-query filter", "a .filter on a scope key is paired with an adjacent .withIndex/.withSearchIndex .eq on that key; an unbounded read of a tenant table in a door a non-master caller is served carries the tenant key in its index range", scanR11, "R-11: zero scope-key .filter sites and zero unbounded tenant-table reads matched in the scanned convex tree — cannot certify the isolation locus. NOT a clean bill.");
379
387
  export const R24 = srcDetector("R-24", "R-24 filter-selected bulk-write defaults to dry run", "a bulk-write mutation defaults dryRun true and gates apply behind an explicit flag + caller guard", scanR24, "R-24: zero bulk-write mutations matched in the scanned convex tree — cannot certify dry-run-by-default. NOT a clean bill.");
380
388
  export const R25 = srcDetector("R-25", "R-25 minted address / cross-boundary bundle org-bound at both ends", "a storage mint or bundle redemption is org-bound by an auth guard; a minted bundle URL expires", scanR25, "R-25: zero storage mint/redemption sites matched (kb*/okfBundle*) — cannot certify org-binding. NOT a clean bill.");
381
389
  export const R26 = srcDetector("R-26", "R-26 library-owned component declares its seam boundary", "each app.use(component) seam declares an authority tier + boundary key", scanR26, "R-26: no component mount seam (*.config.ts app.use) found — cannot certify the component seam contract. NOT a clean bill.");
@@ -0,0 +1,2 @@
1
+ import { type Scan, type SrcFile } from "./_scan.js";
2
+ export declare function scanR11UnboundedReads(files: SrcFile[]): Scan;
@@ -0,0 +1,557 @@
1
+ // R-11, unbounded-read arm — the tenant boundary of an UNBOUNDED read is an
2
+ // index-range predicate inside the query, never a post-query handler filter.
3
+ //
4
+ // Standard bacc54a §4 R-11, read literally: "The boundary is an index-range
5
+ // predicate inside the query — `.withIndex("by_org", q => q.eq("orgId",
6
+ // identity.orgId))` — never a post-query handler filter … the query has already
7
+ // read rows the caller may not see, and correctness rests on a filter nothing
8
+ // enforces structurally — a leak and a cost at once." R-55's frontier hands this
9
+ // shape to R-11 by name: "an unbounded read has no window of its own, so a
10
+ // post-filter over it is R-11 and R-47".
11
+ //
12
+ // WHY A SECOND ARM. The line arm (contract.ts `scanR11`) judges a `.filter(` line
13
+ // whose predicate names a scope key. It cannot see the three cause-4 doors the
14
+ // door audit reproduced at vantage-peers 16f0907: the tenant predicate there is a
15
+ // helper call (`isNamespaceAllowedForScope`, `filterByOrgScope`) on a line that
16
+ // does not name a key, or there is no predicate at all (a streamed count).
17
+ //
18
+ // HOW IT READS. For every PUBLIC door in `convex/` (query, mutation, action) and
19
+ // every in-tree helper its handler reaches (call depth <= 4):
20
+ // read a `<…>.db.query(T)` chain that is UNBOUNDED: it ends in `.collect()`,
21
+ // or it is iterated by `for await (… of <chain>)` with no `.take`,
22
+ // `.paginate`, `.first` or `.unique` in it. A bounded read is R-55's.
23
+ // table T is a string literal, a const naming one, or a helper parameter
24
+ // bound at the call site to either. Anything else is could-not-judge.
25
+ // applies T carries a tenant column in `schema.ts` (orgId / tenantId /
26
+ // workspaceId / orgSlug, required or optional) or a `namespace`.
27
+ // conform an `.eq(K, …)` inside the chain's `.withIndex`/`.withSearchIndex`
28
+ // where K is a tenant column of T, or whose value reads the resolved
29
+ // caller (a value derived from `withOrgScope`/a resolver/an identity),
30
+ // or K is an id-reference column (`v.id("P")`): the read is then the
31
+ // children of ONE parent row, and whether the caller may reach that
32
+ // row is the door's by-id question (R-53, R-52), not R-11's locus;
33
+ // or the read is reached only by the fleet master: an earlier
34
+ // statement refuses every non-master caller (`requireResolvedCaller(…,
35
+ // { masterOnly: true })`, `if (!scope.isMaster) throw|return`, or an
36
+ // in-tree helper whose body does either first), or the read sits in
37
+ // the branch an `isMaster` test reserves for the master.
38
+ // red otherwise. The note says which harm: a post-query handler filter
39
+ // (a scope helper, or a test reading the caller or a tenant field,
40
+ // applied to the rows after the read) or no tenant predicate at all.
41
+ // cnj the table cannot be resolved, or there is no readable `schema.ts`.
42
+ //
43
+ // OUT OF SCOPE, stated. An internal function serves no caller; its reachability
44
+ // from a door through `ctx.runQuery` is not traced. A narrowing hidden behind a
45
+ // value the scan cannot trace is not seen. Where the value of an index `.eq` came
46
+ // from (an argument, a verified claim) is R-5 / R-6's question, not the locus.
47
+ import * as ts from "typescript";
48
+ import { indexFunctions, lineOf, NAMESPACE_COLUMN, parseFile, readSchema, registrations, TENANT_COLUMNS, unwrap, walk, } from "./_ast.js";
49
+ import { emptyScan, isJudgeable } from "./_scan.js";
50
+ const PUBLIC = new Set(["query", "mutation", "action"]);
51
+ const BOUNDING = new Set([
52
+ "take",
53
+ "paginate",
54
+ "first",
55
+ "unique",
56
+ ]);
57
+ const INDEX_CALLS = new Set([
58
+ "withIndex",
59
+ "withSearchIndex",
60
+ ]);
61
+ const MAX_DEPTH = 4;
62
+ /** A call whose result is the resolved caller (scope, principal, identity). */
63
+ const RESOLVER = /^(withOrgScope|getUserIdentity|resolve[A-Z]\w*|require[A-Z]\w*)$/;
64
+ /** A helper that narrows rows to the caller's scope after the read. */
65
+ const SCOPE_HELPER = /^(filterBy\w*|isRowVisibleToScope|isNamespaceAllowedForScope|\w*(ForScope|OnScope|ToScope|OnRoster|OnOrgRoster)\w*)$/;
66
+ const isConvexFile = (f) => /(^|\/)convex\//.test(f.rel);
67
+ // ---- small readers -----------------------------------------------------------
68
+ function calleeIdent(c) {
69
+ const e = unwrap(c.expression);
70
+ if (ts.isIdentifier(e))
71
+ return e.text;
72
+ if (ts.isPropertyAccessExpression(e))
73
+ return e.name.text;
74
+ return undefined;
75
+ }
76
+ function plainCallee(c) {
77
+ const e = unwrap(c.expression);
78
+ return ts.isIdentifier(e) ? e.text : undefined;
79
+ }
80
+ /** `<x>.db.query(…)` / `db.query(…)`. */
81
+ function isDbQuery(c) {
82
+ const e = unwrap(c.expression);
83
+ if (!ts.isPropertyAccessExpression(e) || e.name.text !== "query")
84
+ return false;
85
+ const o = unwrap(e.expression);
86
+ return ((ts.isPropertyAccessExpression(o) && o.name.text === "db") ||
87
+ (ts.isIdentifier(o) && o.text === "db"));
88
+ }
89
+ function stringOf(e) {
90
+ const u = unwrap(e);
91
+ if (ts.isStringLiteralLike(u))
92
+ return u.text;
93
+ if (ts.isIdentifier(u)) {
94
+ const init = constInitOf(u);
95
+ if (init) {
96
+ const i = unwrap(init);
97
+ if (ts.isStringLiteralLike(i))
98
+ return i.text;
99
+ }
100
+ }
101
+ return undefined;
102
+ }
103
+ /** nearest `const id = <init>` in an enclosing block or the file. */
104
+ function constInitOf(id) {
105
+ for (let x = id.parent; x; x = x.parent) {
106
+ const stmts = ts.isBlock(x) || ts.isSourceFile(x) ? x.statements : undefined;
107
+ if (!stmts)
108
+ continue;
109
+ for (const st of stmts) {
110
+ if (!ts.isVariableStatement(st))
111
+ continue;
112
+ if (!(st.declarationList.flags & ts.NodeFlags.Const))
113
+ continue;
114
+ for (const d of st.declarationList.declarations)
115
+ if (ts.isIdentifier(d.name) && d.name.text === id.text)
116
+ return d.initializer;
117
+ }
118
+ }
119
+ return undefined;
120
+ }
121
+ function identsIn(n) {
122
+ const out = new Set();
123
+ walk(n, (x) => {
124
+ if (!ts.isIdentifier(x))
125
+ return;
126
+ const p = x.parent;
127
+ if (p && ts.isPropertyAccessExpression(p) && p.name === x)
128
+ return;
129
+ if (p && ts.isPropertyAssignment(p) && p.name === x)
130
+ return;
131
+ out.add(x.text);
132
+ });
133
+ return out;
134
+ }
135
+ function readsAny(n, names) {
136
+ for (const i of identsIn(n))
137
+ if (names.has(i))
138
+ return true;
139
+ return false;
140
+ }
141
+ /** Names in `fn` holding the resolved caller: assigned from a resolver call, or
142
+ * derived from such a name (to a fixed point), or a loop variable over one. */
143
+ function callerNames(fn, seed) {
144
+ const out = new Set(seed);
145
+ const decls = [];
146
+ const bindingNames = (b) => ts.isIdentifier(b)
147
+ ? [b.text]
148
+ : b.elements.flatMap((e) => ts.isOmittedExpression(e) ? [] : bindingNames(e.name));
149
+ walk(fn, (x) => {
150
+ if (ts.isVariableDeclaration(x) && x.initializer)
151
+ decls.push({ names: bindingNames(x.name), init: x.initializer });
152
+ else if ((ts.isForOfStatement(x) || ts.isForInStatement(x)) &&
153
+ ts.isVariableDeclarationList(x.initializer))
154
+ for (const d of x.initializer.declarations)
155
+ decls.push({ names: bindingNames(d.name), init: x.expression });
156
+ });
157
+ let grew = true;
158
+ while (grew) {
159
+ grew = false;
160
+ for (const d of decls) {
161
+ if (d.names.every((n) => out.has(n)))
162
+ continue;
163
+ let hit = readsAny(d.init, out);
164
+ if (!hit)
165
+ walk(d.init, (y) => {
166
+ if (ts.isCallExpression(y)) {
167
+ const c = calleeIdent(y);
168
+ if (c && RESOLVER.test(c))
169
+ hit = true;
170
+ }
171
+ });
172
+ if (hit)
173
+ for (const n of d.names)
174
+ if (!out.has(n)) {
175
+ out.add(n);
176
+ grew = true;
177
+ }
178
+ }
179
+ }
180
+ return out;
181
+ }
182
+ // ---- the fleet-master gate ---------------------------------------------------
183
+ /** `!x.isMaster` / `!isMaster`, or an `||` one of whose operands is one. */
184
+ function refusesNonMaster(cond) {
185
+ const c = unwrap(cond);
186
+ if (ts.isBinaryExpression(c) &&
187
+ c.operatorToken.kind === ts.SyntaxKind.BarBarToken)
188
+ return refusesNonMaster(c.left) || refusesNonMaster(c.right);
189
+ if (ts.isPrefixUnaryExpression(c) &&
190
+ c.operator === ts.SyntaxKind.ExclamationToken)
191
+ return isMasterRead(c.operand);
192
+ return false;
193
+ }
194
+ function isMasterRead(e) {
195
+ const u = unwrap(e);
196
+ return ((ts.isIdentifier(u) && u.text === "isMaster") ||
197
+ (ts.isPropertyAccessExpression(u) && u.name.text === "isMaster"));
198
+ }
199
+ function exits(s) {
200
+ if (ts.isThrowStatement(s) || ts.isReturnStatement(s))
201
+ return true;
202
+ if (ts.isBlock(s)) {
203
+ const last = s.statements[s.statements.length - 1];
204
+ return last !== undefined && exits(last);
205
+ }
206
+ return false;
207
+ }
208
+ function hasMasterOnlyFlag(c) {
209
+ return c.arguments.some((a) => {
210
+ const u = unwrap(a);
211
+ return (ts.isObjectLiteralExpression(u) &&
212
+ u.properties.some((p) => ts.isPropertyAssignment(p) &&
213
+ ts.isIdentifier(p.name) &&
214
+ p.name.text === "masterOnly" &&
215
+ p.initializer.kind === ts.SyntaxKind.TrueKeyword));
216
+ });
217
+ }
218
+ /** Does this statement refuse every non-master caller before the next one runs? */
219
+ function isMasterGate(s, masterHelpers) {
220
+ if (ts.isIfStatement(s))
221
+ return refusesNonMaster(s.expression) && exits(s.thenStatement);
222
+ let call;
223
+ if (ts.isExpressionStatement(s))
224
+ call = s.expression;
225
+ else if (ts.isVariableStatement(s))
226
+ call = s.declarationList.declarations.find((d) => d.initializer)?.initializer;
227
+ if (!call)
228
+ return false;
229
+ const u = unwrap(call);
230
+ if (!ts.isCallExpression(u))
231
+ return false;
232
+ const name = plainCallee(u);
233
+ if (name === "requireResolvedCaller" && hasMasterOnlyFlag(u))
234
+ return true;
235
+ return name !== undefined && masterHelpers.has(name);
236
+ }
237
+ function bodyStatements(fn) {
238
+ return fn.body && ts.isBlock(fn.body) ? fn.body.statements : [];
239
+ }
240
+ /** In-tree functions whose body refuses every non-master caller (closed over calls). */
241
+ function masterOnlyHelpers(idx) {
242
+ const out = new Set();
243
+ let grew = true;
244
+ while (grew) {
245
+ grew = false;
246
+ for (const [n, ds] of idx) {
247
+ if (out.has(n))
248
+ continue;
249
+ if (ds.every((d) => bodyStatements(d.fn).some((s) => isMasterGate(s, out)))) {
250
+ out.add(n);
251
+ grew = true;
252
+ }
253
+ }
254
+ }
255
+ return out;
256
+ }
257
+ /** Is `node` (inside `fn`) reached only by the fleet master within this frame? */
258
+ function masterOnlyAt(fn, node, masterHelpers) {
259
+ // an enclosing `isMaster` branch reserved for the master
260
+ for (let x = node; x && x !== fn; x = x.parent) {
261
+ const p = x.parent;
262
+ if (!p)
263
+ break;
264
+ if (ts.isIfStatement(p)) {
265
+ if (p.thenStatement === x && isMasterRead(p.expression))
266
+ return true;
267
+ if (p.elseStatement === x && refusesNonMaster(p.expression))
268
+ return true;
269
+ }
270
+ if (ts.isConditionalExpression(p)) {
271
+ if (p.whenTrue === x && isMasterRead(p.condition))
272
+ return true;
273
+ if (p.whenFalse === x && refusesNonMaster(p.condition))
274
+ return true;
275
+ }
276
+ // an earlier statement of any enclosing block that refuses non-masters
277
+ if (ts.isBlock(p) || ts.isSourceFile(p)) {
278
+ for (const s of p.statements) {
279
+ if (s === x)
280
+ break;
281
+ if (isMasterGate(s, masterHelpers))
282
+ return true;
283
+ }
284
+ }
285
+ }
286
+ return false;
287
+ }
288
+ function chainOf(q) {
289
+ const methods = [];
290
+ let top = q;
291
+ for (;;) {
292
+ const p = top.parent;
293
+ if (!p || !ts.isPropertyAccessExpression(p) || p.expression !== top)
294
+ break;
295
+ const call = p.parent;
296
+ if (!call || !ts.isCallExpression(call) || call.expression !== p)
297
+ break;
298
+ methods.push({ name: p.name.text, call });
299
+ top = call;
300
+ }
301
+ return { query: q, methods, top };
302
+ }
303
+ function unbounded(ch) {
304
+ if (ch.methods.some((m) => BOUNDING.has(m.name)))
305
+ return undefined;
306
+ if (ch.methods.some((m) => m.name === "collect"))
307
+ return "collect";
308
+ let x = ch.top;
309
+ while (x.parent &&
310
+ (ts.isParenthesizedExpression(x.parent) || ts.isAsExpression(x.parent)))
311
+ x = x.parent;
312
+ const p = x.parent;
313
+ if (p && ts.isForOfStatement(p) && p.expression === x && p.awaitModifier)
314
+ return "for await";
315
+ return undefined;
316
+ }
317
+ function tenantKeysOf(t) {
318
+ const out = new Set();
319
+ for (const f of t.fields)
320
+ if (TENANT_COLUMNS.has(f))
321
+ out.add(f);
322
+ if (t.namespace !== "none")
323
+ out.add(NAMESPACE_COLUMN);
324
+ return out;
325
+ }
326
+ /** An index `.eq` carrying a tenant key, a value read from the caller, or an
327
+ * id-reference column (the children of ONE parent row). */
328
+ function narrowsInQuery(ch, keys, caller, idRef) {
329
+ for (const m of ch.methods) {
330
+ if (!INDEX_CALLS.has(m.name))
331
+ continue;
332
+ let found;
333
+ walk(m.call, (x) => {
334
+ if (found || !ts.isCallExpression(x))
335
+ return;
336
+ if (calleeIdent(x) !== "eq" || x.arguments.length < 2)
337
+ return;
338
+ const k = stringOf(x.arguments[0]);
339
+ if (k !== undefined && keys.has(k))
340
+ found = `.${m.name} .eq("${k}")`;
341
+ else if (k !== undefined && idRef.has(k))
342
+ found = `.${m.name} .eq("${k}"), an id reference to ${idRef.get(k)}: the read is the children of one ${idRef.get(k)} row, and whether the caller may reach that row is the door's by-id question (R-53/R-52), not the locus`;
343
+ else if (readsAny(x.arguments[1], caller))
344
+ found = `.${m.name} .eq("${k ?? "?"}") on a value read from the resolved caller`;
345
+ });
346
+ if (found)
347
+ return found;
348
+ }
349
+ return undefined;
350
+ }
351
+ /** Is there a narrowing step applied to the rows after the read, in this frame? */
352
+ function postFilterAfter(fn, ch, keys, caller) {
353
+ const from = ch.top.getEnd();
354
+ const scope = unbounded(ch) === "for await"
355
+ ? ch.top.parent
356
+ : (fn.body ?? fn);
357
+ let found;
358
+ walk(scope, (x) => {
359
+ if (found || x.getStart() < from)
360
+ return;
361
+ if (ts.isCallExpression(x)) {
362
+ const c = plainCallee(x);
363
+ if (c && SCOPE_HELPER.test(c)) {
364
+ found = `${c}(…)`;
365
+ return;
366
+ }
367
+ if (calleeIdent(x) === "filter" && x.arguments[0]) {
368
+ const cb = x.arguments[0];
369
+ let tenantField = false;
370
+ walk(cb, (y) => {
371
+ if (ts.isPropertyAccessExpression(y) && keys.has(y.name.text))
372
+ tenantField = true;
373
+ });
374
+ if (tenantField || readsAny(cb, caller))
375
+ found = ".filter(…) on the caller's scope";
376
+ }
377
+ }
378
+ });
379
+ return found;
380
+ }
381
+ function resolveDef(idx, name, rel) {
382
+ const ds = idx.get(name);
383
+ if (!ds || ds.length === 0)
384
+ return undefined;
385
+ return ds.find((d) => d.rel === rel) ?? (ds.length === 1 ? ds[0] : undefined);
386
+ }
387
+ function tableRef(arg, frame) {
388
+ const s = stringOf(arg);
389
+ if (s !== undefined)
390
+ return { kind: "table", name: s };
391
+ const u = unwrap(arg);
392
+ if (ts.isIdentifier(u) && frame.tableOf.has(u.text))
393
+ return frame.tableOf.get(u.text);
394
+ return {
395
+ kind: "unknown",
396
+ why: `the table is \`${arg.getText()}\`, a value the scan cannot resolve to a table name`,
397
+ };
398
+ }
399
+ function visitFrame(c, frame, depth) {
400
+ const caller = callerNames(frame.fn, frame.tainted);
401
+ walk(frame.fn, (x) => {
402
+ if (!ts.isCallExpression(x))
403
+ return;
404
+ if (isDbQuery(x)) {
405
+ judgeRead(c, frame, x, caller);
406
+ return;
407
+ }
408
+ if (depth >= MAX_DEPTH)
409
+ return;
410
+ const name = plainCallee(x);
411
+ if (!name)
412
+ return;
413
+ const def = resolveDef(c.idx, name, frame.rel);
414
+ if (!def || def.fn === frame.fn || frame.via.includes(name))
415
+ return;
416
+ const tableOf = new Map();
417
+ const tainted = new Set();
418
+ def.fn.parameters.forEach((p, i) => {
419
+ if (!ts.isIdentifier(p.name))
420
+ return;
421
+ const a = x.arguments[i];
422
+ if (!a)
423
+ return;
424
+ tableOf.set(p.name.text, tableRef(a, frame));
425
+ if (readsAny(a, caller))
426
+ tainted.add(p.name.text);
427
+ });
428
+ visitFrame(c, {
429
+ fn: def.fn,
430
+ rel: def.rel,
431
+ name,
432
+ tableOf,
433
+ tainted,
434
+ via: [...frame.via, name],
435
+ anchor: frame.anchor ?? x,
436
+ masterOnly: frame.masterOnly || masterOnlyAt(frame.fn, x, c.masterHelpers),
437
+ }, depth + 1);
438
+ });
439
+ }
440
+ function judgeRead(c, frame, q, caller) {
441
+ const ch = chainOf(q);
442
+ const kind = unbounded(ch);
443
+ if (!kind || !q.arguments[0])
444
+ return;
445
+ const anchor = frame.anchor ?? q;
446
+ const line = lineOf(anchor);
447
+ const rel = anchor.getSourceFile().fileName;
448
+ const via = frame.via.length
449
+ ? ` via ${frame.via.join(" -> ")} (${frame.rel}:${lineOf(q)})`
450
+ : "";
451
+ const ref = tableRef(q.arguments[0], frame);
452
+ const key = `${c.door}|${line}|${ref.kind === "table" ? ref.name : "?"}`;
453
+ if (c.seen.has(key))
454
+ return;
455
+ c.seen.add(key);
456
+ const master = frame.masterOnly || masterOnlyAt(frame.fn, q, c.masterHelpers);
457
+ const at = `${c.door}: unbounded ${kind} read`;
458
+ if (ref.kind === "unknown") {
459
+ if (master)
460
+ return;
461
+ c.scan.cnj.push({
462
+ rel,
463
+ n: line,
464
+ note: `${at}${via} — ${ref.why}; whether it carries a tenant column is not decided`,
465
+ });
466
+ return;
467
+ }
468
+ if (!c.schema) {
469
+ c.scan.cnj.push({
470
+ rel,
471
+ n: line,
472
+ note: `${at} of ${ref.name}${via} — no readable convex/schema.ts, so whether ${ref.name} carries a tenant column is not decided`,
473
+ });
474
+ return;
475
+ }
476
+ const t = c.schema.get(ref.name);
477
+ if (!t) {
478
+ c.scan.cnj.push({
479
+ rel,
480
+ n: line,
481
+ note: `${at} of ${ref.name}${via} — ${ref.name} is not a table of convex/schema.ts`,
482
+ });
483
+ return;
484
+ }
485
+ const keys = tenantKeysOf(t);
486
+ if (keys.size === 0)
487
+ return; // no tenant column: not an isolation site
488
+ if (master) {
489
+ c.scan.conform.push({
490
+ rel,
491
+ n: line,
492
+ note: `${at} of ${ref.name}${via} on a fleet-master-only path (every non-master caller is refused before the read)`,
493
+ });
494
+ return;
495
+ }
496
+ const inQuery = narrowsInQuery(ch, keys, caller, t.idRef);
497
+ if (inQuery) {
498
+ c.scan.conform.push({
499
+ rel,
500
+ n: line,
501
+ note: `${at} of ${ref.name}${via} — narrowed by an index range inside the query (${inQuery})`,
502
+ });
503
+ return;
504
+ }
505
+ const post = postFilterAfter(frame.fn, ch, keys, caller);
506
+ const tenant = [...keys].join("/");
507
+ c.scan.violate.push({
508
+ rel,
509
+ n: line,
510
+ note: post
511
+ ? `${at} of ${ref.name}${via} with no index range on ${tenant}; the tenant predicate is a post-query handler filter (${post}) — every row of ${ref.name} the index admits is read before the caller's scope is applied (R-11 defect)`
512
+ : `${at} of ${ref.name}${via} with no index range on ${tenant} and no tenant predicate at all, on a door a non-master caller is served — the result is computed over every tenant's rows (R-11 defect)`,
513
+ });
514
+ }
515
+ export function scanR11UnboundedReads(files) {
516
+ const scan = emptyScan();
517
+ const parsed = [];
518
+ for (const f of files) {
519
+ if (!isJudgeable(f) || !isConvexFile(f))
520
+ continue;
521
+ try {
522
+ parsed.push(parseFile(f));
523
+ }
524
+ catch {
525
+ // an unparsable file yields no site here; other rules report parse failures
526
+ }
527
+ }
528
+ const schemaFile = parsed.find((p) => /(^|\/)convex\/schema\.ts$/.test(p.rel));
529
+ const schema = schemaFile ? readSchema(schemaFile) : undefined;
530
+ const idx = indexFunctions(parsed);
531
+ const masterHelpers = masterOnlyHelpers(idx);
532
+ for (const p of parsed) {
533
+ for (const r of registrations(p)) {
534
+ if (!PUBLIC.has(r.builder))
535
+ continue;
536
+ const door = r.name;
537
+ visitFrame({
538
+ schema: schema && schema.size > 0 ? schema : undefined,
539
+ idx,
540
+ masterHelpers,
541
+ scan,
542
+ door,
543
+ seen: new Set(),
544
+ }, {
545
+ fn: r.handler,
546
+ rel: p.rel,
547
+ name: door,
548
+ tableOf: new Map(),
549
+ tainted: new Set(),
550
+ via: [],
551
+ anchor: undefined,
552
+ masterOnly: false,
553
+ }, 0);
554
+ }
555
+ }
556
+ return scan;
557
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vantageos/backend-doctor",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "A conformance instrument that measures the VantagePeers backend against the pinned backend standard. The THIRD doctor built ON the @vantageos/mcp-doctor engine — never a third engine. It consumes runDoctor/renderTable and adds only detectors.",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",