@vantageos/backend-doctor 0.1.14 → 0.2.0

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.
Files changed (54) hide show
  1. package/README.md +19 -2
  2. package/dist/args.d.ts +16 -0
  3. package/dist/args.js +54 -0
  4. package/dist/build-stamp.d.ts +13 -0
  5. package/dist/build-stamp.js +76 -0
  6. package/dist/build-stamp.json +4 -0
  7. package/dist/caller-resolution.d.ts +222 -0
  8. package/dist/caller-resolution.js +1195 -0
  9. package/dist/cli.js +91 -8
  10. package/dist/coverage-cli.js +8 -0
  11. package/dist/detectors/oracle-axis1.d.ts +5 -3
  12. package/dist/detectors/oracle-axis1.js +24 -15
  13. package/dist/detectors/oracle-axis2.js +5 -6
  14. package/dist/detectors/predicates.d.ts +3 -0
  15. package/dist/detectors/predicates.js +81 -16
  16. package/dist/detectors/shared.js +5 -5
  17. package/dist/detectors/source/backfill.js +9 -1
  18. package/dist/detectors/source/guard-helper-flow.d.ts +112 -0
  19. package/dist/detectors/source/guard-helper-flow.js +1173 -0
  20. package/dist/detectors/source/identity.d.ts +51 -2
  21. package/dist/detectors/source/identity.js +815 -14
  22. package/dist/detectors/source/isolation.d.ts +11 -1
  23. package/dist/detectors/source/isolation.js +106 -25
  24. package/dist/detectors/source/jobs.js +165 -2
  25. package/dist/detectors/source/readbudget.d.ts +1 -0
  26. package/dist/detectors/source/readbudget.js +67 -4
  27. package/dist/detectors/source/security.d.ts +16 -0
  28. package/dist/detectors/source/security.js +225 -7
  29. package/dist/detectors/source/write-isolation.d.ts +8 -1
  30. package/dist/detectors/source/write-isolation.js +64 -30
  31. package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
  32. package/dist/detectors/standard/r46-returns-validator.js +45 -14
  33. package/dist/detectors/structural.js +14 -10
  34. package/dist/oracle.d.ts +92 -3
  35. package/dist/oracle.js +229 -12
  36. package/dist/registry.d.ts +7 -0
  37. package/dist/registry.js +48 -6
  38. package/dist/report.d.ts +11 -1
  39. package/dist/report.js +15 -2
  40. package/dist/state-rank.d.ts +5 -0
  41. package/dist/state-rank.js +9 -0
  42. package/dist/tally.js +1 -7
  43. package/dist/target.d.ts +32 -0
  44. package/dist/target.js +173 -0
  45. package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
  46. package/fixtures/cr1-classes/surface.ts +206 -0
  47. package/fixtures/cr1-empty/nothing.ts +1 -0
  48. package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
  49. package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
  50. package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
  51. package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
  52. package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
  53. package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
  54. package/package.json +2 -2
@@ -1,3 +1,4 @@
1
+ import { conditionalBoundaries, findHelperDefinitions, judgePredicateFunction, judgeResolverFunction, readHandlerGuards, summarizeValidator, topLevelFunctions, topLevelHelperCalls, } from "./guard-helper-flow.js";
1
2
  /** The standard both are pinned against (short hash a9d7877b), §15/§18. */
2
3
  export const IDENTITY_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15/§18";
