@vantageos/backend-doctor 0.1.14 → 0.3.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 (93) hide show
  1. package/README.md +66 -5
  2. package/dist/args.d.ts +16 -0
  3. package/dist/args.js +72 -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 +1198 -0
  9. package/dist/cli.js +249 -17
  10. package/dist/contract.d.ts +25 -7
  11. package/dist/contract.js +52 -7
  12. package/dist/coverage-cli.js +8 -0
  13. package/dist/coverage.d.ts +1 -1
  14. package/dist/coverage.js +2 -2
  15. package/dist/detectors/oracle-axis1.d.ts +5 -3
  16. package/dist/detectors/oracle-axis1.js +28 -18
  17. package/dist/detectors/oracle-axis2.d.ts +6 -0
  18. package/dist/detectors/oracle-axis2.js +37 -22
  19. package/dist/detectors/predicates.d.ts +3 -0
  20. package/dist/detectors/predicates.js +93 -19
  21. package/dist/detectors/shared.js +8 -7
  22. package/dist/detectors/source/_ast.d.ts +92 -0
  23. package/dist/detectors/source/_ast.js +353 -0
  24. package/dist/detectors/source/_scan.js +4 -3
  25. package/dist/detectors/source/backfill.js +71 -18
  26. package/dist/detectors/source/contract.js +21 -13
  27. package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
  28. package/dist/detectors/source/cross-tenant-write.js +1408 -0
  29. package/dist/detectors/source/guard-helper-flow.d.ts +134 -0
  30. package/dist/detectors/source/guard-helper-flow.js +1216 -0
  31. package/dist/detectors/source/identity-by-id.d.ts +80 -0
  32. package/dist/detectors/source/identity-by-id.js +1783 -0
  33. package/dist/detectors/source/identity.d.ts +58 -2
  34. package/dist/detectors/source/identity.js +1158 -28
  35. package/dist/detectors/source/isolation.d.ts +11 -1
  36. package/dist/detectors/source/isolation.js +106 -25
  37. package/dist/detectors/source/jobs.js +178 -4
  38. package/dist/detectors/source/lifecycle.js +14 -2
  39. package/dist/detectors/source/local-identity.d.ts +26 -0
  40. package/dist/detectors/source/local-identity.js +661 -0
  41. package/dist/detectors/source/package-principal.d.ts +9 -0
  42. package/dist/detectors/source/package-principal.js +177 -0
  43. package/dist/detectors/source/r6-shapes.d.ts +20 -0
  44. package/dist/detectors/source/r6-shapes.js +285 -0
  45. package/dist/detectors/source/readbudget.d.ts +1 -0
  46. package/dist/detectors/source/readbudget.js +358 -4
  47. package/dist/detectors/source/recipient-by-id.d.ts +40 -0
  48. package/dist/detectors/source/recipient-by-id.js +1108 -0
  49. package/dist/detectors/source/refusal.d.ts +14 -0
  50. package/dist/detectors/source/refusal.js +75 -2
  51. package/dist/detectors/source/security.d.ts +16 -0
  52. package/dist/detectors/source/security.js +680 -33
  53. package/dist/detectors/source/write-isolation.d.ts +8 -1
  54. package/dist/detectors/source/write-isolation.js +64 -30
  55. package/dist/detectors/source/writesafety.js +27 -14
  56. package/dist/detectors/standard/_doc.d.ts +1 -1
  57. package/dist/detectors/standard/_doc.js +5 -4
  58. package/dist/detectors/standard/r41-frontier.js +3 -2
  59. package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
  60. package/dist/detectors/standard/r46-returns-validator.js +48 -16
  61. package/dist/detectors/structural.d.ts +6 -7
  62. package/dist/detectors/structural.js +84 -48
  63. package/dist/not-checkable.d.ts +1 -1
  64. package/dist/not-checkable.js +11 -0
  65. package/dist/oracle.d.ts +92 -3
  66. package/dist/oracle.js +229 -12
  67. package/dist/registry.d.ts +8 -1
  68. package/dist/registry.js +74 -8
  69. package/dist/report.d.ts +11 -1
  70. package/dist/report.js +15 -2
  71. package/dist/rule-class.d.ts +85 -0
  72. package/dist/rule-class.js +218 -0
  73. package/dist/state-rank.d.ts +5 -0
  74. package/dist/state-rank.js +9 -0
  75. package/dist/tally.d.ts +13 -5
  76. package/dist/tally.js +15 -13
  77. package/dist/target.d.ts +32 -0
  78. package/dist/target.js +173 -0
  79. package/dist/violation-count.d.ts +48 -0
  80. package/dist/violation-count.js +94 -0
  81. package/fixtures/backend-standard-2825122.md +722 -0
  82. package/fixtures/backend-standard-4dcd3e0.md +722 -0
  83. package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
  84. package/fixtures/cr1-classes/surface.ts +206 -0
  85. package/fixtures/cr1-empty/nothing.ts +1 -0
  86. package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
  87. package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
  88. package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
  89. package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
  90. package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
  91. package/fixtures/rule-classes-2825122.json +228 -0
  92. package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
  93. package/package.json +2 -2
@@ -1,3 +1,9 @@
1
+ import { itemCount, siteCount, withCount } from "../../violation-count.js";
2
+ import { parseFile } from "./_ast.js";
3
+ import { conditionalBoundaries, findHelperDefinitions, judgePredicateFunction, judgeResolverFunction, readHandlerGuards, SCOPE_REFINERS, summarizeValidator, topLevelFunctions, topLevelHelperCalls, } from "./guard-helper-flow.js";
4
+ import { IDENTITY_PACKAGE } from "./identity-by-id.js";
5
+ import { packagePrincipals, } from "./package-principal.js";
6
+ import { masterOnlyWrapperCredit, possessionGatedCredit } from "./r6-shapes.js";
1
7
  /** The standard both are pinned against (short hash a9d7877b), §15/§18. */
2
8
  export const IDENTITY_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15/§18";
3
9
  function emptyScan() {
@@ -17,8 +23,32 @@ function isJudgeable(f) {
17
23
  function code(l) {
18
24
  return !l.comment;
19
25
  }
20
- /** convex source only — R-6/R-16 govern the data layer's write handlers. */
21
- function isConvex(f) {
26
+ /**
27
+ * True when the ENGINE's scan root is itself a convex directory. loadTree sets
28
+ * `tree.root` to the target the CLI was pointed at, and computes every file's
29
+ * `rel` relative to that root. So the SAME convex file yields two different rels
30
+ * under the two documented invocations:
31
+ * - pointed at the repo ROOT → rel = `convex/messages.ts` (carries `convex/`)
32
+ * - pointed at the CONVEX dir → rel = `messages.ts` (bare, no segment)
33
+ * The bare-rel case is the normal, documented invocation, and it is precisely
34
+ * where a `rel`-only `isConvex` guard (see below) returned FALSE for 100% of
35
+ * files — silently skipping R-6/R-16 (Path-B C1 blind spot 1). When the root IS
36
+ * a convex dir, EVERY collected file is by construction under that convex tree,
37
+ * so no non-convex file (mcp-server/app/src) can be pulled in by trusting it.
38
+ */
39
+ function rootIsConvexDir(root) {
40
+ // Any `convex` path SEGMENT of the scan root (`.../convex` or `.../convex/…`).
41
+ // A repo root (`/…/vantage-memory`) has no such segment, so this stays FALSE
42
+ // there and the per-file `convex/` rel prefix does the scoping instead.
43
+ return /(^|\/)convex(\/|$)/.test(root.replace(/\/+$/, ""));
44
+ }
45
+ /** convex source only — R-6/R-16 govern the data layer's write handlers. When
46
+ * the scan root is itself a convex dir every file is convex source (bare rels);
47
+ * otherwise a file qualifies only by a `convex/` rel prefix — so a repo-root
48
+ * scan still EXCLUDES non-convex source (mcp-server/src/*, app/*, src/*). */
49
+ function isConvex(f, rootIsConvex) {
50
+ if (rootIsConvex)
51
+ return true;
22
52
  return /(^|\/)convex\//.test(f.rel);
23
53
  }
24
54
  /**
@@ -38,12 +68,12 @@ function decide(ruleId, id, check, scan, emptyReason) {
38
68
  .map((x) => `${x.rel}:${x.n} (${x.note})`)
39
69
  .join("; ");
40
70
  if (nV > 0) {
41
- return {
71
+ return withCount({
42
72
  id,
43
73
  anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
44
74
  state: "non-conforming",
45
75
  verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}`,
46
- };
76
+ }, siteCount(scan.violate));
47
77
  }
48
78
  if (nJ > 0) {
49
79
  const reason = `${ruleId}: ${nJ} site(s) could not be judged — ${fmt(scan.cnj)}. This is NOT a clean bill. ${breakdown}`;
@@ -73,10 +103,11 @@ function decide(ruleId, id, check, scan, emptyReason) {
73
103
  }
74
104
  /** Split a convex file into its top-level mutation/internalMutation handler
75
105
  * 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) {
106
+ * top-level `export ` (or EOF). Queries/actions are not R-6 write handlers.
107
+ * Each block records whether it is an `internalMutation` registration. */
108
+ export function splitMutationHandlers(f) {
78
109
  const out = [];
79
- const open = /export\s+const\s+(\w+)\s*=\s*(?:internalMutation|mutation)\s*\(/;
110
+ const open = /export\s+const\s+(\w+)\s*=\s*(internalMutation|mutation)\s*\(/;
80
111
  const topExport = /^export\s+/;
81
112
  let cur = null;
82
113
  for (const l of f.lines) {
@@ -84,7 +115,12 @@ function splitMutationHandlers(f) {
84
115
  if (m) {
85
116
  if (cur)
86
117
  out.push(cur);
87
- cur = { name: m[1], startN: l.n, lines: [l] };
118
+ cur = {
119
+ name: m[1],
120
+ startN: l.n,
121
+ lines: [l],
122
+ internal: m[2] === "internalMutation",
123
+ };
88
124
  continue;
89
125
  }
90
126
  if (cur) {
@@ -116,16 +152,832 @@ function splitMutationHandlers(f) {
116
152
  const R6_WRITE = /ctx\.db\.(?:insert|patch|replace|delete)\s*\(/;
117
153
  const R6_IDENTITY_GUARD = /getUserIdentity|resolveOrgId|resolveOrgContext|assertNamespaceAllowed|expectedOrgIdForNamespace|tenantSlug|organizationId|organizationSlug|AUTH_NO_ORG|AUTH_NAMESPACE_DENIED/;
118
154
  const R6_RESOURCE_GUARD = /assertTaskCallerAuthorized|RBAC_DENIED|\.createdBy\s*===|\.assignedTo\s*===|\.orchestratorId\s*===|callerOrchestrator\s*===\s*["']system["']|note\.createdBy/;
119
- const R6_BOUNDARY_ARG = /\b(?:createdBy|orgId|orgSlug|namespace|orchestratorId|ownerId|tenantId)\b/;
120
- export function scanR6(files) {
121
- const scan = emptyScan();
155
+ // ---- WRITE-SITE provenance (blind spot 2) ---------------------------------
156
+ //
157
+ // Guard-PRESENCE alone cannot distinguish the historical tenant-write defect
158
+ // from its fix: BOTH `messages.sendMessage` versions call `withOrgScope(ctx)`.
159
+ // The real discriminator is the tenant-scoping boundary field's RHS AT THE
160
+ // WRITE SITE. `tenantId: args.tenantId` (raw caller arg, written directly) is
161
+ // the defect; `tenantId: derivedTenantId` (a scope-derived local) is the fix —
162
+ // even though a `derivedTenantId = args.tenantId` ASSIGNMENT exists inside the
163
+ // master carve-out branch, that assignment is not a ctx.db write and is never
164
+ // flagged. So a direct-arg tenant write is decisive VIOLATE regardless of any
165
+ // guard, and a derived-local tenant write (with scope resolved) is CONFORM.
166
+ // `args.x`, and a COMPUTED subscript `args[k]` (whatever the key, the value is
167
+ // read off the caller's arguments). A literal-keyed subscript (`args["x"]`) never
168
+ // reaches this regex: normalizeArgsAccess rewrites it to `args.x` first.
169
+ const R6_TENANT_FIELDS = new Set([
170
+ "tenantId",
171
+ "orgId",
172
+ "orgSlug",
173
+ ]);
174
+ const R6_TENANT_FROM_ARG = /\b(?:tenantId|orgId|orgSlug)\s*:\s*args(?:\.\w+|\s*\[)/;
175
+ // ---- literal-keyed subscript (task k179nydvbdxm40ps7g0y18mwhs8dje91) -------
176
+ //
177
+ // `args["orgSlug"]`, `args['orgSlug']` and args[`orgSlug`] are the SAME property
178
+ // read as `args.orgSlug`. Every R-6 regex below was written against the dotted
179
+ // form, so the subscript form was invisible: `const t = args["orgSlug"]` kept
180
+ // its `\borgSlug\b` token after the `args.<field>` strip and read SCOPE-derived,
181
+ // and `tenantId: args["orgSlug"]` matched no direct-arg regex at all. Rewriting
182
+ // the literal subscript to the dotted form BEFORE any regex runs makes every
183
+ // existing rule see one shape. A template literal is rewritten only when it has
184
+ // no `\${…}` interpolation (the key must be a plain identifier).
185
+ export function normalizeArgsAccess(raw) {
186
+ return raw.replace(/\bargs\s*\[\s*(["'`])([A-Za-z_$][\w$]*)\1\s*\]/g, "args.$2");
187
+ }
188
+ function normalizeLines(lines) {
189
+ return lines.map((l) => {
190
+ const raw = normalizeArgsAccess(l.raw);
191
+ return raw === l.raw ? l : { ...l, raw };
192
+ });
193
+ }
194
+ const R6_SCOPE_RESOLUTION = /withOrgScope|getUserIdentity|resolveOrgId|resolveOrgContext|assertNamespaceAllowed|RBAC_DENIED/;
195
+ // ---- follow-the-local taint (blind spot 2b — the ONE-HOP rename) ----------
196
+ //
197
+ // Write-site RHS provenance alone is defeated by a single rename:
198
+ // const t = args.tenantId; ... ctx.db.insert(..., { tenantId: t })
199
+ // The boundary field is now a LOCAL, so R6_TENANT_FROM_ARG does not match and
200
+ // R6_TENANT_FROM_LOCAL wrongly reads CONFORM — the caller's argument reaches the
201
+ // write untouched, wearing another name. Same file, same defect, laundered.
202
+ //
203
+ // The fix follows the local ONE hop: collect every assignment/declaration of the
204
+ // local written as a boundary field, and discriminate on whether ANY of them is
205
+ // SCOPE-derived. A local fed ONLY from args (fromScope === 0) is the argument
206
+ // under an alias → TAINTED → VIOLATE. A local that receives at least one
207
+ // scope/identity-derived value (fromScope >= 1) is genuinely derived → CONFORM.
208
+ // This is exactly what keeps the master carve-out safe (its non-master branch
209
+ // assigns from scope.orgSlug → fromScope >= 1) WITHOUT rescuing the laundering
210
+ // (whose sole assignment is `= args.tenantId` → fromScope === 0).
211
+ // The verified-identity CLAIM names (`identity.organizationId`, `.organizationSlug`,
212
+ // `.tenantSlug`) are the same tokens R6_IDENTITY_GUARD already trusts. Without
213
+ // them a shorthand `{ orgId }` fed from `identity.organizationId` read TAINTED —
214
+ // the false positive the shorthand read (k176ewgwrbc6mg1y4h42rg25gn8dja1p)
215
+ // surfaced in tests/source-identity.test.ts "neuter-proof". An argument whose
216
+ // field carries such a name is stripped below before this test, as for orgSlug.
217
+ const R6_SCOPE_RHS = /\bscope\b|withOrgScope|getUserIdentity|resolveOrgId|resolveOrgContext|\borgSlug\b|assertNamespaceAllowed|\b(?:organizationId|organizationSlug|tenantSlug)\b/;
218
+ /** The non-`args` LOCAL identifiers written to a tenant-scoping boundary field
219
+ * at this write-site text (`tenantId: <local>`). `tenantId: args.tenantId` is
220
+ * excluded here — it is the DIRECT-arg case handled by R6_TENANT_FROM_ARG. */
221
+ function boundaryLocalsInWrite(text) {
222
+ const out = [];
223
+ const re = /\b(?:tenantId|orgId|orgSlug)\s*:\s*([A-Za-z_$][\w$]*)/g;
224
+ let m = re.exec(text);
225
+ while (m !== null) {
226
+ if (m[1] !== "args")
227
+ out.push(m[1]);
228
+ m = re.exec(text);
229
+ }
230
+ // ES6 SHORTHAND (task k176ewgwrbc6mg1y4h42rg25gn8dja1p): `{ tenantId }` is
231
+ // `{ tenantId: tenantId }` — the boundary field written from a LOCAL of the
232
+ // same name. The keyed regex above needs a `:` and never saw it, so
233
+ // `const tenantId = args.namespace; insert({ tenantId })` read CONFORM. The
234
+ // shorthand member sits after `{` or `,` and before `,` or `}` (or a line
235
+ // end, for a multi-line literal); its local then goes through the SAME
236
+ // provenance analysis as a keyed local.
237
+ const short = /(?:^|[{,])\s*(tenantId|orgId|orgSlug)\s*(?=[,}]|$)/gm;
238
+ let s = short.exec(text);
239
+ while (s !== null) {
240
+ out.push(s[1]);
241
+ s = short.exec(text);
242
+ }
243
+ return out;
244
+ }
245
+ /** If `local` is bound by destructuring the HANDLER'S OWN PARAMETER —
246
+ * `handler: async (ctx, { tenantId }) =>` / `async handler(ctx, { tenantId })`
247
+ * — it IS a caller argument under its own name. Returns true when this line is
248
+ * such a binding of `local`. */
249
+ function boundFromHandlerParam(raw, local) {
250
+ const m = /\bhandler\b[^(\n]*\(\s*[A-Za-z_$][\w$]*\s*(?::\s*[\w.<>]+\s*)?,\s*\{([^}]*)\}/.exec(raw);
251
+ if (!m)
252
+ return false;
253
+ for (const rawPart of m[1].split(",")) {
254
+ const part = rawPart.trim().split("=")[0].trim();
255
+ if (!part)
256
+ continue;
257
+ const bound = part.includes(":") ? (part.split(":")[1] ?? "").trim() : part;
258
+ if (bound === local)
259
+ return true;
260
+ }
261
+ return false;
262
+ }
263
+ /** Max bare-identifier hops chased before exhaustion is declared could-not-judge
264
+ * (defect 3): `const t = orgSlug; const orgSlug = args.x` resolves in one extra
265
+ * hop; deeper indirection or a cycle stops and is reported cnj, never guessed. */
266
+ const R6_MAX_HOP = 2;
267
+ /** If `local` is bound by an object-destructuring declaration on `raw`, return
268
+ * the destructuring RHS (the expression after `} =`) so it can be tested for
269
+ * scope-ness EXACTLY as a plain assignment RHS is (Eta REVISE #3 defect 1).
270
+ * Recognises the shorthand (`const { orgSlug } = ...`), the keyed/renamed form
271
+ * (`const { orgSlug: t } = ...`), and a defaulted binding (`const { x = d } = `).
272
+ * Returns null when this line does not destructure `local`. */
273
+ function destructuredRhs(raw, local) {
274
+ const m = /(?:const|let|var)\s*\{([^}]*)\}\s*=(?!=)\s*([\s\S]+)$/.exec(raw);
275
+ if (!m)
276
+ return null;
277
+ for (const rawPart of m[1].split(",")) {
278
+ const part = rawPart.trim().split("=")[0].trim(); // drop any default value
279
+ if (!part)
280
+ continue;
281
+ const bound = part.includes(":") ? (part.split(":")[1] ?? "").trim() : part;
282
+ if (bound === local)
283
+ return m[2];
284
+ }
285
+ return null;
286
+ }
287
+ /** Follow ONE hop (plus one bounded bare-identifier hop): classify every
288
+ * assignment/declaration of `local` in the handler as scope-derived, arg-derived,
289
+ * or could-not-judge. Reads a PLAIN assignment RHS and a DESTRUCTURING RHS with
290
+ * the same scope test (defect 1). Exposes `matched` so a local with NO resolvable
291
+ * assignment is a third state, not silently tainted (defect 2). A bare-identifier
292
+ * RHS (`const t = orgSlug`) is NOT scope evidence on its own — the identifier's
293
+ * own provenance is resolved one further, bounded, cycle-guarded hop (defect 3);
294
+ * exhaustion is `cnj`, never a guess. */
295
+ export function analyzeLocalTaint(h, local, isResolver, principalOf) {
296
+ return analyzeLocalTaintInner({ ...h, lines: normalizeLines(h.lines) }, local, 0, new Set(), isResolver, principalOf);
297
+ }
298
+ /** The RHS starting on `codeLines[i]`, extended over the following lines until
299
+ * an opening `await <name>(` is balanced (bounded; unbalanced -> the line alone).
300
+ * Used ONLY to recognise a resolver call that spans lines — never to widen the
301
+ * scope test of an ordinary RHS. */
302
+ function joinedAwaitedCall(rhs, codeLines, i) {
303
+ if (!/^\s*await\s+[A-Za-z_$][\w$]*\s*\(/.test(rhs))
304
+ return rhs;
305
+ let text = rhs;
306
+ const depthOf = (t) => (t.match(/\(/g) ?? []).length - (t.match(/\)/g) ?? []).length;
307
+ for (let j = i + 1; depthOf(text) > 0 && j < codeLines.length && j <= i + 40; j++)
308
+ text += `\n${codeLines[j].raw}`;
309
+ return depthOf(text) > 0 ? rhs : text;
310
+ }
311
+ /** `rhs` is exactly `await <name>(…)` closed on this line (nothing after the
312
+ * call but `;`): the whole value is that function's return. */
313
+ function wholeAwaitedCall(rhs) {
314
+ const m = /^\s*await\s+([A-Za-z_$][\w$]*)\s*\(/.exec(rhs);
315
+ if (!m)
316
+ return null;
317
+ let depth = 0;
318
+ for (let i = m[0].length - 1; i < rhs.length; i++) {
319
+ const ch = rhs[i];
320
+ if (ch === "(")
321
+ depth++;
322
+ else if (ch === ")") {
323
+ depth--;
324
+ if (depth === 0)
325
+ return /^\s*;?\s*$/.test(rhs.slice(i + 1)) ? m[1] : null;
326
+ }
327
+ }
328
+ return null;
329
+ }
330
+ function analyzeLocalTaintInner(h, local, depth, visited, isResolver, principalOf) {
331
+ // `local` IS an alias of a checked package principal (`const { principal }
332
+ // = who` after `if (!who.ok) throw`): the verified identity under a name.
333
+ if (principalOf?.(local))
334
+ return { fromArgs: 0, fromScope: 1, matched: 1, cnj: 0, fromResolver: 1 };
335
+ let fromArgs = 0;
336
+ let fromScope = 0;
337
+ let matched = 0;
338
+ let cnj = 0;
339
+ let fromResolver = 0;
340
+ const esc = local.replace(/[$]/g, "\\$&");
341
+ const assign = new RegExp(`(?:^|[^\\w.$])${esc}\\s*(?::[^=\\n]*)?=(?!=)`);
342
+ const decl = new RegExp(`\\b(?:const|let|var)\\s+${esc}\\b`);
343
+ const classify = (rhs, call = rhs) => {
344
+ // A BARE IDENTIFIER RHS carries no scope evidence on its own — its NAME
345
+ // (even `orgSlug`) never counts as scope; the identifier's own provenance
346
+ // decides, or the scan refuses (defect 3, Eta pole 2).
347
+ const bare = rhs
348
+ .trim()
349
+ .replace(/;+\s*$/, "")
350
+ .trim();
351
+ if (/^[A-Za-z_$][\w$]*$/.test(bare) && bare !== "args") {
352
+ if (visited.has(bare) || depth >= R6_MAX_HOP) {
353
+ cnj++; // cycle or hop exhausted — could-not-judge, never a guess.
354
+ return;
355
+ }
356
+ const next = new Set(visited);
357
+ next.add(local);
358
+ const sub = analyzeLocalTaintInner(h, bare, depth + 1, next, isResolver, principalOf);
359
+ if (sub.matched === 0)
360
+ cnj++; // the referenced local has no resolvable assignment.
361
+ else if (sub.fromScope > 0 && sub.fromArgs === 0)
362
+ fromScope++;
363
+ else if (sub.fromArgs > 0)
364
+ fromArgs++;
365
+ else
366
+ cnj++;
367
+ return;
368
+ }
369
+ // The whole value is the return of a function the tree shows to be an
370
+ // identity RESOLVER (it returns the verified scope itself and refuses an
371
+ // absent identity first): scope-derived, whatever arguments it was handed.
372
+ const called = isResolver ? wholeAwaitedCall(call) : null;
373
+ if (called && isResolver?.(called, call)) {
374
+ fromScope++;
375
+ fromResolver++;
376
+ return;
377
+ }
378
+ // A member read of a checked package principal (`who.principal.orgId`).
379
+ if (principalOf?.(rhs)) {
380
+ fromScope++;
381
+ fromResolver++;
382
+ return;
383
+ }
384
+ // Strip `args.<field>` from the RHS BEFORE the scope test so an argument
385
+ // whose FIELD is named like a scope key cannot launder itself:
386
+ // `const t = args.orgSlug` must not read scope-derived just because
387
+ // R6_SCOPE_RHS carries `\borgSlug\b`. The value is still an argument, and
388
+ // the law above (a local fed only from args is tainted) governs. Genuine
389
+ // `scope.orgSlug` / `withOrgScope(...)` RHS has no `args.` token to strip
390
+ // and still matches → fromScope.
391
+ const rhsForScope = rhs.replace(/\bargs\.\w+/g, "");
392
+ if (R6_SCOPE_RHS.test(rhsForScope))
393
+ fromScope++;
394
+ else
395
+ fromArgs++;
396
+ };
397
+ const codeLines = h.lines.filter(code);
398
+ for (let i = 0; i < codeLines.length; i++) {
399
+ const raw = codeLines[i].raw;
400
+ // The handler's own parameter destructured — the binding IS the argument.
401
+ if (boundFromHandlerParam(raw, local)) {
402
+ matched++;
403
+ fromArgs++;
404
+ continue;
405
+ }
406
+ // A destructuring binding of `local` — test its RHS as a plain RHS.
407
+ const dr = destructuredRhs(raw, local);
408
+ if (dr !== null) {
409
+ matched++;
410
+ classify(dr);
411
+ continue;
412
+ }
413
+ const isAssign = assign.test(raw);
414
+ const isBareDecl = !isAssign && decl.test(raw) && !raw.includes("=");
415
+ if (!isAssign && !isBareDecl)
416
+ continue;
417
+ matched++;
418
+ const rhs = isAssign ? raw.slice(raw.indexOf("=") + 1) : "";
419
+ classify(rhs, joinedAwaitedCall(rhs, codeLines, i));
420
+ }
421
+ return fromResolver > 0
422
+ ? { fromArgs, fromScope, matched, cnj, fromResolver }
423
+ : { fromArgs, fromScope, matched, cnj };
424
+ }
425
+ /** The full source text of every `ctx.db.insert|patch|replace(...)` call in a
426
+ * handler, each joined across the lines the call spans (parens balanced). The
427
+ * tenant-provenance regexes read THIS — never the args-declaration block or a
428
+ * plain local assignment — so `tenantId: args.tenantId` is only ever counted
429
+ * when it is the value actually handed to a write. */
430
+ function writeSiteTexts(h) {
431
+ const out = [];
432
+ const codeLines = h.lines.filter(code);
433
+ for (let i = 0; i < codeLines.length; i++) {
434
+ if (!/ctx\.db\.(?:insert|patch|replace)\s*\(/.test(codeLines[i].raw))
435
+ continue;
436
+ let depth = 0;
437
+ let started = false;
438
+ let text = "";
439
+ for (let j = i; j < codeLines.length; j++) {
440
+ const raw = codeLines[j].raw;
441
+ text += (text ? "\n" : "") + raw;
442
+ for (const ch of raw) {
443
+ if (ch === "(") {
444
+ depth++;
445
+ started = true;
446
+ }
447
+ else if (ch === ")")
448
+ depth--;
449
+ }
450
+ if (started && depth <= 0)
451
+ break;
452
+ }
453
+ out.push(text);
454
+ }
455
+ return out;
456
+ }
457
+ // ---- named guard HELPERS defined outside the handler ----------------------
458
+ //
459
+ // A public mutation frequently enforces its boundary in ONE line that calls a
460
+ // helper defined elsewhere: `await requireOrgAdmin(ctx, args.orgSlug)` binds the
461
+ // org argument to the verified identity's own org and throws RBAC_DENIED
462
+ // otherwise; `requireFleetMaster` / `requireServiceAccount` refuse every caller
463
+ // that is not the fleet master. The in-handler regexes above cannot see the
464
+ // helper's body. The fix is a NAMED ALLOWLIST — never "any function whose name
465
+ // starts with require" — and each listed helper is credited only when its OWN
466
+ // DEFINITION is found in the scanned tree and its CONTROL FLOW refuses, on every
467
+ // path to a normal exit, through a branch conditioned on the verified identity
468
+ // (guard-helper-flow.ts). A helper the walk cannot decide is could-not-judge,
469
+ // never credited; so is a call to a listed helper with no readable definition.
470
+ //
471
+ // - "binds-arg": the helper compares its target parameter with the identity;
472
+ // it legitimises a direct-arg boundary write ONLY for an argument that was
473
+ // handed to it (`requireOrgAdmin(ctx, args.orgSlug)` then
474
+ // `orgSlug: args.orgSlug`). A different argument written as the boundary is
475
+ // still the argument.
476
+ // - "master-only": the helper admits the fleet master alone; the master's
477
+ // authority is fleet-wide by design (the documented carve-out), so a
478
+ // boundary argument it writes is the master's decision, not a forgery.
479
+ //
480
+ // The CALL is credited only when it is a direct `await` statement of the
481
+ // handler's body, before the write: a call inside a branch, a try or a closure
482
+ // of the handler is not run on every path.
483
+ export const R6_GUARD_HELPERS = {
484
+ requireOrgAdmin: "binds-arg",
485
+ requireFleetMaster: "master-only",
486
+ requireServiceAccount: "master-only",
487
+ };
488
+ // ---- SCOPE REFINERS at the handler (a local assigned from one) ------------
489
+ //
490
+ // `const scope = await resolveVerifiedPerson(ctx, transportScope, args.verifiedPerson, …)`
491
+ // read as caller-arg-derived (fromScope=0) because an argument is handed to the
492
+ // call — the false R-6 accusation at vantage-peers 45e40c8 missions.ts:232. A
493
+ // SCOPE_REFINERS call (guard-helper-flow.ts) is scope-derived when its
494
+ // definition is READABLE in the scanned tree and the scope it is handed, at
495
+ // `argIndex`, is itself a scope-derived local. Its other arguments never taint
496
+ // its return. (A resolver such as `requireAuthenticatedCaller` stays judged on
497
+ // its BODY by judgeResolverFunction, which now reads a refiner of a pure scope
498
+ // as pure — recurringTasks.ts:216.)
499
+ /** Top-level comma-separated arguments of the first `name(…)` call in `call`. */
500
+ function callArguments(call, name) {
501
+ const at = call.search(new RegExp(`\\b${name}\\s*\\(`));
502
+ if (at === -1)
503
+ return null;
504
+ const open = call.indexOf("(", at);
505
+ const out = [];
506
+ let depth = 0;
507
+ let cur = "";
508
+ for (let i = open; i < call.length; i++) {
509
+ const ch = call[i];
510
+ if ("([{".includes(ch)) {
511
+ depth++;
512
+ if (depth === 1)
513
+ continue;
514
+ }
515
+ else if (")]}".includes(ch)) {
516
+ depth--;
517
+ if (depth === 0) {
518
+ if (cur.trim())
519
+ out.push(cur.trim());
520
+ return out;
521
+ }
522
+ }
523
+ else if (ch === "," && depth === 1) {
524
+ out.push(cur.trim());
525
+ cur = "";
526
+ continue;
527
+ }
528
+ cur += ch;
529
+ }
530
+ return null;
531
+ }
532
+ const sourceOf = (f) => f.lines.map((l) => l.raw).join("\n");
533
+ /** Index every definition of an allowlisted helper in the tree. */
534
+ export function indexGuardHelpers(files) {
535
+ const out = new Map();
536
+ const names = Object.keys(R6_GUARD_HELPERS);
122
537
  for (const f of files) {
123
- if (!isJudgeable(f) || !isConvex(f))
538
+ if (!isJudgeable(f))
539
+ continue;
540
+ const src = sourceOf(f);
541
+ if (!names.some((n) => src.includes(n)))
124
542
  continue;
543
+ for (const d of findHelperDefinitions(f.rel, src, R6_GUARD_HELPERS)) {
544
+ const list = out.get(d.name) ?? [];
545
+ list.push({
546
+ rel: f.rel,
547
+ n: d.line,
548
+ exported: d.exported,
549
+ judgement: d.judgement,
550
+ });
551
+ out.set(d.name, list);
552
+ }
553
+ }
554
+ return out;
555
+ }
556
+ const HELPER_NAMES = new Set(Object.keys(R6_GUARD_HELPERS));
557
+ const HELPER_MENTION = new RegExp(`\\b(?:${Object.keys(R6_GUARD_HELPERS).join("|")})\\s*\\(`);
558
+ /** `null` when the handler mentions a listed helper but its body could not be
559
+ * located for the statement-level read (could-not-judge, never credit). */
560
+ function helperCalls(h, f, writeLine, index, src) {
561
+ if (!h.lines.some((l) => code(l) && HELPER_MENTION.test(l.raw)))
562
+ return [];
563
+ const calls = topLevelHelperCalls(f.rel, src(), h.name, HELPER_NAMES, writeLine);
564
+ if (calls === null)
565
+ return null;
566
+ return calls.map((c) => {
567
+ const defs = index.get(c.name) ?? [];
568
+ const def = defs.find((d) => d.rel === f.rel) ?? defs.find((d) => d.exported);
569
+ return { name: c.name, kind: R6_GUARD_HELPERS[c.name], args: c.args, def };
570
+ });
571
+ }
572
+ /** Every `args.<field>` written DIRECTLY to a boundary field at a write site. */
573
+ function directBoundaryArgs(writes) {
574
+ const out = [];
575
+ const re = /\b(?:tenantId|orgId|orgSlug|createdBy|namespace|orchestratorId|ownerId)\s*:\s*(args\.\w+)/g;
576
+ for (const t of writes) {
577
+ re.lastIndex = 0;
578
+ let m = re.exec(t);
579
+ while (m !== null) {
580
+ out.push(m[1]);
581
+ m = re.exec(t);
582
+ }
583
+ }
584
+ return out;
585
+ }
586
+ /** The honoured helper call that covers this handler's boundary, if any.
587
+ * "master-only" covers any boundary write; "binds-arg" covers it only when every
588
+ * direct-arg boundary value written is an argument handed to the helper. */
589
+ function coveringCall(calls, writes) {
590
+ const honoured = calls.filter((c) => c.def?.judgement.verdict === "honoured");
591
+ const master = honoured.find((c) => c.kind === "master-only");
592
+ if (master)
593
+ return master;
594
+ const bound = honoured.filter((c) => c.kind === "binds-arg");
595
+ if (bound.length === 0)
596
+ return undefined;
597
+ const direct = directBoundaryArgs(writes);
598
+ const all = direct.every((a) => bound.some((c) => new RegExp(`\\b${a.replace(".", "\\.")}\\b`).test(c.args)));
599
+ return all ? bound[0] : undefined;
600
+ }
601
+ class FunctionIndex {
602
+ files;
603
+ parsed = new Map();
604
+ judged = new Map();
605
+ resolvers = new Map();
606
+ constructor(files) {
607
+ this.files = files;
608
+ }
609
+ defsIn(f) {
610
+ let d = this.parsed.get(f.rel);
611
+ if (!d) {
612
+ d = topLevelFunctions(f.rel, sourceOf(f));
613
+ this.parsed.set(f.rel, d);
614
+ }
615
+ return d;
616
+ }
617
+ lookup(name, fromRel) {
618
+ if (!/^[A-Za-z_][\w]*$/.test(name))
619
+ return undefined;
620
+ const decl = new RegExp(`\\b(?:function\\s*\\*?\\s*${name}\\b|const\\s+${name}\\s*=)`);
621
+ const local = this.files.find((f) => f.rel === fromRel);
622
+ if (local) {
623
+ const own = this.defsIn(local).find((d) => d.name === name);
624
+ if (own)
625
+ return { rel: local.rel, f: own };
626
+ }
627
+ const found = [];
628
+ for (const f of this.files) {
629
+ if (f.rel === fromRel || !isJudgeable(f))
630
+ continue;
631
+ if (!f.lines.some((l) => decl.test(l.raw)))
632
+ continue;
633
+ for (const d of this.defsIn(f))
634
+ if (d.name === name && d.exported)
635
+ found.push({ rel: f.rel, f: d });
636
+ }
637
+ return found.length === 1 ? found[0] : undefined;
638
+ }
639
+ judge(key, run) {
640
+ let v = this.judged.get(key);
641
+ if (!v) {
642
+ v = run();
643
+ this.judged.set(key, v);
644
+ }
645
+ return v;
646
+ }
647
+ /** Names seen from `fromRel`, resolved by `lookup` (same file first, else
648
+ * the single exported definition). */
649
+ callResolver(fromRel) {
650
+ let r = this.resolvers.get(fromRel);
651
+ if (!r) {
652
+ r = (name) => {
653
+ const d = this.lookup(name, fromRel);
654
+ return d
655
+ ? {
656
+ fn: d.f.fn,
657
+ key: `${d.rel}:${d.f.line}`,
658
+ resolve: this.callResolver(d.rel),
659
+ }
660
+ : undefined;
661
+ };
662
+ this.resolvers.set(fromRel, r);
663
+ }
664
+ return r;
665
+ }
666
+ resolver(name, fromRel) {
667
+ const d = this.lookup(name, fromRel);
668
+ if (!d)
669
+ return undefined;
670
+ const v = this.judge(`R:${d.rel}:${d.f.line}`, () => judgeResolverFunction(d.f.fn));
671
+ return v.verdict === "honoured" ? d : undefined;
672
+ }
673
+ }
674
+ /** The boundary RHS expressions (`mission.orgId`) that a refusing predicate
675
+ * bound to the caller's verified scope before the write, with the predicate
676
+ * that did it. */
677
+ function predicateBoundValues(h, f, writeLine, fns, src) {
678
+ const out = new Map();
679
+ if (!h.lines.some((l) => code(l) && /\bif\s*\(\s*!\s*[A-Za-z_$][\w$]*\s*\(/.test(l.raw)))
680
+ return out;
681
+ const g = readHandlerGuards(f.rel, src(), h.name, writeLine);
682
+ if (!g)
683
+ return out;
684
+ const isIdentityLocal = (n) => {
685
+ if (!n)
686
+ return false;
687
+ const c = g.consts.get(n);
688
+ if (!c)
689
+ return false;
690
+ if (c.identityRead)
691
+ return true;
692
+ return !!c.awaitedCallee && !!fns.resolver(c.awaitedCallee, f.rel);
693
+ };
694
+ for (const r of g.refusals) {
695
+ const i = r.args.findIndex((a) => isIdentityLocal(a.ident));
696
+ const j = r.args.findIndex((a) => a.memberBase !== undefined && g.consts.has(a.memberBase));
697
+ if (i < 0 || j < 0)
698
+ continue;
699
+ const value = r.args[j].text.replace(/\s+/g, "");
700
+ // The bound property must not be re-assigned in the handler.
701
+ const esc = value.replace(/[.$]/g, "\\$&");
702
+ if (h.lines.some((l) => code(l) && new RegExp(`${esc}\\s*=(?!=)`).test(l.raw)))
703
+ continue;
704
+ const d = fns.lookup(r.predicate, f.rel);
705
+ if (!d)
706
+ continue;
707
+ const v = fns.judge(`P:${d.rel}:${d.f.line}:${i}:${j}`, () => judgePredicateFunction(d.f.fn, i, j));
708
+ if (v.verdict === "honoured")
709
+ out.set(value, `${r.predicate}() (definition ${d.rel}:${d.f.line})`);
710
+ }
711
+ return out;
712
+ }
713
+ /** The caller arguments (`args.namespace`) a BINDING VALIDATOR bound to the
714
+ * caller's verified scope before the write: a direct `await V(…, args.x, …)`
715
+ * statement of the handler whose callee, read in the tree on its body
716
+ * (summarizeValidator), reaches a normal exit only when `args.x` passed a
717
+ * predicate over the verified scope (or for the fleet master). Name-free. */
718
+ function validatorBoundArgs(h, f, writeLine, fns, src) {
719
+ const out = new Map();
720
+ const calls = topLevelHelperCalls(f.rel, src(), h.name, null, writeLine);
721
+ if (!calls)
722
+ return out;
723
+ let g;
724
+ for (const c of calls) {
725
+ const targets = c.argNodes
726
+ .map((a, k) => ({ k, text: a.getText().replace(/\s+/g, "") }))
727
+ .filter((a) => /^args\.[A-Za-z_$][\w$]*$/.test(a.text));
728
+ if (targets.length === 0)
729
+ continue;
730
+ const d = fns.lookup(c.name, f.rel);
731
+ if (!d)
732
+ continue;
733
+ // An argument that is the handler's own verified scope (a const bound to
734
+ // an identity read, or to a function judged an identity resolver).
735
+ g ??= readHandlerGuards(f.rel, src(), h.name, writeLine);
736
+ const identityIdx = c.argNodes.findIndex((a) => {
737
+ const t = a.getText();
738
+ const k = g?.consts.get(t);
739
+ return (!!k &&
740
+ (k.identityRead ||
741
+ (!!k.awaitedCallee && !!fns.resolver(k.awaitedCallee, f.rel))));
742
+ });
743
+ for (const t of targets) {
744
+ const sum = summarizeValidator(d.f.fn, {
745
+ boundIdx: t.k,
746
+ identityIdx: identityIdx < 0 ? undefined : identityIdx,
747
+ callArgs: c.argNodes,
748
+ resolve: fns.callResolver(d.rel),
749
+ key: `${d.rel}:${d.f.line}`,
750
+ depth: 0,
751
+ });
752
+ if (sum.ok && sum.exitsBound && !sum.never)
753
+ out.set(t.text, `${c.name}() (definition ${d.rel}:${d.f.line})`);
754
+ }
755
+ }
756
+ return out;
757
+ }
758
+ /** The write texts with every boundary value that IS (completely) a validated
759
+ * argument replaced by a placeholder: what is left is what still reads args. */
760
+ function maskValidated(writes, validated) {
761
+ 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));
762
+ }
763
+ /** Every RHS of the boundary local `loc` at the write sites, when EACH is a
764
+ * complete member expression on it (`orgId: mission.orgId,`); null when any
765
+ * occurrence is something else (`orgId: mission.orgId ?? args.x`, `{ orgId }`). */
766
+ function boundaryMemberRhs(writes, loc) {
767
+ const out = [];
768
+ const esc = loc.replace(/[$]/g, "\\$&");
769
+ const any = new RegExp(`\\b(?:tenantId|orgId|orgSlug)\\s*:\\s*${esc}\\b`, "g");
770
+ const full = new RegExp(`^\\s*(${esc}(?:\\.[A-Za-z_$][\\w$]*)+)\\s*(?:,|}|$)`);
771
+ for (const t of writes) {
772
+ any.lastIndex = 0;
773
+ let m = any.exec(t);
774
+ while (m !== null) {
775
+ const rest = t.slice(m.index + m[0].length - loc.length);
776
+ const fm = full.exec(rest.split("\n")[0]);
777
+ if (!fm)
778
+ return null;
779
+ out.push(fm[1]);
780
+ m = any.exec(t);
781
+ }
782
+ }
783
+ return out.length ? out : null;
784
+ }
785
+ // ---- WHICH boundary field reaches a write from an ARGUMENT -----------------
786
+ //
787
+ // The last-resort accusation ("a boundary field taken from a caller argument
788
+ // with no identity/resource guard") used to fire on ANY handler line carrying a
789
+ // boundary token. vantage-peers 45e40c8 tasks.complete was accused for
790
+ // `createdBy: fixPatternCreatedBy` where `fixPatternCreatedBy = task.assignedTo`
791
+ // and `task = await ctx.db.get(args.taskId)` — a value read off the TARGET
792
+ // resource, server-side, never the caller's argument. The accusation now needs
793
+ // the boundary field's value, AT A WRITE, to come from the caller's arguments:
794
+ // - `field: args.x` directly, or a local whose assignments read `args` (one
795
+ // hop through renames, member reads and destructuring, bounded);
796
+ // - a local object handed to the write (`patch(id, obj)`) carrying the field
797
+ // in its literal or through `obj.field = …`;
798
+ // - `args` (or a rest/alias of it) spread or passed whole, when the args
799
+ // validator declares a boundary field the write does not override.
800
+ // NOT the argument: a value read from the database (`ctx.db.get`/`.query`),
801
+ // the return of an identity resolver, or anything with no `args` in its
802
+ // provenance (`lastActedBy`, a server-computed actor). A local whose assignment
803
+ // cannot be found stays an argument (the old, conservative reading).
804
+ const R6_BOUNDARY_FIELD = "(?:createdBy|orgId|orgSlug|namespace|orchestratorId|ownerId|tenantId)";
805
+ function boundaryFromArgument(h, writes, isResolver, principalOf) {
806
+ const codeLines = h.lines.filter(code);
807
+ const handlerAt = codeLines.findIndex((l) => /\bhandler\b/.test(l.raw));
808
+ const validator = codeLines
809
+ .slice(0, handlerAt === -1 ? 0 : handlerAt)
810
+ .map((l) => l.raw)
811
+ .join("\n");
812
+ const validatorFields = new Set([
813
+ ...validator.matchAll(new RegExp(`\\b(${R6_BOUNDARY_FIELD})\\s*:`, "g")),
814
+ ].map((m) => m[1]));
815
+ const body = codeLines.slice(Math.max(handlerAt, 0)).map((l) => l.raw);
816
+ const bodyText = body.join("\n");
817
+ const esc = (n) => n.replace(/[$]/g, "\\$&");
818
+ // assignments of `local` in the handler body: RHS texts (multi-line object
819
+ // literals and awaited calls joined), plus whether a handler param binds it.
820
+ const assignments = (local) => {
821
+ const out = [];
822
+ const assign = new RegExp(`(?:^|[^\\w.$])${esc(local)}\\s*(?::[^=\\n]*)?=(?!=)`);
823
+ for (let i = 0; i < body.length; i++) {
824
+ const raw = body[i];
825
+ if (boundFromHandlerParam(raw, local))
826
+ return "param";
827
+ const dr = destructuredRhs(raw, local);
828
+ if (dr !== null) {
829
+ out.push(dr);
830
+ continue;
831
+ }
832
+ if (!assign.test(raw))
833
+ continue;
834
+ let rhs = raw.slice(raw.indexOf("=") + 1);
835
+ const depth = (t) => (t.match(/[({[]/g) ?? []).length - (t.match(/[)}\]]/g) ?? []).length;
836
+ for (let j = i + 1; depth(rhs) > 0 && j < body.length && j <= i + 60; j++)
837
+ rhs += `\n${body[j]}`;
838
+ out.push(rhs);
839
+ }
840
+ return out;
841
+ };
842
+ const argFed = (local, seen) => {
843
+ if (local === "args")
844
+ return true;
845
+ if (principalOf?.(local))
846
+ return false; // an alias of a checked principal
847
+ if (seen.has(local) || seen.size > 6)
848
+ return false;
849
+ const next = new Set(seen).add(local);
850
+ const rhss = assignments(local);
851
+ if (rhss === "param")
852
+ return true;
853
+ if (rhss.length === 0)
854
+ return true; // not found: conservative, as before.
855
+ return rhss.some((rhs) => valueFromArgs(rhs, next));
856
+ };
857
+ const valueFromArgs = (expr, seen) => {
858
+ const e = expr
859
+ .trim()
860
+ .replace(/;+\s*$/, "")
861
+ .trim();
862
+ if (/^(?:await\s+)?ctx\.db\.(?:get|query)\b/.test(e))
863
+ return false; // a stored row
864
+ const called = wholeAwaitedCall(e);
865
+ if (called && isResolver(called, e))
866
+ return false; // the verified scope
867
+ if (principalOf?.(e))
868
+ return false; // a checked package principal
869
+ const root = /^([A-Za-z_$][\w$]*)(?:\s*(?:\.|\?\.|\[|$|\s+as\b|!))/.exec(e);
870
+ if (root &&
871
+ /^[A-Za-z_$][\w$]*(?:(?:\.|\?\.)[A-Za-z_$][\w$]*)*(?:\s+as\s+[\w<>[\]|. ]+|!)?$/.test(e))
872
+ return argFed(root[1], seen); // a local, or a member read of one
873
+ if (/\bargs\b/.test(e))
874
+ return true;
875
+ // any other expression: argument-fed only through a local it names.
876
+ for (const m of e.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)\s*(?:\.|\?\.)/g))
877
+ if (m[1] !== "ctx" && argFed(m[1], seen))
878
+ return true;
879
+ return false;
880
+ };
881
+ const objectCarries = (text, seen) => {
882
+ // `field: <value>` up to the next top-level `,` / `}` / line end.
883
+ for (const m of text.matchAll(new RegExp(`(?:^|[{,\\s])(${R6_BOUNDARY_FIELD})\\s*:\\s*([^,}\\n]+)`, "gm")))
884
+ if (valueFromArgs(m[2], seen))
885
+ return true;
886
+ for (const m of text.matchAll(new RegExp(`(?:^|[{,])\\s*(${R6_BOUNDARY_FIELD})\\s*(?=[,}]|$)`, "gm")))
887
+ if (argFed(m[1], seen))
888
+ return true;
889
+ // spreads: `...args`, or `...x` with x an alias/rest of args.
890
+ for (const m of text.matchAll(/\.\.\.\s*([A-Za-z_$][\w$]*)/g)) {
891
+ const x = m[1];
892
+ if (x === "args" || restOfArgs(x)) {
893
+ const pulled = x === "args" ? new Set() : pulledFromArgs(x);
894
+ const overridden = new Set([
895
+ ...text.matchAll(new RegExp(`\\b(${R6_BOUNDARY_FIELD})\\s*[:,}]`, "g")),
896
+ ].map((k) => k[1]));
897
+ if ([...validatorFields].some((fld) => !pulled.has(fld) && !overridden.has(fld)))
898
+ return true;
899
+ }
900
+ else if (localObjectCarries(x, seen))
901
+ return true;
902
+ }
903
+ return false;
904
+ };
905
+ const restOfArgs = (x) => new RegExp(`\\.\\.\\.\\s*${esc(x)}\\s*,?\\s*\\}\\s*=\\s*args\\b`).test(bodyText) ||
906
+ new RegExp(`\\b(?:const|let|var)\\s+${esc(x)}\\s*=\\s*args\\s*;?\\s*$`, "m").test(bodyText);
907
+ const pulledFromArgs = (x) => {
908
+ const m = new RegExp(`\\{([^}]*)\\.\\.\\.\\s*${esc(x)}\\s*,?\\s*\\}\\s*=\\s*args\\b`).exec(bodyText);
909
+ const out = new Set();
910
+ if (m)
911
+ for (const part of m[1].split(",")) {
912
+ const key = part.split(":")[0].split("=")[0].trim();
913
+ if (key)
914
+ out.add(key);
915
+ }
916
+ return out;
917
+ };
918
+ // A local object handed to a write: its literal, and every `obj.field = v`.
919
+ const localObjectCarries = (obj, seen) => {
920
+ if (seen.has(`obj:${obj}`))
921
+ return false;
922
+ const next = new Set(seen).add(`obj:${obj}`);
923
+ const rhss = assignments(obj);
924
+ if (rhss !== "param")
925
+ for (const rhs of rhss)
926
+ if (rhs.trim().startsWith("{") && objectCarries(rhs, next))
927
+ return true;
928
+ const memberSet = new RegExp(`\\b${esc(obj)}\\s*(?:\\.\\s*(${R6_BOUNDARY_FIELD})|\\[\\s*["'\`](${R6_BOUNDARY_FIELD})["'\`]\\s*\\])\\s*=(?!=)\\s*([^;\\n]+)`, "g");
929
+ for (const m of bodyText.matchAll(memberSet))
930
+ if (valueFromArgs(m[3], next))
931
+ return true;
932
+ return false;
933
+ };
934
+ for (const w of writes) {
935
+ if (new RegExp(`\\b${R6_BOUNDARY_FIELD}\\s*:\\s*args\\b`).test(w))
936
+ return true;
937
+ if (objectCarries(w, new Set()))
938
+ return true;
939
+ // the object handed to the write: its last top-level argument.
940
+ const m = /ctx\.db\.(?:insert|patch|replace)\s*\(([\s\S]*)\)\s*;?\s*$/.exec(w.trim());
941
+ const last = m ? m[1].split(",").pop()?.trim() : undefined;
942
+ if (last === "args") {
943
+ if (validatorFields.size > 0)
944
+ return true;
945
+ }
946
+ else if (last && /^[A-Za-z_$][\w$]*$/.test(last)) {
947
+ if (restOfArgs(last) && validatorFields.size > 0)
948
+ return true;
949
+ if (localObjectCarries(last, new Set()))
950
+ return true;
951
+ }
952
+ }
953
+ return false;
954
+ }
955
+ export function scanR6(files, rootIsConvex = false) {
956
+ const scan = emptyScan();
957
+ const helperIndex = indexGuardHelpers(files);
958
+ const fns = new FunctionIndex(files);
959
+ for (const raw of files) {
960
+ if (!isJudgeable(raw) || !isConvex(raw, rootIsConvex))
961
+ continue;
962
+ // One shape for every argument read: `args["x"]` → `args.x`.
963
+ const f = { ...raw, lines: normalizeLines(raw.lines) };
964
+ // Parsed once per file, and only when the file names the identity
965
+ // package: the checked-principal read (package-principal.ts) needs it.
966
+ const parsed = f.lines.some((l) => l.raw.includes(IDENTITY_PACKAGE))
967
+ ? parseFile(f)
968
+ : undefined;
125
969
  for (const h of splitMutationHandlers(f)) {
970
+ // An internalMutation has NO client caller: its arguments come from
971
+ // server code (a cron, a scheduler hop, another function), so "a
972
+ // caller-supplied argument" — the thing R-6 forbids (standard
973
+ // 4bcd605 :71, :252 "never accept a userId as a function argument")
974
+ // does not exist there. R-6 governs the client-callable write only.
975
+ if (h.internal)
976
+ continue;
126
977
  let writeLine = 0;
127
978
  let guard = false;
128
979
  let boundaryArg = false;
980
+ let resolvesScope = false;
129
981
  for (const l of h.lines) {
130
982
  if (!code(l))
131
983
  continue;
@@ -133,32 +985,310 @@ export function scanR6(files) {
133
985
  writeLine = l.n;
134
986
  if (R6_IDENTITY_GUARD.test(l.raw) || R6_RESOURCE_GUARD.test(l.raw))
135
987
  guard = true;
136
- if (R6_BOUNDARY_ARG.test(l.raw))
137
- boundaryArg = true;
988
+ if (R6_SCOPE_RESOLUTION.test(l.raw))
989
+ resolvesScope = true;
138
990
  }
139
991
  if (!writeLine)
140
992
  continue; // not a write handler — R-6 does not govern it.
141
- if (guard) {
993
+ const writes = writeSiteTexts(h);
994
+ let directTenant = writes.some((t) => R6_TENANT_FROM_ARG.test(t));
995
+ // Follow the local ONE hop: every non-`args` local written to a tenant
996
+ // boundary field, and its THREE-STATE provenance. A local fed from args
997
+ // (matched>0, fromScope===0, fromArgs>0) is TAINTED; a local with NO
998
+ // resolvable assignment (matched===0) or only unresolved indirections is
999
+ // COULD-NOT-JUDGE — never silently tainted (Eta REVISE #3 defect 2).
1000
+ const boundaryLocals = [
1001
+ ...new Set(writes.flatMap(boundaryLocalsInWrite)),
1002
+ ];
1003
+ let taintedLocal;
1004
+ let cnjLocal;
1005
+ let anyDerived = false;
1006
+ let fileSrc;
1007
+ const getSrc = () => {
1008
+ fileSrc ??= sourceOf(f);
1009
+ return fileSrc;
1010
+ };
1011
+ const resolvedBy = new Map();
1012
+ const isResolver = (name, call) => {
1013
+ const d = fns.resolver(name, f.rel);
1014
+ if (d) {
1015
+ resolvedBy.set(name, `identity resolver ${name}() (definition ${d.rel}:${d.f.line}), which returns the verified scope itself and refuses an absent identity on every path`);
1016
+ return true;
1017
+ }
1018
+ const spec = SCOPE_REFINERS[name];
1019
+ if (!spec)
1020
+ return false;
1021
+ const def = fns.lookup(name, f.rel);
1022
+ const arg = callArguments(call, name)?.[spec.argIndex];
1023
+ if (!def || !arg || !/^[A-Za-z_$][\w$]*$/.test(arg) || arg === "args")
1024
+ return false;
1025
+ const t = analyzeLocalTaint(h, arg, isResolver);
1026
+ if (!(t.fromScope > 0 && t.fromArgs === 0))
1027
+ return false;
1028
+ resolvedBy.set(name, `named scope refiner ${name}() (definition ${def.rel}:${def.f.line}) handed the scope-derived local "${arg}"`);
1029
+ return true;
1030
+ };
1031
+ // The principal @vantageos/cloud-identity resolved, refused unless ok
1032
+ // before the write (package-principal.ts): a verified-identity value.
1033
+ const principals = parsed
1034
+ ? packagePrincipals(parsed, h.name, writeLine)
1035
+ : undefined;
1036
+ const principalOf = principals
1037
+ ? (expr) => {
1038
+ const why = principals.principalOf(expr);
1039
+ if (why)
1040
+ resolvedBy.set(`principal:${why}`, why);
1041
+ return why;
1042
+ }
1043
+ : undefined;
1044
+ // The boundary-from-argument test reads the WRITE's provenance, never a
1045
+ // boundary token anywhere in the handler (see boundaryFromArgument).
1046
+ boundaryArg = boundaryFromArgument(h, writes, isResolver, principalOf);
1047
+ // An awaited call, before the first write, to a function judged an
1048
+ // identity resolver (it refuses an absent identity on every path and
1049
+ // returns the verified scope) IS an identity guard, whatever arguments
1050
+ // it is handed — vantage-peers 45e40c8 tasks.complete resolves its
1051
+ // caller through requireAuthenticatedCaller before writing.
1052
+ let resolverGuard;
1053
+ {
1054
+ const codeLines = h.lines.filter(code);
1055
+ for (let i = 0; i < codeLines.length && !resolverGuard; i++) {
1056
+ if (codeLines[i].n >= writeLine)
1057
+ break;
1058
+ const m = /\bawait\s+([A-Za-z_$][\w$]*)\s*\(/.exec(codeLines[i].raw);
1059
+ if (!m || m[1] === "ctx")
1060
+ continue;
1061
+ const from = codeLines[i].raw.slice(m.index);
1062
+ const call = joinedAwaitedCall(from, codeLines, i);
1063
+ resolvedBy.clear();
1064
+ if (isResolver(m[1], call))
1065
+ resolverGuard = `${[...resolvedBy.values()].join(", ")} called at line ${codeLines[i].n}`;
1066
+ }
1067
+ resolvedBy.clear();
1068
+ }
1069
+ // Resolving the caller proves WHO writes, not that a boundary ARGUMENT
1070
+ // is bound to them: the credit stands only when no argument reaches a
1071
+ // boundary field at a write (a hollow master-only helper that returns the
1072
+ // scope must not excuse `orchestratorId: args.orchestratorId`).
1073
+ if (resolverGuard && !boundaryArg)
1074
+ guard = true;
1075
+ else
1076
+ resolverGuard = undefined;
1077
+ const credits = [];
1078
+ // A boundary written straight from an argument a BINDING VALIDATOR
1079
+ // bound to the verified scope before the write: credited only when
1080
+ // EVERY argument written to a boundary field is so bound.
1081
+ if (directBoundaryArgs(writes).length > 0) {
1082
+ const validated = validatorBoundArgs(h, f, writeLine, fns, getSrc);
1083
+ const masked = maskValidated(writes, validated);
1084
+ if (validated.size > 0 &&
1085
+ directBoundaryArgs(masked).length === 0 &&
1086
+ !masked.some((t) => R6_TENANT_FROM_ARG.test(t))) {
1087
+ directTenant = false;
1088
+ boundaryArg = false;
1089
+ anyDerived = true;
1090
+ resolvesScope = true;
1091
+ const written = new Set(directBoundaryArgs(writes));
1092
+ for (const [arg, by] of validated)
1093
+ if (written.has(arg))
1094
+ 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`);
1095
+ }
1096
+ }
1097
+ let bound;
1098
+ for (const loc of boundaryLocals) {
1099
+ const rhs = boundaryMemberRhs(writes, loc);
1100
+ // Every write of `loc` reads the checked package principal
1101
+ // (`orgId: who.principal.orgId`, `orgId: p.orgId` for an alias p).
1102
+ const viaPrincipal = rhs && principals ? rhs.map((x) => principals.principalOf(x)) : [];
1103
+ if (viaPrincipal.length > 0 && viaPrincipal.every((x) => x)) {
1104
+ anyDerived = true;
1105
+ resolvesScope = true;
1106
+ credits.push(`\`${rhs?.[0]}\` is ${viaPrincipal[0]}`);
1107
+ continue;
1108
+ }
1109
+ // A value a refusing predicate bound to the caller's scope before
1110
+ // the write (`if (!P(scope, mission.orgId)) throw`), written
1111
+ // unchanged: derived from the verified identity through the target.
1112
+ if (rhs) {
1113
+ bound ??= predicateBoundValues(h, f, writeLine, fns, getSrc);
1114
+ const by = rhs.map((x) => bound?.get(x));
1115
+ if (by.every((x) => x !== undefined)) {
1116
+ anyDerived = true;
1117
+ resolvesScope = true;
1118
+ credits.push(`\`${rhs[0]}\` bound to the verified scope by refusing predicate ${by[0]}`);
1119
+ continue;
1120
+ }
1121
+ }
1122
+ resolvedBy.clear();
1123
+ const a = analyzeLocalTaint(h, loc, isResolver, principalOf);
1124
+ if (a.fromResolver) {
1125
+ resolvesScope = true;
1126
+ const by = [...resolvedBy.entries()];
1127
+ const returned = by.filter(([k]) => !k.startsWith("principal:"));
1128
+ const read = by.filter(([k]) => k.startsWith("principal:"));
1129
+ if (returned.length > 0)
1130
+ credits.push(`local "${loc}" is the return of ${returned.map(([, v]) => v).join(", ")}`);
1131
+ if (read.length > 0)
1132
+ credits.push(`local "${loc}" reads ${read.map(([, v]) => v).join(", ")}`);
1133
+ }
1134
+ if (a.fromScope > 0) {
1135
+ anyDerived = true;
1136
+ }
1137
+ else if (a.matched === 0) {
1138
+ cnjLocal ??= loc; // no assignment/declaration found — cannot judge.
1139
+ }
1140
+ else if (a.fromArgs > 0) {
1141
+ taintedLocal ??= loc; // fed only from args — laundered argument.
1142
+ }
1143
+ else {
1144
+ cnjLocal ??= loc; // matched but only unresolved bare-id hops.
1145
+ }
1146
+ }
1147
+ // A CONDITIONAL boundary value (`orgId: c ? a : b`): an arm that reads
1148
+ // the caller's args is the argument, unless it is the TRUE arm of a
1149
+ // positive `<scope>.isMaster` test on a scope-derived local (the
1150
+ // fleet carve-out: the master's authority is fleet-wide by design).
1151
+ let conditionalArg;
1152
+ for (const cb of writes.flatMap((t) => conditionalBoundaries(t, R6_TENANT_FIELDS))) {
1153
+ const base = cb.masterBase;
1154
+ const masterArm = base !== undefined &&
1155
+ base !== "args" &&
1156
+ (() => {
1157
+ const a = analyzeLocalTaint(h, base, isResolver);
1158
+ return a.fromScope > 0 && a.fromArgs === 0;
1159
+ })();
1160
+ if (cb.falseReadsArgs || (cb.trueReadsArgs && !masterArm)) {
1161
+ conditionalArg ??= cb.field;
1162
+ }
1163
+ }
1164
+ const derivedTenant = anyDerived;
1165
+ const accusable = directTenant ||
1166
+ conditionalArg ||
1167
+ taintedLocal ||
1168
+ (!guard && boundaryArg);
1169
+ const calls = helperCalls(h, f, writeLine, helperIndex, getSrc);
1170
+ const violateBefore = scan.violate.length;
1171
+ const cover = calls ? coveringCall(calls, writes) : undefined;
1172
+ if (cover?.def) {
142
1173
  scan.conform.push({
143
1174
  rel: f.rel,
144
1175
  n: writeLine,
145
- note: `${h.name}: boundary derived from a verified identity / target-resource guard`,
1176
+ 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)`,
146
1177
  });
1178
+ continue;
147
1179
  }
148
- else if (boundaryArg) {
1180
+ // The handler leans on an allowlisted helper the doctor cannot judge:
1181
+ // its definition is not in the scanned tree, its control flow is not
1182
+ // modelled, or the handler body itself could not be located. Neither
1183
+ // credited nor accused.
1184
+ const undecided = calls === null
1185
+ ? "its handler body could not be located for the statement-level read"
1186
+ : calls
1187
+ .map((c) => c.def === undefined
1188
+ ? `${c.name}() has no readable definition in the scanned tree`
1189
+ : c.def.judgement.verdict === "could-not-judge"
1190
+ ? `${c.name}() at ${c.def.rel}:${c.def.n} could not be judged — ${c.def.judgement.why}`
1191
+ : "")
1192
+ .find((x) => x !== "");
1193
+ if (undecided && accusable) {
1194
+ scan.cnj.push({
1195
+ rel: f.rel,
1196
+ n: writeLine,
1197
+ note: `${h.name}: relies on an allowlisted guard helper whose enforcement cannot be decided — ${undecided} (could-not-judge, not an accusation)`,
1198
+ });
1199
+ continue;
1200
+ }
1201
+ if (directTenant) {
1202
+ // Decisive VIOLATE — the WRITE-SITE provenance, not guard presence,
1203
+ // is what R-6 governs. A tenant-scoping field written straight from a
1204
+ // caller arg is the boundary being the argument, even when the handler
1205
+ // resolves scope elsewhere (the historical sendMessage defect).
149
1206
  scan.violate.push({
150
1207
  rel: f.rel,
151
1208
  n: writeLine,
152
- note: `${h.name}: writes a boundary field (org/createdBy/namespace) taken from a caller argument with no identity/resource guard — the boundary is the argument`,
1209
+ 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`,
153
1210
  });
154
1211
  }
155
- else {
1212
+ else if (conditionalArg) {
1213
+ scan.violate.push({
1214
+ rel: f.rel,
1215
+ n: writeLine,
1216
+ 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`,
1217
+ });
1218
+ }
1219
+ else if (taintedLocal) {
1220
+ // LAUNDERED VIOLATE — the boundary field is a local (`tenantId: t`)
1221
+ // but every assignment to that local is fed from args (fromScope===0):
1222
+ // the caller's argument reaching the write under another name. A rename
1223
+ // hop does not sanitise provenance; scope resolved elsewhere in the
1224
+ // handler does not rescue it.
1225
+ scan.violate.push({
1226
+ rel: f.rel,
1227
+ n: writeLine,
1228
+ 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`,
1229
+ });
1230
+ }
1231
+ else if (cnjLocal && !anyDerived) {
1232
+ // THIRD STATE — a boundary local whose provenance could not be
1233
+ // resolved (no assignment/declaration found in-handler, or only
1234
+ // unresolved bare-identifier indirections). This is could-not-judge,
1235
+ // NEVER an accusation (Eta REVISE #3 defect 2).
156
1236
  scan.cnj.push({
157
1237
  rel: f.rel,
158
1238
  n: writeLine,
159
- note: `${h.name}: writes with no identity/resource guard and no clearly caller-supplied boundary field — deferred, not statically confirmed`,
1239
+ 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`,
1240
+ });
1241
+ }
1242
+ else if (derivedTenant && resolvesScope) {
1243
+ scan.conform.push({
1244
+ rel: f.rel,
1245
+ n: writeLine,
1246
+ note: `${h.name}: tenant boundary written from a scope-derived local at the write site (scope/identity resolved in-handler)${credits.length ? ` — ${credits.join("; ")}` : ""}`,
1247
+ });
1248
+ }
1249
+ else if (guard) {
1250
+ scan.conform.push({
1251
+ rel: f.rel,
1252
+ n: writeLine,
1253
+ note: `${h.name}: boundary derived from a verified identity / target-resource guard${resolverGuard ? ` — ${resolverGuard}` : ""}`,
1254
+ });
1255
+ }
1256
+ else if (boundaryArg) {
1257
+ scan.violate.push({
1258
+ rel: f.rel,
1259
+ n: writeLine,
1260
+ note: `${h.name}: writes a boundary field (org/createdBy/namespace) taken from a caller argument with no identity/resource guard — the boundary is the argument`,
160
1261
  });
161
1262
  }
1263
+ else {
1264
+ // Two shapes read in full from the AST before deferring: a FILE-LOCAL
1265
+ // master-only wrapper dominating the write, and a possession-gated
1266
+ // write of the one row a hashed argument selects (r6-shapes.ts).
1267
+ const credit = masterOnlyWrapperCredit(raw, h.name) ??
1268
+ possessionGatedCredit(raw, h.name);
1269
+ if (credit)
1270
+ scan.conform.push({
1271
+ rel: f.rel,
1272
+ n: writeLine,
1273
+ note: `${h.name}: ${credit}`,
1274
+ });
1275
+ else
1276
+ scan.cnj.push({
1277
+ rel: f.rel,
1278
+ n: writeLine,
1279
+ note: `${h.name}: writes with no identity/resource guard and no clearly caller-supplied boundary field — deferred, not statically confirmed`,
1280
+ });
1281
+ }
1282
+ // A violation at a site calling a listed helper whose definition was
1283
+ // read and NOT honoured says why, so the reader is not left believing
1284
+ // the helper was simply missed.
1285
+ const refused = calls?.find((c) => c.def?.judgement.verdict === "refused");
1286
+ if (refused?.def &&
1287
+ refused.def.judgement.verdict === "refused" &&
1288
+ scan.violate.length > violateBefore) {
1289
+ const v = scan.violate[scan.violate.length - 1];
1290
+ v.note += `; allowlisted helper ${refused.name}() at ${refused.def.rel}:${refused.def.n} NOT honoured — ${refused.def.judgement.why}`;
1291
+ }
162
1292
  }
163
1293
  }
164
1294
  return scan;
@@ -199,10 +1329,10 @@ function collectConvexErrorThrows(f) {
199
1329
  }
200
1330
  return out;
201
1331
  }
202
- export function scanR16(files) {
1332
+ export function scanR16(files, rootIsConvex = false) {
203
1333
  const scan = emptyScan();
204
1334
  for (const f of files) {
205
- if (!isJudgeable(f) || !isConvex(f))
1335
+ if (!isJudgeable(f) || !isConvex(f, rootIsConvex))
206
1336
  continue;
207
1337
  for (const t of collectConvexErrorThrows(f)) {
208
1338
  if (R16_CODE_TOKEN.test(t.payload)) {
@@ -232,12 +1362,12 @@ const R16_CHECK = "a refusal throws a ConvexError with a stable UPPER_SNAKE_CASE
232
1362
  /** Classify an R-6 live probe: a boundary-less write MUST be refused fail-closed. */
233
1363
  export function classifyR6Live(id, tool, outcome) {
234
1364
  if (outcome.kind === "accepted") {
235
- return {
1365
+ return withCount({
236
1366
  id,
237
1367
  anchor: `live:${tool}`,
238
1368
  state: "non-conforming",
239
1369
  verdict: `R-6 (live): tool "${tool}" ACCEPTED a write with no verified identity/boundary — fail-open.`,
240
- };
1370
+ }, itemCount(1));
241
1371
  }
242
1372
  if (/RBAC|AUTH|ORG|unauthori|denied|required|forbidden/i.test(outcome.message)) {
243
1373
  return {
@@ -269,12 +1399,12 @@ export function classifyR16Live(id, payload) {
269
1399
  };
270
1400
  }
271
1401
  if (R16_REFUSAL_KW.test(payload)) {
272
- return {
1402
+ return withCount({
273
1403
  id,
274
1404
  anchor: "live:error-contract",
275
1405
  state: "non-conforming",
276
1406
  verdict: `R-16 (live): a triggered refusal carried NO structured code — opaque error contract (${payload.slice(0, 80)}).`,
277
- };
1407
+ }, itemCount(1));
278
1408
  }
279
1409
  const reason = `R-16 (live): could not trigger a classifiable refusal — the served response is neither a coded refusal nor a recognisable one. NOT a clean bill.`;
280
1410
  return {
@@ -394,13 +1524,13 @@ function stringifyResult(res) {
394
1524
  /** R-6 — static primary + live secondary (LiveDetector). */
395
1525
  export const R6 = {
396
1526
  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."),
1527
+ 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
1528
  runLive: r6RunLive,
399
1529
  };
400
1530
  /** R-16 — static primary + live secondary (LiveDetector). */
401
1531
  export const R16 = {
402
1532
  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."),
1533
+ 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
1534
  runLive: r16RunLive,
405
1535
  };
406
1536
  /** The two, in a form the orchestrator spreads into REGISTRY. */