3
4
  function emptyScan() {
@@ -17,8 +18,32 @@ function isJudgeable(f) {
17
18
  function code(l) {
18
19
  return !l.comment;
19
20
  }
20
- /** convex source only — R-6/R-16 govern the data layer's write handlers. */
21
- function isConvex(f) {
21
+ /**
22
+ * True when the ENGINE's scan root is itself a convex directory. loadTree sets
23
+ * `tree.root` to the target the CLI was pointed at, and computes every file's
24
+ * `rel` relative to that root. So the SAME convex file yields two different rels
25
+ * under the two documented invocations:
26
+ * - pointed at the repo ROOT → rel = `convex/messages.ts` (carries `convex/`)
27
+ * - pointed at the CONVEX dir → rel = `messages.ts` (bare, no segment)
28
+ * The bare-rel case is the normal, documented invocation, and it is precisely
29
+ * where a `rel`-only `isConvex` guard (see below) returned FALSE for 100% of
30
+ * files — silently skipping R-6/R-16 (Path-B C1 blind spot 1). When the root IS
31
+ * a convex dir, EVERY collected file is by construction under that convex tree,
32
+ * so no non-convex file (mcp-server/app/src) can be pulled in by trusting it.
33
+ */
34
+ function rootIsConvexDir(root) {
35
+ // Any `convex` path SEGMENT of the scan root (`.../convex` or `.../convex/…`).
36
+ // A repo root (`/…/vantage-memory`) has no such segment, so this stays FALSE
37
+ // there and the per-file `convex/` rel prefix does the scoping instead.
38
+ return /(^|\/)convex(\/|$)/.test(root.replace(/\/+$/, ""));
39
+ }
40
+ /** convex source only — R-6/R-16 govern the data layer's write handlers. When
41
+ * the scan root is itself a convex dir every file is convex source (bare rels);
42
+ * otherwise a file qualifies only by a `convex/` rel prefix — so a repo-root
43
+ * scan still EXCLUDES non-convex source (mcp-server/src/*, app/*, src/*). */
44
+ function isConvex(f, rootIsConvex) {
45
+ if (rootIsConvex)
46
+ return true;
22
47
  return /(^|\/)convex\//.test(f.rel);
23
48
  }
24
49
  /**
@@ -73,10 +98,11 @@ function decide(ruleId, id, check, scan, emptyReason) {
73
98
  }
74
99
  /** Split a convex file into its top-level mutation/internalMutation handler
75
100
  * blocks. A block runs from `export const <name> = mutation(` to the next
76
- * top-level `export ` (or EOF). Queries/actions are not R-6 write handlers. */
77
- function splitMutationHandlers(f) {
101
+ * top-level `export ` (or EOF). Queries/actions are not R-6 write handlers.
102
+ * Each block records whether it is an `internalMutation` registration. */
103
+ export function splitMutationHandlers(f) {
78
104
  const out = [];
79
- const open = /export\s+const\s+(\w+)\s*=\s*(?:internalMutation|mutation)\s*\(/;
105
+ const open = /export\s+const\s+(\w+)\s*=\s*(internalMutation|mutation)\s*\(/;
80
106
  const topExport = /^export\s+/;
81
107
  let cur = null;
82
108
  for (const l of f.lines) {
@@ -84,7 +110,12 @@ function splitMutationHandlers(f) {
84
110
  if (m) {
85
111
  if (cur)
86
112
  out.push(cur);
87
- cur = { name: m[1], startN: l.n, lines: [l] };
113
+ cur = {
114
+ name: m[1],
115
+ startN: l.n,
116
+ lines: [l],
117
+ internal: m[2] === "internalMutation",
118
+ };
88
119
  continue;
89
120
  }
90
121
  if (cur) {
@@ -117,15 +148,591 @@ const R6_WRITE = /ctx\.db\.(?:insert|patch|replace|delete)\s*\(/;
117
148
  const R6_IDENTITY_GUARD = /getUserIdentity|resolveOrgId|resolveOrgContext|assertNamespaceAllowed|expectedOrgIdForNamespace|tenantSlug|organizationId|organizationSlug|AUTH_NO_ORG|AUTH_NAMESPACE_DENIED/;
118
149
  const R6_RESOURCE_GUARD = /assertTaskCallerAuthorized|RBAC_DENIED|\.createdBy\s*===|\.assignedTo\s*===|\.orchestratorId\s*===|callerOrchestrator\s*===\s*["']system["']|note\.createdBy/;
119
150
  const R6_BOUNDARY_ARG = /\b(?:createdBy|orgId|orgSlug|namespace|orchestratorId|ownerId|tenantId)\b/;
120
- export function scanR6(files) {
121
- const scan = emptyScan();
151
+ // ---- WRITE-SITE provenance (blind spot 2) ---------------------------------
152
+ //
153
+ // Guard-PRESENCE alone cannot distinguish the historical tenant-write defect
154
+ // from its fix: BOTH `messages.sendMessage` versions call `withOrgScope(ctx)`.
155
+ // The real discriminator is the tenant-scoping boundary field's RHS AT THE
156
+ // WRITE SITE. `tenantId: args.tenantId` (raw caller arg, written directly) is
157
+ // the defect; `tenantId: derivedTenantId` (a scope-derived local) is the fix —
158
+ // even though a `derivedTenantId = args.tenantId` ASSIGNMENT exists inside the
159
+ // master carve-out branch, that assignment is not a ctx.db write and is never
160
+ // flagged. So a direct-arg tenant write is decisive VIOLATE regardless of any
161
+ // guard, and a derived-local tenant write (with scope resolved) is CONFORM.
162
+ // `args.x`, and a COMPUTED subscript `args[k]` (whatever the key, the value is
163
+ // read off the caller's arguments). A literal-keyed subscript (`args["x"]`) never
164
+ // reaches this regex: normalizeArgsAccess rewrites it to `args.x` first.
165
+ const R6_TENANT_FIELDS = new Set([
166
+ "tenantId",
167
+ "orgId",
168
+ "orgSlug",
169
+ ]);
170
+ const R6_TENANT_FROM_ARG = /\b(?:tenantId|orgId|orgSlug)\s*:\s*args(?:\.\w+|\s*\[)/;
171
+ // ---- literal-keyed subscript (task k179nydvbdxm40ps7g0y18mwhs8dje91) -------
172
+ //
173
+ // `args["orgSlug"]`, `args['orgSlug']` and args[`orgSlug`] are the SAME property
174
+ // read as `args.orgSlug`. Every R-6 regex below was written against the dotted
175
+ // form, so the subscript form was invisible: `const t = args["orgSlug"]` kept
176
+ // its `\borgSlug\b` token after the `args.<field>` strip and read SCOPE-derived,
177
+ // and `tenantId: args["orgSlug"]` matched no direct-arg regex at all. Rewriting
178
+ // the literal subscript to the dotted form BEFORE any regex runs makes every
179
+ // existing rule see one shape. A template literal is rewritten only when it has
180
+ // no `\${…}` interpolation (the key must be a plain identifier).
181
+ export function normalizeArgsAccess(raw) {
182
+ return raw.replace(/\bargs\s*\[\s*(["'`])([A-Za-z_$][\w$]*)\1\s*\]/g, "args.$2");
183
+ }
184
+ function normalizeLines(lines) {
185
+ return lines.map((l) => {
186
+ const raw = normalizeArgsAccess(l.raw);
187
+ return raw === l.raw ? l : { ...l, raw };
188
+ });
189
+ }
190
+ const R6_SCOPE_RESOLUTION = /withOrgScope|getUserIdentity|resolveOrgId|resolveOrgContext|assertNamespaceAllowed|RBAC_DENIED/;
191
+ // ---- follow-the-local taint (blind spot 2b — the ONE-HOP rename) ----------
192
+ //
193
+ // Write-site RHS provenance alone is defeated by a single rename:
194
+ // const t = args.tenantId; ... ctx.db.insert(..., { tenantId: t })
195
+ // The boundary field is now a LOCAL, so R6_TENANT_FROM_ARG does not match and
196
+ // R6_TENANT_FROM_LOCAL wrongly reads CONFORM — the caller's argument reaches the
197
+ // write untouched, wearing another name. Same file, same defect, laundered.
198
+ //
199
+ // The fix follows the local ONE hop: collect every assignment/declaration of the
200
+ // local written as a boundary field, and discriminate on whether ANY of them is
201
+ // SCOPE-derived. A local fed ONLY from args (fromScope === 0) is the argument
202
+ // under an alias → TAINTED → VIOLATE. A local that receives at least one
203
+ // scope/identity-derived value (fromScope >= 1) is genuinely derived → CONFORM.
204
+ // This is exactly what keeps the master carve-out safe (its non-master branch
205
+ // assigns from scope.orgSlug → fromScope >= 1) WITHOUT rescuing the laundering
206
+ // (whose sole assignment is `= args.tenantId` → fromScope === 0).
207
+ // The verified-identity CLAIM names (`identity.organizationId`, `.organizationSlug`,
208
+ // `.tenantSlug`) are the same tokens R6_IDENTITY_GUARD already trusts. Without
209
+ // them a shorthand `{ orgId }` fed from `identity.organizationId` read TAINTED —
210
+ // the false positive the shorthand read (k176ewgwrbc6mg1y4h42rg25gn8dja1p)
211
+ // surfaced in tests/source-identity.test.ts "neuter-proof". An argument whose
212
+ // field carries such a name is stripped below before this test, as for orgSlug.
213
+ const R6_SCOPE_RHS = /\bscope\b|withOrgScope|getUserIdentity|resolveOrgId|resolveOrgContext|\borgSlug\b|assertNamespaceAllowed|\b(?:organizationId|organizationSlug|tenantSlug)\b/;
214
+ /** The non-`args` LOCAL identifiers written to a tenant-scoping boundary field
215
+ * at this write-site text (`tenantId: <local>`). `tenantId: args.tenantId` is
216
+ * excluded here — it is the DIRECT-arg case handled by R6_TENANT_FROM_ARG. */
217
+ function boundaryLocalsInWrite(text) {
218
+ const out = [];
219
+ const re = /\b(?:tenantId|orgId|orgSlug)\s*:\s*([A-Za-z_$][\w$]*)/g;
220
+ let m = re.exec(text);
221
+ while (m !== null) {
222
+ if (m[1] !== "args")
223
+ out.push(m[1]);
224
+ m = re.exec(text);
225
+ }
226
+ // ES6 SHORTHAND (task k176ewgwrbc6mg1y4h42rg25gn8dja1p): `{ tenantId }` is
227
+ // `{ tenantId: tenantId }` — the boundary field written from a LOCAL of the
228
+ // same name. The keyed regex above needs a `:` and never saw it, so
229
+ // `const tenantId = args.namespace; insert({ tenantId })` read CONFORM. The
230
+ // shorthand member sits after `{` or `,` and before `,` or `}` (or a line
231
+ // end, for a multi-line literal); its local then goes through the SAME
232
+ // provenance analysis as a keyed local.
233
+ const short = /(?:^|[{,])\s*(tenantId|orgId|orgSlug)\s*(?=[,}]|$)/gm;
234
+ let s = short.exec(text);
235
+ while (s !== null) {
236
+ out.push(s[1]);
237
+ s = short.exec(text);
238
+ }
239
+ return out;
240
+ }
241
+ /** If `local` is bound by destructuring the HANDLER'S OWN PARAMETER —
242
+ * `handler: async (ctx, { tenantId }) =>` / `async handler(ctx, { tenantId })`
243
+ * — it IS a caller argument under its own name. Returns true when this line is
244
+ * such a binding of `local`. */
245
+ function boundFromHandlerParam(raw, local) {
246
+ const m = /\bhandler\b[^(\n]*\(\s*[A-Za-z_$][\w$]*\s*(?::\s*[\w.<>]+\s*)?,\s*\{([^}]*)\}/.exec(raw);
247
+ if (!m)
248
+ return false;
249
+ for (const rawPart of m[1].split(",")) {
250
+ const part = rawPart.trim().split("=")[0].trim();
251
+ if (!part)
252
+ continue;
253
+ const bound = part.includes(":") ? (part.split(":")[1] ?? "").trim() : part;
254
+ if (bound === local)
255
+ return true;
256
+ }
257
+ return false;
258
+ }
259
+ /** Max bare-identifier hops chased before exhaustion is declared could-not-judge
260
+ * (defect 3): `const t = orgSlug; const orgSlug = args.x` resolves in one extra
261
+ * hop; deeper indirection or a cycle stops and is reported cnj, never guessed. */
262
+ const R6_MAX_HOP = 2;
263
+ /** If `local` is bound by an object-destructuring declaration on `raw`, return
264
+ * the destructuring RHS (the expression after `} =`) so it can be tested for
265
+ * scope-ness EXACTLY as a plain assignment RHS is (Eta REVISE #3 defect 1).
266
+ * Recognises the shorthand (`const { orgSlug } = ...`), the keyed/renamed form
267
+ * (`const { orgSlug: t } = ...`), and a defaulted binding (`const { x = d } = `).
268
+ * Returns null when this line does not destructure `local`. */
269
+ function destructuredRhs(raw, local) {
270
+ const m = /(?:const|let|var)\s*\{([^}]*)\}\s*=(?!=)\s*([\s\S]+)$/.exec(raw);
271
+ if (!m)
272
+ return null;
273
+ for (const rawPart of m[1].split(",")) {
274
+ const part = rawPart.trim().split("=")[0].trim(); // drop any default value
275
+ if (!part)
276
+ continue;
277
+ const bound = part.includes(":") ? (part.split(":")[1] ?? "").trim() : part;
278
+ if (bound === local)
279
+ return m[2];
280
+ }
281
+ return null;
282
+ }
283
+ /** Follow ONE hop (plus one bounded bare-identifier hop): classify every
284
+ * assignment/declaration of `local` in the handler as scope-derived, arg-derived,
285
+ * or could-not-judge. Reads a PLAIN assignment RHS and a DESTRUCTURING RHS with
286
+ * the same scope test (defect 1). Exposes `matched` so a local with NO resolvable
287
+ * assignment is a third state, not silently tainted (defect 2). A bare-identifier
288
+ * RHS (`const t = orgSlug`) is NOT scope evidence on its own — the identifier's
289
+ * own provenance is resolved one further, bounded, cycle-guarded hop (defect 3);
290
+ * exhaustion is `cnj`, never a guess. */
291
+ export function analyzeLocalTaint(h, local, isResolver) {
292
+ return analyzeLocalTaintInner({ ...h, lines: normalizeLines(h.lines) }, local, 0, new Set(), isResolver);
293
+ }
294
+ /** `rhs` is exactly `await <name>(…)` closed on this line (nothing after the
295
+ * call but `;`): the whole value is that function's return. */
296
+ function wholeAwaitedCall(rhs) {
297
+ const m = /^\s*await\s+([A-Za-z_$][\w$]*)\s*\(/.exec(rhs);
298
+ if (!m)
299
+ return null;
300
+ let depth = 0;
301
+ for (let i = m[0].length - 1; i < rhs.length; i++) {
302
+ const ch = rhs[i];
303
+ if (ch === "(")
304
+ depth++;
305
+ else if (ch === ")") {
306
+ depth--;
307
+ if (depth === 0)
308
+ return /^\s*;?\s*$/.test(rhs.slice(i + 1)) ? m[1] : null;
309
+ }
310
+ }
311
+ return null;
312
+ }
313
+ function analyzeLocalTaintInner(h, local, depth, visited, isResolver) {
314
+ let fromArgs = 0;
315
+ let fromScope = 0;
316
+ let matched = 0;
317
+ let cnj = 0;
318
+ let fromResolver = 0;
319
+ const esc = local.replace(/[$]/g, "\\$&");
320
+ const assign = new RegExp(`(?:^|[^\\w.$])${esc}\\s*(?::[^=\\n]*)?=(?!=)`);
321
+ const decl = new RegExp(`\\b(?:const|let|var)\\s+${esc}\\b`);
322
+ const classify = (rhs) => {
323
+ // A BARE IDENTIFIER RHS carries no scope evidence on its own — its NAME
324
+ // (even `orgSlug`) never counts as scope; the identifier's own provenance
325
+ // decides, or the scan refuses (defect 3, Eta pole 2).
326
+ const bare = rhs
327
+ .trim()
328
+ .replace(/;+\s*$/, "")
329
+ .trim();
330
+ if (/^[A-Za-z_$][\w$]*$/.test(bare) && bare !== "args") {
331
+ if (visited.has(bare) || depth >= R6_MAX_HOP) {
332
+ cnj++; // cycle or hop exhausted — could-not-judge, never a guess.
333
+ return;
334
+ }
335
+ const next = new Set(visited);
336
+ next.add(local);
337
+ const sub = analyzeLocalTaintInner(h, bare, depth + 1, next, isResolver);
338
+ if (sub.matched === 0)
339
+ cnj++; // the referenced local has no resolvable assignment.
340
+ else if (sub.fromScope > 0 && sub.fromArgs === 0)
341
+ fromScope++;
342
+ else if (sub.fromArgs > 0)
343
+ fromArgs++;
344
+ else
345
+ cnj++;
346
+ return;
347
+ }
348
+ // The whole value is the return of a function the tree shows to be an
349
+ // identity RESOLVER (it returns the verified scope itself and refuses an
350
+ // absent identity first): scope-derived, whatever arguments it was handed.
351
+ const called = isResolver ? wholeAwaitedCall(rhs) : null;
352
+ if (called && isResolver?.(called)) {
353
+ fromScope++;
354
+ fromResolver++;
355
+ return;
356
+ }
357
+ // Strip `args.<field>` from the RHS BEFORE the scope test so an argument
358
+ // whose FIELD is named like a scope key cannot launder itself:
359
+ // `const t = args.orgSlug` must not read scope-derived just because
360
+ // R6_SCOPE_RHS carries `\borgSlug\b`. The value is still an argument, and
361
+ // the law above (a local fed only from args is tainted) governs. Genuine
362
+ // `scope.orgSlug` / `withOrgScope(...)` RHS has no `args.` token to strip
363
+ // and still matches → fromScope.
364
+ const rhsForScope = rhs.replace(/\bargs\.\w+/g, "");
365
+ if (R6_SCOPE_RHS.test(rhsForScope))
366
+ fromScope++;
367
+ else
368
+ fromArgs++;
369
+ };
370
+ for (const l of h.lines) {
371
+ if (!code(l))
372
+ continue;
373
+ const raw = l.raw;
374
+ // The handler's own parameter destructured — the binding IS the argument.
375
+ if (boundFromHandlerParam(raw, local)) {
376
+ matched++;
377
+ fromArgs++;
378
+ continue;
379
+ }
380
+ // A destructuring binding of `local` — test its RHS as a plain RHS.
381
+ const dr = destructuredRhs(raw, local);
382
+ if (dr !== null) {
383
+ matched++;
384
+ classify(dr);
385
+ continue;
386
+ }
387
+ const isAssign = assign.test(raw);
388
+ const isBareDecl = !isAssign && decl.test(raw) && !raw.includes("=");
389
+ if (!isAssign && !isBareDecl)
390
+ continue;
391
+ matched++;
392
+ const rhs = isAssign ? raw.slice(raw.indexOf("=") + 1) : "";
393
+ classify(rhs);
394
+ }
395
+ return fromResolver > 0
396
+ ? { fromArgs, fromScope, matched, cnj, fromResolver }
397
+ : { fromArgs, fromScope, matched, cnj };
398
+ }
399
+ /** The full source text of every `ctx.db.insert|patch|replace(...)` call in a
400
+ * handler, each joined across the lines the call spans (parens balanced). The
401
+ * tenant-provenance regexes read THIS — never the args-declaration block or a
402
+ * plain local assignment — so `tenantId: args.tenantId` is only ever counted
403
+ * when it is the value actually handed to a write. */
404
+ function writeSiteTexts(h) {
405
+ const out = [];
406
+ const codeLines = h.lines.filter(code);
407
+ for (let i = 0; i < codeLines.length; i++) {
408
+ if (!/ctx\.db\.(?:insert|patch|replace)\s*\(/.test(codeLines[i].raw))
409
+ continue;
410
+ let depth = 0;
411
+ let started = false;
412
+ let text = "";
413
+ for (let j = i; j < codeLines.length; j++) {
414
+ const raw = codeLines[j].raw;
415
+ text += (text ? "\n" : "") + raw;
416
+ for (const ch of raw) {
417
+ if (ch === "(") {
418
+ depth++;
419
+ started = true;
420
+ }
421
+ else if (ch === ")")
422
+ depth--;
423
+ }
424
+ if (started && depth <= 0)
425
+ break;
426
+ }
427
+ out.push(text);
428
+ }
429
+ return out;
430
+ }
431
+ // ---- named guard HELPERS defined outside the handler ----------------------
432
+ //
433
+ // A public mutation frequently enforces its boundary in ONE line that calls a
434
+ // helper defined elsewhere: `await requireOrgAdmin(ctx, args.orgSlug)` binds the
435
+ // org argument to the verified identity's own org and throws RBAC_DENIED
436
+ // otherwise; `requireFleetMaster` / `requireServiceAccount` refuse every caller
437
+ // that is not the fleet master. The in-handler regexes above cannot see the
438
+ // helper's body. The fix is a NAMED ALLOWLIST — never "any function whose name
439
+ // starts with require" — and each listed helper is credited only when its OWN
440
+ // DEFINITION is found in the scanned tree and its CONTROL FLOW refuses, on every
441
+ // path to a normal exit, through a branch conditioned on the verified identity
442
+ // (guard-helper-flow.ts). A helper the walk cannot decide is could-not-judge,
443
+ // never credited; so is a call to a listed helper with no readable definition.
444
+ //
445
+ // - "binds-arg": the helper compares its target parameter with the identity;
446
+ // it legitimises a direct-arg boundary write ONLY for an argument that was
447
+ // handed to it (`requireOrgAdmin(ctx, args.orgSlug)` then
448
+ // `orgSlug: args.orgSlug`). A different argument written as the boundary is
449
+ // still the argument.
450
+ // - "master-only": the helper admits the fleet master alone; the master's
451
+ // authority is fleet-wide by design (the documented carve-out), so a
452
+ // boundary argument it writes is the master's decision, not a forgery.
453
+ //
454
+ // The CALL is credited only when it is a direct `await` statement of the
455
+ // handler's body, before the write: a call inside a branch, a try or a closure
456
+ // of the handler is not run on every path.
457
+ export const R6_GUARD_HELPERS = {
458
+ requireOrgAdmin: "binds-arg",
459
+ requireFleetMaster: "master-only",
460
+ requireServiceAccount: "master-only",
461
+ };
462
+ const sourceOf = (f) => f.lines.map((l) => l.raw).join("\n");
463
+ /** Index every definition of an allowlisted helper in the tree. */
464
+ export function indexGuardHelpers(files) {
465
+ const out = new Map();
466
+ const names = Object.keys(R6_GUARD_HELPERS);
122
467
  for (const f of files) {
123
- if (!isJudgeable(f) || !isConvex(f))
468
+ if (!isJudgeable(f))
469
+ continue;
470
+ const src = sourceOf(f);
471
+ if (!names.some((n) => src.includes(n)))
472
+ continue;
473
+ for (const d of findHelperDefinitions(f.rel, src, R6_GUARD_HELPERS)) {
474
+ const list = out.get(d.name) ?? [];
475
+ list.push({
476
+ rel: f.rel,
477
+ n: d.line,
478
+ exported: d.exported,
479
+ judgement: d.judgement,
480
+ });
481
+ out.set(d.name, list);
482
+ }
483
+ }
484
+ return out;
485
+ }
486
+ const HELPER_NAMES = new Set(Object.keys(R6_GUARD_HELPERS));
487
+ const HELPER_MENTION = new RegExp(`\\b(?:${Object.keys(R6_GUARD_HELPERS).join("|")})\\s*\\(`);
488
+ /** `null` when the handler mentions a listed helper but its body could not be
489
+ * located for the statement-level read (could-not-judge, never credit). */
490
+ function helperCalls(h, f, writeLine, index, src) {
491
+ if (!h.lines.some((l) => code(l) && HELPER_MENTION.test(l.raw)))
492
+ return [];
493
+ const calls = topLevelHelperCalls(f.rel, src(), h.name, HELPER_NAMES, writeLine);
494
+ if (calls === null)
495
+ return null;
496
+ return calls.map((c) => {
497
+ const defs = index.get(c.name) ?? [];
498
+ const def = defs.find((d) => d.rel === f.rel) ?? defs.find((d) => d.exported);
499
+ return { name: c.name, kind: R6_GUARD_HELPERS[c.name], args: c.args, def };
500
+ });
501
+ }
502
+ /** Every `args.<field>` written DIRECTLY to a boundary field at a write site. */
503
+ function directBoundaryArgs(writes) {
504
+ const out = [];
505
+ const re = /\b(?:tenantId|orgId|orgSlug|createdBy|namespace|orchestratorId|ownerId)\s*:\s*(args\.\w+)/g;
506
+ for (const t of writes) {
507
+ re.lastIndex = 0;
508
+ let m = re.exec(t);
509
+ while (m !== null) {
510
+ out.push(m[1]);
511
+ m = re.exec(t);
512
+ }
513
+ }
514
+ return out;
515
+ }
516
+ /** The honoured helper call that covers this handler's boundary, if any.
517
+ * "master-only" covers any boundary write; "binds-arg" covers it only when every
518
+ * direct-arg boundary value written is an argument handed to the helper. */
519
+ function coveringCall(calls, writes) {
520
+ const honoured = calls.filter((c) => c.def?.judgement.verdict === "honoured");
521
+ const master = honoured.find((c) => c.kind === "master-only");
522
+ if (master)
523
+ return master;
524
+ const bound = honoured.filter((c) => c.kind === "binds-arg");
525
+ if (bound.length === 0)
526
+ return undefined;
527
+ const direct = directBoundaryArgs(writes);
528
+ const all = direct.every((a) => bound.some((c) => new RegExp(`\\b${a.replace(".", "\\.")}\\b`).test(c.args)));
529
+ return all ? bound[0] : undefined;
530
+ }
531
+ class FunctionIndex {
532
+ files;
533
+ parsed = new Map();
534
+ judged = new Map();
535
+ resolvers = new Map();
536
+ constructor(files) {
537
+ this.files = files;
538
+ }
539
+ defsIn(f) {
540
+ let d = this.parsed.get(f.rel);
541
+ if (!d) {
542
+ d = topLevelFunctions(f.rel, sourceOf(f));
543
+ this.parsed.set(f.rel, d);
544
+ }
545
+ return d;
546
+ }
547
+ lookup(name, fromRel) {
548
+ if (!/^[A-Za-z_][\w]*$/.test(name))
549
+ return undefined;
550
+ const decl = new RegExp(`\\b(?:function\\s*\\*?\\s*${name}\\b|const\\s+${name}\\s*=)`);
551
+ const local = this.files.find((f) => f.rel === fromRel);
552
+ if (local) {
553
+ const own = this.defsIn(local).find((d) => d.name === name);
554
+ if (own)
555
+ return { rel: local.rel, f: own };
556
+ }
557
+ const found = [];
558
+ for (const f of this.files) {
559
+ if (f.rel === fromRel || !isJudgeable(f))
560
+ continue;
561
+ if (!f.lines.some((l) => decl.test(l.raw)))
562
+ continue;
563
+ for (const d of this.defsIn(f))
564
+ if (d.name === name && d.exported)
565
+ found.push({ rel: f.rel, f: d });
566
+ }
567
+ return found.length === 1 ? found[0] : undefined;
568
+ }
569
+ judge(key, run) {
570
+ let v = this.judged.get(key);
571
+ if (!v) {
572
+ v = run();
573
+ this.judged.set(key, v);
574
+ }
575
+ return v;
576
+ }
577
+ /** Names seen from `fromRel`, resolved by `lookup` (same file first, else
578
+ * the single exported definition). */
579
+ callResolver(fromRel) {
580
+ let r = this.resolvers.get(fromRel);
581
+ if (!r) {
582
+ r = (name) => {
583
+ const d = this.lookup(name, fromRel);
584
+ return d
585
+ ? {
586
+ fn: d.f.fn,
587
+ key: `${d.rel}:${d.f.line}`,
588
+ resolve: this.callResolver(d.rel),
589
+ }
590
+ : undefined;
591
+ };
592
+ this.resolvers.set(fromRel, r);
593
+ }
594
+ return r;
595
+ }
596
+ resolver(name, fromRel) {
597
+ const d = this.lookup(name, fromRel);
598
+ if (!d)
599
+ return undefined;
600
+ const v = this.judge(`R:${d.rel}:${d.f.line}`, () => judgeResolverFunction(d.f.fn));
601
+ return v.verdict === "honoured" ? d : undefined;
602
+ }
603
+ }
604
+ /** The boundary RHS expressions (`mission.orgId`) that a refusing predicate
605
+ * bound to the caller's verified scope before the write, with the predicate
606
+ * that did it. */
607
+ function predicateBoundValues(h, f, writeLine, fns, src) {
608
+ const out = new Map();
609
+ if (!h.lines.some((l) => code(l) && /\bif\s*\(\s*!\s*[A-Za-z_$][\w$]*\s*\(/.test(l.raw)))
610
+ return out;
611
+ const g = readHandlerGuards(f.rel, src(), h.name, writeLine);
612
+ if (!g)
613
+ return out;
614
+ const isIdentityLocal = (n) => {
615
+ if (!n)
616
+ return false;
617
+ const c = g.consts.get(n);
618
+ if (!c)
619
+ return false;
620
+ if (c.identityRead)
621
+ return true;
622
+ return !!c.awaitedCallee && !!fns.resolver(c.awaitedCallee, f.rel);
623
+ };
624
+ for (const r of g.refusals) {
625
+ const i = r.args.findIndex((a) => isIdentityLocal(a.ident));
626
+ const j = r.args.findIndex((a) => a.memberBase !== undefined && g.consts.has(a.memberBase));
627
+ if (i < 0 || j < 0)
628
+ continue;
629
+ const value = r.args[j].text.replace(/\s+/g, "");
630
+ // The bound property must not be re-assigned in the handler.
631
+ const esc = value.replace(/[.$]/g, "\\$&");
632
+ if (h.lines.some((l) => code(l) && new RegExp(`${esc}\\s*=(?!=)`).test(l.raw)))
633
+ continue;
634
+ const d = fns.lookup(r.predicate, f.rel);
635
+ if (!d)
124
636
  continue;
637
+ const v = fns.judge(`P:${d.rel}:${d.f.line}:${i}:${j}`, () => judgePredicateFunction(d.f.fn, i, j));
638
+ if (v.verdict === "honoured")
639
+ out.set(value, `${r.predicate}() (definition ${d.rel}:${d.f.line})`);
640
+ }
641
+ return out;
642
+ }
643
+ /** The caller arguments (`args.namespace`) a BINDING VALIDATOR bound to the
644
+ * caller's verified scope before the write: a direct `await V(…, args.x, …)`
645
+ * statement of the handler whose callee, read in the tree on its body
646
+ * (summarizeValidator), reaches a normal exit only when `args.x` passed a
647
+ * predicate over the verified scope (or for the fleet master). Name-free. */
648
+ function validatorBoundArgs(h, f, writeLine, fns, src) {
649
+ const out = new Map();
650
+ const calls = topLevelHelperCalls(f.rel, src(), h.name, null, writeLine);
651
+ if (!calls)
652
+ return out;
653
+ let g;
654
+ for (const c of calls) {
655
+ const targets = c.argNodes
656
+ .map((a, k) => ({ k, text: a.getText().replace(/\s+/g, "") }))
657
+ .filter((a) => /^args\.[A-Za-z_$][\w$]*$/.test(a.text));
658
+ if (targets.length === 0)
659
+ continue;
660
+ const d = fns.lookup(c.name, f.rel);
661
+ if (!d)
662
+ continue;
663
+ // An argument that is the handler's own verified scope (a const bound to
664
+ // an identity read, or to a function judged an identity resolver).
665
+ g ??= readHandlerGuards(f.rel, src(), h.name, writeLine);
666
+ const identityIdx = c.argNodes.findIndex((a) => {
667
+ const t = a.getText();
668
+ const k = g?.consts.get(t);
669
+ return (!!k &&
670
+ (k.identityRead ||
671
+ (!!k.awaitedCallee && !!fns.resolver(k.awaitedCallee, f.rel))));
672
+ });
673
+ for (const t of targets) {
674
+ const sum = summarizeValidator(d.f.fn, {
675
+ boundIdx: t.k,
676
+ identityIdx: identityIdx < 0 ? undefined : identityIdx,
677
+ callArgs: c.argNodes,
678
+ resolve: fns.callResolver(d.rel),
679
+ key: `${d.rel}:${d.f.line}`,
680
+ depth: 0,
681
+ });
682
+ if (sum.ok && sum.exitsBound && !sum.never)
683
+ out.set(t.text, `${c.name}() (definition ${d.rel}:${d.f.line})`);
684
+ }
685
+ }
686
+ return out;
687
+ }
688
+ /** The write texts with every boundary value that IS (completely) a validated
689
+ * argument replaced by a placeholder: what is left is what still reads args. */
690
+ function maskValidated(writes, validated) {
691
+ return writes.map((t) => t.replace(/(\b(?:tenantId|orgId|orgSlug|createdBy|namespace|orchestratorId|ownerId)\s*:\s*)(args\.[A-Za-z_$][\w$]*)(?=\s*(?:,|\}|$))/gm, (m, field, arg) => validated.has(arg) ? `${field}__validated__` : m));
692
+ }
693
+ /** Every RHS of the boundary local `loc` at the write sites, when EACH is a
694
+ * complete member expression on it (`orgId: mission.orgId,`); null when any
695
+ * occurrence is something else (`orgId: mission.orgId ?? args.x`, `{ orgId }`). */
696
+ function boundaryMemberRhs(writes, loc) {
697
+ const out = [];
698
+ const esc = loc.replace(/[$]/g, "\\$&");
699
+ const any = new RegExp(`\\b(?:tenantId|orgId|orgSlug)\\s*:\\s*${esc}\\b`, "g");
700
+ const full = new RegExp(`^\\s*(${esc}(?:\\.[A-Za-z_$][\\w$]*)+)\\s*(?:,|}|$)`);
701
+ for (const t of writes) {
702
+ any.lastIndex = 0;
703
+ let m = any.exec(t);
704
+ while (m !== null) {
705
+ const rest = t.slice(m.index + m[0].length - loc.length);
706
+ const fm = full.exec(rest.split("\n")[0]);
707
+ if (!fm)
708
+ return null;
709
+ out.push(fm[1]);
710
+ m = any.exec(t);
711
+ }
712
+ }
713
+ return out.length ? out : null;
714
+ }
715
+ export function scanR6(files, rootIsConvex = false) {
716
+ const scan = emptyScan();
717
+ const helperIndex = indexGuardHelpers(files);
718
+ const fns = new FunctionIndex(files);
719
+ for (const raw of files) {
720
+ if (!isJudgeable(raw) || !isConvex(raw, rootIsConvex))
721
+ continue;
722
+ // One shape for every argument read: `args["x"]` → `args.x`.
723
+ const f = { ...raw, lines: normalizeLines(raw.lines) };
125
724
  for (const h of splitMutationHandlers(f)) {
725
+ // An internalMutation has NO client caller: its arguments come from
726
+ // server code (a cron, a scheduler hop, another function), so "a
727
+ // caller-supplied argument" — the thing R-6 forbids (standard
728
+ // 4bcd605 :71, :252 "never accept a userId as a function argument")
729
+ // does not exist there. R-6 governs the client-callable write only.
730
+ if (h.internal)
731
+ continue;
126
732
  let writeLine = 0;
127
733
  let guard = false;
128
734
  let boundaryArg = false;
735
+ let resolvesScope = false;
129
736
  for (const l of h.lines) {
130
737
  if (!code(l))
131
738
  continue;
@@ -133,12 +740,196 @@ export function scanR6(files) {
133
740
  writeLine = l.n;
134
741
  if (R6_IDENTITY_GUARD.test(l.raw) || R6_RESOURCE_GUARD.test(l.raw))
135
742
  guard = true;
743
+ if (R6_SCOPE_RESOLUTION.test(l.raw))
744
+ resolvesScope = true;
136
745
  if (R6_BOUNDARY_ARG.test(l.raw))
137
746
  boundaryArg = true;
138
747
  }
139
748
  if (!writeLine)
140
749
  continue; // not a write handler — R-6 does not govern it.
141
- if (guard) {
750
+ const writes = writeSiteTexts(h);
751
+ let directTenant = writes.some((t) => R6_TENANT_FROM_ARG.test(t));
752
+ // Follow the local ONE hop: every non-`args` local written to a tenant
753
+ // boundary field, and its THREE-STATE provenance. A local fed from args
754
+ // (matched>0, fromScope===0, fromArgs>0) is TAINTED; a local with NO
755
+ // resolvable assignment (matched===0) or only unresolved indirections is
756
+ // COULD-NOT-JUDGE — never silently tainted (Eta REVISE #3 defect 2).
757
+ const boundaryLocals = [
758
+ ...new Set(writes.flatMap(boundaryLocalsInWrite)),
759
+ ];
760
+ let taintedLocal;
761
+ let cnjLocal;
762
+ let anyDerived = false;
763
+ let fileSrc;
764
+ const getSrc = () => {
765
+ fileSrc ??= sourceOf(f);
766
+ return fileSrc;
767
+ };
768
+ const resolvedBy = new Map();
769
+ const isResolver = (name) => {
770
+ const d = fns.resolver(name, f.rel);
771
+ if (d)
772
+ resolvedBy.set(name, `${d.rel}:${d.f.line}`);
773
+ return !!d;
774
+ };
775
+ const credits = [];
776
+ // A boundary written straight from an argument a BINDING VALIDATOR
777
+ // bound to the verified scope before the write: credited only when
778
+ // EVERY argument written to a boundary field is so bound.
779
+ if (directBoundaryArgs(writes).length > 0) {
780
+ const validated = validatorBoundArgs(h, f, writeLine, fns, getSrc);
781
+ const masked = maskValidated(writes, validated);
782
+ if (validated.size > 0 &&
783
+ directBoundaryArgs(masked).length === 0 &&
784
+ !masked.some((t) => R6_TENANT_FROM_ARG.test(t))) {
785
+ directTenant = false;
786
+ boundaryArg = false;
787
+ anyDerived = true;
788
+ resolvesScope = true;
789
+ const written = new Set(directBoundaryArgs(writes));
790
+ for (const [arg, by] of validated)
791
+ if (written.has(arg))
792
+ credits.push(`\`${arg}\` bound to the verified scope by validator ${by}, which returns only when it passes a predicate over the verified scope (or for the fleet master) and refuses otherwise`);
793
+ }
794
+ }
795
+ let bound;
796
+ for (const loc of boundaryLocals) {
797
+ // A value a refusing predicate bound to the caller's scope before
798
+ // the write (`if (!P(scope, mission.orgId)) throw`), written
799
+ // unchanged: derived from the verified identity through the target.
800
+ const rhs = boundaryMemberRhs(writes, loc);
801
+ if (rhs) {
802
+ bound ??= predicateBoundValues(h, f, writeLine, fns, getSrc);
803
+ const by = rhs.map((x) => bound?.get(x));
804
+ if (by.every((x) => x !== undefined)) {
805
+ anyDerived = true;
806
+ resolvesScope = true;
807
+ credits.push(`\`${rhs[0]}\` bound to the verified scope by refusing predicate ${by[0]}`);
808
+ continue;
809
+ }
810
+ }
811
+ resolvedBy.clear();
812
+ const a = analyzeLocalTaint(h, loc, isResolver);
813
+ if (a.fromResolver) {
814
+ resolvesScope = true;
815
+ credits.push(`local "${loc}" is the return of identity resolver ${[...resolvedBy].map(([n, at]) => `${n}() (definition ${at})`).join(", ")}, which returns the verified scope itself and refuses an absent identity on every path`);
816
+ }
817
+ if (a.fromScope > 0) {
818
+ anyDerived = true;
819
+ }
820
+ else if (a.matched === 0) {
821
+ cnjLocal ??= loc; // no assignment/declaration found — cannot judge.
822
+ }
823
+ else if (a.fromArgs > 0) {
824
+ taintedLocal ??= loc; // fed only from args — laundered argument.
825
+ }
826
+ else {
827
+ cnjLocal ??= loc; // matched but only unresolved bare-id hops.
828
+ }
829
+ }
830
+ // A CONDITIONAL boundary value (`orgId: c ? a : b`): an arm that reads
831
+ // the caller's args is the argument, unless it is the TRUE arm of a
832
+ // positive `<scope>.isMaster` test on a scope-derived local (the
833
+ // fleet carve-out: the master's authority is fleet-wide by design).
834
+ let conditionalArg;
835
+ for (const cb of writes.flatMap((t) => conditionalBoundaries(t, R6_TENANT_FIELDS))) {
836
+ const base = cb.masterBase;
837
+ const masterArm = base !== undefined &&
838
+ base !== "args" &&
839
+ (() => {
840
+ const a = analyzeLocalTaint(h, base, isResolver);
841
+ return a.fromScope > 0 && a.fromArgs === 0;
842
+ })();
843
+ if (cb.falseReadsArgs || (cb.trueReadsArgs && !masterArm)) {
844
+ conditionalArg ??= cb.field;
845
+ }
846
+ }
847
+ const derivedTenant = anyDerived;
848
+ const accusable = directTenant ||
849
+ conditionalArg ||
850
+ taintedLocal ||
851
+ (!guard && boundaryArg);
852
+ const calls = helperCalls(h, f, writeLine, helperIndex, getSrc);
853
+ const violateBefore = scan.violate.length;
854
+ const cover = calls ? coveringCall(calls, writes) : undefined;
855
+ if (cover?.def) {
856
+ scan.conform.push({
857
+ rel: f.rel,
858
+ n: writeLine,
859
+ note: `${h.name}: boundary enforced by allowlisted guard helper ${cover.name}() (${cover.kind}; definition ${cover.def.rel}:${cover.def.n} refuses on every path through a branch conditioned on the verified identity)`,
860
+ });
861
+ continue;
862
+ }
863
+ // The handler leans on an allowlisted helper the doctor cannot judge:
864
+ // its definition is not in the scanned tree, its control flow is not
865
+ // modelled, or the handler body itself could not be located. Neither
866
+ // credited nor accused.
867
+ const undecided = calls === null
868
+ ? "its handler body could not be located for the statement-level read"
869
+ : calls
870
+ .map((c) => c.def === undefined
871
+ ? `${c.name}() has no readable definition in the scanned tree`
872
+ : c.def.judgement.verdict === "could-not-judge"
873
+ ? `${c.name}() at ${c.def.rel}:${c.def.n} could not be judged — ${c.def.judgement.why}`
874
+ : "")
875
+ .find((x) => x !== "");
876
+ if (undecided && accusable) {
877
+ scan.cnj.push({
878
+ rel: f.rel,
879
+ n: writeLine,
880
+ note: `${h.name}: relies on an allowlisted guard helper whose enforcement cannot be decided — ${undecided} (could-not-judge, not an accusation)`,
881
+ });
882
+ continue;
883
+ }
884
+ if (directTenant) {
885
+ // Decisive VIOLATE — the WRITE-SITE provenance, not guard presence,
886
+ // is what R-6 governs. A tenant-scoping field written straight from a
887
+ // caller arg is the boundary being the argument, even when the handler
888
+ // resolves scope elsewhere (the historical sendMessage defect).
889
+ scan.violate.push({
890
+ rel: f.rel,
891
+ n: writeLine,
892
+ note: `${h.name}: writes a tenant-scoping boundary field (tenantId/orgId/orgSlug) directly from a caller argument AT THE WRITE SITE — provenance is the raw arg, not a resolved scope, even if a guard is present elsewhere`,
893
+ });
894
+ }
895
+ else if (conditionalArg) {
896
+ scan.violate.push({
897
+ rel: f.rel,
898
+ n: writeLine,
899
+ note: `${h.name}: writes tenant-scoping boundary field "${conditionalArg}" from a conditional whose arm reads a caller argument outside a positive isMaster test on the resolved scope — that arm writes the argument`,
900
+ });
901
+ }
902
+ else if (taintedLocal) {
903
+ // LAUNDERED VIOLATE — the boundary field is a local (`tenantId: t`)
904
+ // but every assignment to that local is fed from args (fromScope===0):
905
+ // the caller's argument reaching the write under another name. A rename
906
+ // hop does not sanitise provenance; scope resolved elsewhere in the
907
+ // handler does not rescue it.
908
+ scan.violate.push({
909
+ rel: f.rel,
910
+ n: writeLine,
911
+ note: `${h.name}: writes tenant-scoping boundary field from local "${taintedLocal}" whose every assignment is caller-arg-derived (fromScope=0) — the argument laundered through a rename, not a scope-derived value`,
912
+ });
913
+ }
914
+ else if (cnjLocal && !anyDerived) {
915
+ // THIRD STATE — a boundary local whose provenance could not be
916
+ // resolved (no assignment/declaration found in-handler, or only
917
+ // unresolved bare-identifier indirections). This is could-not-judge,
918
+ // NEVER an accusation (Eta REVISE #3 defect 2).
919
+ scan.cnj.push({
920
+ rel: f.rel,
921
+ n: writeLine,
922
+ note: `${h.name}: writes tenant-scoping boundary field from local "${cnjLocal}" whose provenance could not be resolved in-handler (no assignment/declaration found, or only unresolved indirections) — could-not-judge, not an accusation`,
923
+ });
924
+ }
925
+ else if (derivedTenant && resolvesScope) {
926
+ scan.conform.push({
927
+ rel: f.rel,
928
+ n: writeLine,
929
+ note: `${h.name}: tenant boundary written from a scope-derived local at the write site (scope/identity resolved in-handler)${credits.length ? ` — ${credits.join("; ")}` : ""}`,
930
+ });
931
+ }
932
+ else if (guard) {
142
933
  scan.conform.push({
143
934
  rel: f.rel,
144
935
  n: writeLine,
@@ -159,6 +950,16 @@ export function scanR6(files) {
159
950
  note: `${h.name}: writes with no identity/resource guard and no clearly caller-supplied boundary field — deferred, not statically confirmed`,
160
951
  });
161
952
  }
953
+ // A violation at a site calling a listed helper whose definition was
954
+ // read and NOT honoured says why, so the reader is not left believing
955
+ // the helper was simply missed.
956
+ const refused = calls?.find((c) => c.def?.judgement.verdict === "refused");
957
+ if (refused?.def &&
958
+ refused.def.judgement.verdict === "refused" &&
959
+ scan.violate.length > violateBefore) {
960
+ const v = scan.violate[scan.violate.length - 1];
961
+ v.note += `; allowlisted helper ${refused.name}() at ${refused.def.rel}:${refused.def.n} NOT honoured — ${refused.def.judgement.why}`;
962
+ }
162
963
  }
163
964
  }
164
965
  return scan;
@@ -199,10 +1000,10 @@ function collectConvexErrorThrows(f) {
199
1000
  }
200
1001
  return out;
201
1002
  }
202
- export function scanR16(files) {
1003
+ export function scanR16(files, rootIsConvex = false) {
203
1004
  const scan = emptyScan();
204
1005
  for (const f of files) {
205
- if (!isJudgeable(f) || !isConvex(f))
1006
+ if (!isJudgeable(f) || !isConvex(f, rootIsConvex))
206
1007
  continue;
207
1008
  for (const t of collectConvexErrorThrows(f)) {
208
1009
  if (R16_CODE_TOKEN.test(t.payload)) {
@@ -394,13 +1195,13 @@ function stringifyResult(res) {
394
1195
  /** R-6 — static primary + live secondary (LiveDetector). */
395
1196
  export const R6 = {
396
1197
  id: R6_ID,
397
- run: (tree) => decide("R-6", R6_ID, R6_CHECK, scanR6(tree.files), "R-6: zero convex write handlers matched in the scanned tree — cannot certify boundary derivation. NOT a clean bill."),
1198
+ run: (tree) => decide("R-6", R6_ID, R6_CHECK, scanR6(tree.files, rootIsConvexDir(tree.root)), "R-6: zero convex write handlers matched in the scanned tree — cannot certify boundary derivation. NOT a clean bill."),
398
1199
  runLive: r6RunLive,
399
1200
  };
400
1201
  /** R-16 — static primary + live secondary (LiveDetector). */
401
1202
  export const R16 = {
402
1203
  id: R16_ID,
403
- run: (tree) => decide("R-16", R16_ID, R16_CHECK, scanR16(tree.files), "R-16: zero ConvexError throw sites matched in the scanned convex tree — cannot certify the error contract. NOT a clean bill."),
1204
+ run: (tree) => decide("R-16", R16_ID, R16_CHECK, scanR16(tree.files, rootIsConvexDir(tree.root)), "R-16: zero ConvexError throw sites matched in the scanned convex tree — cannot certify the error contract. NOT a clean bill."),
404
1205
  runLive: r16RunLive,
405
1206
  };
406
1207
  /** The two, in a form the orchestrator spreads into REGISTRY. */