@vantageos/backend-doctor 0.2.0 → 0.4.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 (70) hide show
  1. package/README.md +57 -4
  2. package/dist/args.d.ts +1 -1
  3. package/dist/args.js +19 -1
  4. package/dist/build-stamp.json +2 -2
  5. package/dist/caller-resolution.js +11 -8
  6. package/dist/cli.js +164 -15
  7. package/dist/contract.d.ts +25 -7
  8. package/dist/contract.js +52 -7
  9. package/dist/coverage.d.ts +1 -1
  10. package/dist/coverage.js +2 -2
  11. package/dist/detectors/oracle-axis1.js +5 -4
  12. package/dist/detectors/oracle-axis2.d.ts +6 -0
  13. package/dist/detectors/oracle-axis2.js +32 -16
  14. package/dist/detectors/predicates.js +12 -3
  15. package/dist/detectors/shared.js +3 -2
  16. package/dist/detectors/source/_ast.d.ts +92 -0
  17. package/dist/detectors/source/_ast.js +353 -0
  18. package/dist/detectors/source/_scan.js +4 -3
  19. package/dist/detectors/source/backfill.js +62 -17
  20. package/dist/detectors/source/contract.js +21 -13
  21. package/dist/detectors/source/credential-hygiene.d.ts +11 -0
  22. package/dist/detectors/source/credential-hygiene.js +619 -0
  23. package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
  24. package/dist/detectors/source/cross-tenant-write.js +1408 -0
  25. package/dist/detectors/source/guard-helper-flow.d.ts +22 -0
  26. package/dist/detectors/source/guard-helper-flow.js +49 -6
  27. package/dist/detectors/source/identity-by-id.d.ts +80 -0
  28. package/dist/detectors/source/identity-by-id.js +1783 -0
  29. package/dist/detectors/source/identity.d.ts +8 -1
  30. package/dist/detectors/source/identity.js +364 -35
  31. package/dist/detectors/source/jobs.js +13 -2
  32. package/dist/detectors/source/lifecycle.js +14 -2
  33. package/dist/detectors/source/local-identity.d.ts +26 -0
  34. package/dist/detectors/source/local-identity.js +666 -0
  35. package/dist/detectors/source/narrow-then-bound.d.ts +11 -0
  36. package/dist/detectors/source/narrow-then-bound.js +571 -0
  37. package/dist/detectors/source/package-principal.d.ts +9 -0
  38. package/dist/detectors/source/package-principal.js +177 -0
  39. package/dist/detectors/source/r6-shapes.d.ts +20 -0
  40. package/dist/detectors/source/r6-shapes.js +285 -0
  41. package/dist/detectors/source/readbudget.js +291 -0
  42. package/dist/detectors/source/recipient-by-id.d.ts +40 -0
  43. package/dist/detectors/source/recipient-by-id.js +1108 -0
  44. package/dist/detectors/source/refusal.d.ts +14 -0
  45. package/dist/detectors/source/refusal.js +75 -2
  46. package/dist/detectors/source/security.js +455 -26
  47. package/dist/detectors/source/service-account-claim.d.ts +15 -0
  48. package/dist/detectors/source/service-account-claim.js +491 -0
  49. package/dist/detectors/source/writesafety.js +27 -14
  50. package/dist/detectors/standard/_doc.d.ts +1 -1
  51. package/dist/detectors/standard/_doc.js +5 -4
  52. package/dist/detectors/standard/r41-frontier.js +3 -2
  53. package/dist/detectors/standard/r46-returns-validator.js +3 -2
  54. package/dist/detectors/structural.d.ts +6 -7
  55. package/dist/detectors/structural.js +75 -43
  56. package/dist/not-checkable.d.ts +1 -1
  57. package/dist/not-checkable.js +12 -0
  58. package/dist/registry.d.ts +1 -1
  59. package/dist/registry.js +55 -3
  60. package/dist/rule-class.d.ts +85 -0
  61. package/dist/rule-class.js +218 -0
  62. package/dist/tally.d.ts +13 -5
  63. package/dist/tally.js +14 -6
  64. package/dist/violation-count.d.ts +48 -0
  65. package/dist/violation-count.js +94 -0
  66. package/fixtures/backend-standard-2825122.md +722 -0
  67. package/fixtures/backend-standard-4dcd3e0.md +722 -0
  68. package/fixtures/backend-standard-bacc54a.md +785 -0
  69. package/fixtures/rule-classes-bacc54a.json +244 -0
  70. package/package.json +1 -1
@@ -0,0 +1,491 @@
1
+ // R-54 — a door the fleet service account can reach acts only inside an
2
+ // organisation the transport verified for this call; a call that carries no
3
+ // verified claim is refused, never served as master.
4
+ //
5
+ // Standard bacc54a §4 R-54 and its Detector spec, read literally:
6
+ // red (A) a public door that resolves its caller through the organisation-scope
7
+ // resolver (so the service account resolves to master) and declares no
8
+ // claim argument (verifiedActor / verifiedOrg / verifiedPerson);
9
+ // red (B) a door that declares a claim argument but has no refusal for the
10
+ // claimless service-account call: its claim is optional, and the absence
11
+ // of the claim leaves the call master;
12
+ // green a claim argument together with a refusal for the claimless
13
+ // service-account call, or one of three listed exits:
14
+ // (a) service-account-only by purpose (requireServiceAccount, or
15
+ // requireResolvedCaller with masterOnly AND mcpBoundOnly);
16
+ // (b) not reachable by the service account: the callers resolve only
17
+ // through a verified session (requireOrgAdmin and its siblings, or
18
+ // a bare getUserIdentity read) and the door never reaches the
19
+ // organisation-scope resolver;
20
+ // (c) `fleet-wide: <reason of eight characters or more>` in the door's
21
+ // source, stating why a whole-fleet reach is its purpose;
22
+ // could-not-judge an action whose scope arrives through a run call, a
23
+ // resolver-shaped callee defined outside the scanned tree, a claim
24
+ // argument spread from a validator the scan cannot find, and a door
25
+ // registered through a wrapper whose context is not read here. Never a
26
+ // readable door.
27
+ //
28
+ // WHAT A REFUSAL IS. A `throw` (or a `return` of an object that says it refuses)
29
+ // whose PATH CONDITION holds both the service-account state and the claim's
30
+ // absence: every enclosing `if` of the throw contributes its condition (its
31
+ // negation for an `else`), split on `&&` (on `||` for a negation). The atoms are
32
+ // read by shape, not by spelling:
33
+ // service-account state `scope.isMaster`, `isMcpBoundMaster(scope)`,
34
+ // `scope.masterSource === "service-account"`, a
35
+ // `serviceAccount`-named value;
36
+ // claim absence `X === undefined`, `X == null`, `!X`, `typeof X ===
37
+ // "undefined"` — or the negation of `X !== undefined`,
38
+ // `X != null`, `X` — where X is CLAIM-DERIVED: it names
39
+ // verifiedActor / verifiedOrg / verifiedPerson, a
40
+ // parameter called `claim` or `verified*`, or a local
41
+ // initialised from a claim-derived value (to a fixpoint).
42
+ // A path that also requires the caller NOT to be the service account (`!isMaster`)
43
+ // refuses the other population, so it is not a claimless-service-account refusal.
44
+ // A claim that is mandatory for every caller (absence alone refuses) is bound.
45
+ //
46
+ // WHAT IS NOT READ. R-54 clause 2 (the claim confines every row the door reads or
47
+ // writes) is not decided here: which rows a handler touches, and against which
48
+ // tenant, is R-6 / R-52 / R-53 clause 2 (the target check). This detector decides
49
+ // that a verified claim exists to confine to, and that its absence is refused.
50
+ import * as ts from "typescript";
51
+ import { calledNames, indexFunctions, lineOf, parseFile, registrations, unwrap, walk, } from "./_ast.js";
52
+ import { decide, emptyScan, isJudgeable, } from "./_scan.js";
53
+ export const R54_ID = "R-54 a door the service account can reach acts inside a transport-verified organisation; a claimless call is refused, never served as master";
54
+ const R54_CHECK = "a door resolving its caller through the organisation-scope resolver declares a verified-claim argument and refuses the call that is the service account and carries no claim, or is service-account-only, session-only, or declared fleet-wide";
55
+ export const R54_STANDARD_PIN = "vantagepeers/backend-standard/backend-standard.md @ bacc54a53d5f6f9f20e5254908e2b46807902faa §4 R-54";
56
+ const CLAIMS = ["verifiedActor", "verifiedOrg", "verifiedPerson"];
57
+ const PUBLIC_BUILDERS = new Set([
58
+ "query",
59
+ "mutation",
60
+ "action",
61
+ ]);
62
+ const SCOPE_RESOLVER = "withOrgScope";
63
+ /** `fleet-wide: <reason>` — the reason must be a real sentence, not a token. */
64
+ export const FLEET_WIDE_MARKER = /fleet-wide:\s*(\S[^\n]{7,})/;
65
+ /** A callee name that looks like it resolves a caller / scope. */
66
+ const RESOLVER_SHAPED = /^(require|resolve|get|with|assert|check)[A-Za-z]*(Scope|Caller|Principal|Identity|Reader|Actor|Auth)[A-Za-z]*$/;
67
+ /** Session-only gates: the caller is a verified session, never the service account. */
68
+ const SESSION_GATES = new Set([
69
+ "requireOrgAdmin",
70
+ "requireOperatorAdminToCreateOrg",
71
+ "resolveOperatorAdmin",
72
+ ]);
73
+ const SERVICE_STATE = /\bisMaster\b|\bisMcpBoundMaster\b|masterSource\s*===?\s*["']service-account["']|\bisServiceAccount\b|serviceAccount/i;
74
+ const REFUSAL_RETURN = /refus|denied|unauthori[sz]ed|RBAC/i;
75
+ // ---- args ------------------------------------------------------------------
76
+ /** Top-level `const NAME = { … }` / `v.object({ … })` initialiser text, tree-wide. */
77
+ function topLevelConsts(ps) {
78
+ const m = new Map();
79
+ for (const p of ps)
80
+ for (const st of p.sf.statements)
81
+ if (ts.isVariableStatement(st))
82
+ for (const d of st.declarationList.declarations)
83
+ if (ts.isIdentifier(d.name) && d.initializer)
84
+ m.set(d.name.text, d.initializer.getText(p.sf));
85
+ return m;
86
+ }
87
+ /** The registration's config object literal (`{ args, handler, … }`). */
88
+ function configOf(r) {
89
+ for (let x = r.handler.parent; x; x = x.parent)
90
+ if (ts.isObjectLiteralExpression(x))
91
+ return x;
92
+ return undefined;
93
+ }
94
+ function readArgs(r, p, consts) {
95
+ const out = { claims: [], unresolved: [] };
96
+ const cfg = configOf(r);
97
+ const prop = cfg?.properties.find((x) => x.name && ts.isIdentifier(x.name) && x.name.text === "args");
98
+ if (!prop || !ts.isPropertyAssignment(prop))
99
+ return out;
100
+ const seen = new Set();
101
+ const visit = (text, depth) => {
102
+ for (const c of CLAIMS)
103
+ if (new RegExp(`\\b${c}\\s*:`).test(text) && !out.claims.includes(c))
104
+ out.claims.push(c);
105
+ for (const m of text.matchAll(/\.\.\.\s*([A-Za-z_$][\w$]*)/g)) {
106
+ const n = m[1];
107
+ if (seen.has(n))
108
+ continue;
109
+ seen.add(n);
110
+ const body = consts.get(n);
111
+ if (body !== undefined && depth < 4)
112
+ visit(body, depth + 1);
113
+ else
114
+ out.unresolved.push(n);
115
+ }
116
+ };
117
+ const init = unwrap(prop.initializer);
118
+ if (ts.isIdentifier(init)) {
119
+ const body = consts.get(init.text);
120
+ if (body === undefined)
121
+ out.unresolved.push(init.text);
122
+ else
123
+ visit(body, 1);
124
+ }
125
+ else
126
+ visit(init.getText(p.sf), 0);
127
+ return out;
128
+ }
129
+ function closureOf(handler, idx) {
130
+ const nodes = [handler];
131
+ const calls = new Set();
132
+ const undefinedCalls = new Set();
133
+ const seen = new Set();
134
+ const queue = [handler];
135
+ while (queue.length) {
136
+ const n = queue.pop();
137
+ for (const c of calledNames(n)) {
138
+ calls.add(c);
139
+ if (seen.has(c))
140
+ continue;
141
+ seen.add(c);
142
+ const defs = idx.get(c);
143
+ if (defs === undefined) {
144
+ undefinedCalls.add(c);
145
+ continue;
146
+ }
147
+ for (const d of defs) {
148
+ nodes.push(d.fn);
149
+ queue.push(d.fn);
150
+ }
151
+ }
152
+ }
153
+ return { nodes, calls, undefinedCalls };
154
+ }
155
+ // ---- claim refusal -----------------------------------------------------------
156
+ const isClaimName = (n) => /^verified(Actor|Org|Person)$/.test(n) || /^(claim|verified\w*)$/i.test(n);
157
+ /** A callee that turns a claim into a value which is undefined exactly when the
158
+ * claim is absent (`resolveVerifiedOrg`, `resolveDoorVerifiedOrg`). A resolver
159
+ * that returns a SCOPE whatever the claim (`resolveVerifiedPerson` hands back the
160
+ * transport scope when no person is carried) is not one: its result is never
161
+ * undefined, so testing it says nothing about the claim. */
162
+ const CLAIM_RESOLVER = /^resolve(Door)?Verified(Org|Actor)$|Claim$/;
163
+ /** Is `e` the claim itself, or a value that is undefined exactly when the claim
164
+ * is absent? The claim; a property / element chain on it; a ternary, `??`, `||`
165
+ * or `&&` with a claim-derived operand; a claim resolver called on it. A value
166
+ * merely COMPUTED FROM the claim (a row read by its agent ID) is not: its absence
167
+ * is not the claim's absence. */
168
+ function isClaimValue(e, tainted) {
169
+ const x = unwrap(e);
170
+ if (ts.isIdentifier(x))
171
+ return tainted.has(x.text) || isClaimName(x.text);
172
+ if (ts.isPropertyAccessExpression(x))
173
+ return (/^verified(Actor|Org|Person)$/.test(x.name.text) ||
174
+ isClaimValue(x.expression, tainted));
175
+ if (ts.isElementAccessExpression(x))
176
+ return isClaimValue(x.expression, tainted);
177
+ if (ts.isConditionalExpression(x))
178
+ return (isClaimValue(x.whenTrue, tainted) || isClaimValue(x.whenFalse, tainted));
179
+ if (ts.isBinaryExpression(x)) {
180
+ const op = x.operatorToken.kind;
181
+ if (op === ts.SyntaxKind.QuestionQuestionToken ||
182
+ op === ts.SyntaxKind.BarBarToken ||
183
+ op === ts.SyntaxKind.AmpersandAmpersandToken)
184
+ return isClaimValue(x.left, tainted) || isClaimValue(x.right, tainted);
185
+ return false;
186
+ }
187
+ if (ts.isCallExpression(x)) {
188
+ const callee = unwrap(x.expression);
189
+ const name = ts.isIdentifier(callee)
190
+ ? callee.text
191
+ : ts.isPropertyAccessExpression(callee)
192
+ ? callee.name.text
193
+ : undefined;
194
+ return (name !== undefined &&
195
+ CLAIM_RESOLVER.test(name) &&
196
+ x.arguments.some((a) => isClaimValue(a, tainted)));
197
+ }
198
+ return false;
199
+ }
200
+ function claimDerivedLocals(fn) {
201
+ const tainted = new Set();
202
+ if (ts.isFunctionLike(fn) &&
203
+ "parameters" in fn &&
204
+ Array.isArray(fn.parameters))
205
+ for (const prm of fn.parameters)
206
+ if (ts.isIdentifier(prm.name) && isClaimName(prm.name.text))
207
+ tainted.add(prm.name.text);
208
+ for (let pass = 0; pass < 4; pass++) {
209
+ let grew = false;
210
+ walk(fn, (x) => {
211
+ if (!ts.isVariableDeclaration(x) || !x.initializer)
212
+ return;
213
+ const names = [];
214
+ if (ts.isIdentifier(x.name))
215
+ names.push(x.name.text);
216
+ else if (ts.isObjectBindingPattern(x.name))
217
+ for (const e of x.name.elements)
218
+ if (ts.isIdentifier(e.name))
219
+ names.push(e.name.text);
220
+ const fromDestructure = ts.isObjectBindingPattern(x.name) &&
221
+ x.name.elements.some((e) => ts.isIdentifier(e.name) &&
222
+ /^verified(Actor|Org|Person)$/.test(e.name.text));
223
+ if (fromDestructure || isClaimValue(x.initializer, tainted))
224
+ for (const n of names)
225
+ if (!tainted.has(n)) {
226
+ tainted.add(n);
227
+ grew = true;
228
+ }
229
+ });
230
+ if (!grew)
231
+ break;
232
+ }
233
+ return tainted;
234
+ }
235
+ /** Classify ONE atom that holds with truth value `holds` in the path. */
236
+ function classify(atom, holds, claimDerived) {
237
+ const a = unwrap(atom);
238
+ if (ts.isPrefixUnaryExpression(a) &&
239
+ a.operator === ts.SyntaxKind.ExclamationToken)
240
+ return classify(a.operand, !holds, claimDerived);
241
+ if (ts.isBinaryExpression(a)) {
242
+ const op = a.operatorToken.kind;
243
+ const eq = op === ts.SyntaxKind.EqualsEqualsEqualsToken ||
244
+ op === ts.SyntaxKind.EqualsEqualsToken;
245
+ const ne = op === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
246
+ op === ts.SyntaxKind.ExclamationEqualsToken;
247
+ if (eq || ne) {
248
+ const l = unwrap(a.left);
249
+ const r = unwrap(a.right);
250
+ const nullish = (e) => (ts.isIdentifier(e) && e.text === "undefined") ||
251
+ e.kind === ts.SyntaxKind.NullKeyword;
252
+ const bool = (e) => e.kind === ts.SyntaxKind.TrueKeyword
253
+ ? true
254
+ : e.kind === ts.SyntaxKind.FalseKeyword
255
+ ? false
256
+ : undefined;
257
+ if (bool(r) !== undefined || bool(l) !== undefined) {
258
+ const lit = (bool(r) ?? bool(l));
259
+ const subject = bool(r) !== undefined ? l : r;
260
+ return classify(subject, (eq ? holds : !holds) === lit, claimDerived);
261
+ }
262
+ if (nullish(r) || nullish(l)) {
263
+ const subject = nullish(r) ? l : r;
264
+ if (claimDerived(subject)) {
265
+ // X === undefined holds => absent; X !== undefined holds => present
266
+ const absent = eq ? holds : !holds;
267
+ return absent ? "claim-absent" : "claim-present";
268
+ }
269
+ }
270
+ if (ts.isPropertyAccessExpression(l) &&
271
+ l.name.text === "masterSource" &&
272
+ ts.isStringLiteralLike(r) &&
273
+ r.text === "service-account") {
274
+ const is = eq ? holds : !holds;
275
+ return is ? "service" : "not-service";
276
+ }
277
+ }
278
+ return "other";
279
+ }
280
+ const text = a.getText();
281
+ if (claimDerived(a))
282
+ return holds ? "claim-present" : "claim-absent";
283
+ if (SERVICE_STATE.test(text))
284
+ return holds ? "service" : "not-service";
285
+ return "other";
286
+ }
287
+ /** Split `e` into the atoms that ALL hold when `e` is true (&&) or when it is
288
+ * false (||); anything else is one opaque atom. */
289
+ function atomsOf(e, holds) {
290
+ const x = unwrap(e);
291
+ if (ts.isBinaryExpression(x)) {
292
+ const op = x.operatorToken.kind;
293
+ if (holds && op === ts.SyntaxKind.AmpersandAmpersandToken)
294
+ return [...atomsOf(x.left, true), ...atomsOf(x.right, true)];
295
+ if (!holds && op === ts.SyntaxKind.BarBarToken)
296
+ return [...atomsOf(x.left, false), ...atomsOf(x.right, false)];
297
+ }
298
+ if (ts.isPrefixUnaryExpression(x) &&
299
+ x.operator === ts.SyntaxKind.ExclamationToken)
300
+ return atomsOf(x.operand, !holds);
301
+ return [{ atom: x, holds }];
302
+ }
303
+ function pathFacts(t, fn, claimDerived) {
304
+ const facts = new Set();
305
+ for (let child = t, up = t.parent; up && child !== fn; child = up, up = up.parent) {
306
+ if (ts.isIfStatement(up)) {
307
+ const inThen = up.thenStatement === child;
308
+ const inElse = up.elseStatement === child;
309
+ if (!inThen && !inElse)
310
+ continue;
311
+ for (const { atom, holds } of atomsOf(up.expression, inThen))
312
+ facts.add(classify(atom, holds, claimDerived));
313
+ }
314
+ }
315
+ return facts;
316
+ }
317
+ function refusesClaimless(closure) {
318
+ for (const fn of closure.nodes) {
319
+ const tainted = claimDerivedLocals(fn);
320
+ const claimDerived = (e) => isClaimValue(e, tainted);
321
+ let found = false;
322
+ walk(fn, (x) => {
323
+ if (found)
324
+ return;
325
+ const refusal = ts.isThrowStatement(x) ||
326
+ (ts.isReturnStatement(x) &&
327
+ x.expression !== undefined &&
328
+ REFUSAL_RETURN.test(x.expression.getText()));
329
+ if (!refusal)
330
+ return;
331
+ const facts = pathFacts(x, fn, claimDerived);
332
+ // The path must say "no claim", optionally "and the caller is the service
333
+ // account", and nothing else: a refusal that also needs some other
334
+ // condition (a flag, a name, a present claim) refuses a narrower call.
335
+ if (!facts.has("claim-absent"))
336
+ return;
337
+ for (const f of facts)
338
+ if (f !== "claim-absent" && f !== "service")
339
+ return;
340
+ found = true;
341
+ });
342
+ if (found)
343
+ return true;
344
+ }
345
+ return false;
346
+ }
347
+ const isConvexFile = (f) => /(^|\/)convex\//.test(f.rel);
348
+ function statementOf(n) {
349
+ let x = n;
350
+ while (x.parent && !ts.isSourceFile(x.parent))
351
+ x = x.parent;
352
+ return x;
353
+ }
354
+ export function scanR54(files) {
355
+ const scan = emptyScan();
356
+ scan.notApplicable = [];
357
+ scan.doors = 0;
358
+ const ps = files
359
+ .filter((f) => isJudgeable(f) && isConvexFile(f))
360
+ .map(parseFile);
361
+ const idx = indexFunctions(ps);
362
+ const consts = topLevelConsts(ps);
363
+ for (const p of ps) {
364
+ for (const r of registrations(p)) {
365
+ if (!PUBLIC_BUILDERS.has(r.builder))
366
+ continue;
367
+ scan.doors++;
368
+ const site = (note) => ({
369
+ rel: r.rel,
370
+ n: r.line,
371
+ note: `${r.name}: ${note}`,
372
+ });
373
+ const stmt = statementOf(r.handler);
374
+ const fullText = p.sf.text.slice(stmt.getFullStart(), stmt.getEnd());
375
+ const closure = closureOf(r.handler, idx);
376
+ const args = readArgs(r, p, consts);
377
+ const marker = FLEET_WIDE_MARKER.exec(fullText);
378
+ if (marker) {
379
+ scan.conform.push(site(`exit (c): declared fleet-wide — ${marker[1].trim().slice(0, 160)}`));
380
+ continue;
381
+ }
382
+ const serviceOnly = closure.calls.has("requireServiceAccount") ||
383
+ closureHasMasterOnlyResolved(closure);
384
+ if (serviceOnly) {
385
+ scan.conform.push(site("exit (a): service-account-only by purpose (requireServiceAccount / requireResolvedCaller masterOnly+mcpBoundOnly)"));
386
+ continue;
387
+ }
388
+ const reaches = closure.calls.has(SCOPE_RESOLVER);
389
+ if (!reaches) {
390
+ const session = [...closure.calls].some((c) => SESSION_GATES.has(c)) ||
391
+ closureReadsIdentity(closure);
392
+ if (session) {
393
+ scan.conform.push(site("exit (b): callers resolve only through a verified session; the door never reaches the organisation-scope resolver"));
394
+ continue;
395
+ }
396
+ if (r.builder === "action" && closureCallsRun(closure)) {
397
+ scan.cnj.push(site("caller scope arrives through a run call (ctx.runQuery / ctx.runMutation); its resolution is not read here"));
398
+ continue;
399
+ }
400
+ const foreign = [...closure.undefinedCalls].filter((c) => RESOLVER_SHAPED.test(c));
401
+ if (foreign.length) {
402
+ scan.cnj.push(site(`caller resolution delegated to ${foreign.join(", ")}, defined outside the scanned tree`));
403
+ continue;
404
+ }
405
+ scan.notApplicable.push(`${r.rel}:${r.name}`);
406
+ continue;
407
+ }
408
+ // The door reaches the scope resolver: the service account is master here.
409
+ if (args.claims.length === 0) {
410
+ if (args.unresolved.length) {
411
+ scan.cnj.push(site(`args spread from ${args.unresolved.join(", ")}, which the scan cannot find; a claim argument may be among them`));
412
+ continue;
413
+ }
414
+ scan.violate.push(site("(A) resolves its caller through the organisation-scope resolver (the service account is master here) and declares no claim argument (verifiedActor / verifiedOrg / verifiedPerson)"));
415
+ continue;
416
+ }
417
+ if (refusesClaimless(closure)) {
418
+ scan.conform.push(site(`claim-bound: accepts ${args.claims.join(", ")} and refuses the claimless service-account call`));
419
+ continue;
420
+ }
421
+ scan.violate.push(site(`(B) the claim is optional: accepts ${args.claims.join(", ")} but no branch refuses the claimless service-account call, so the absence of a claim leaves the call master`));
422
+ }
423
+ // A door registered through a wrapper is not read: say so, never skip it.
424
+ for (const st of p.sf.statements) {
425
+ if (!ts.isVariableStatement(st))
426
+ continue;
427
+ for (const d of st.declarationList.declarations) {
428
+ if (!d.initializer || !ts.isIdentifier(d.name))
429
+ continue;
430
+ const init = unwrap(d.initializer);
431
+ if (!ts.isCallExpression(init))
432
+ continue;
433
+ const callee = init.expression;
434
+ if (!ts.isIdentifier(callee))
435
+ continue;
436
+ if (/^(query|mutation|action|internal\w+|httpAction)$/.test(callee.text))
437
+ continue;
438
+ const cfg = init.arguments[0];
439
+ if (!cfg || !ts.isObjectLiteralExpression(cfg))
440
+ continue;
441
+ const hasHandler = cfg.properties.some((x) => x.name && ts.isIdentifier(x.name) && x.name.text === "handler");
442
+ if (!hasHandler)
443
+ continue;
444
+ scan.cnj.push({
445
+ rel: p.rel,
446
+ n: lineOf(d),
447
+ note: `${d.name.text}: registered through the wrapper ${callee.text}; the caller resolution lives in the wrapper's context, which is not read here`,
448
+ });
449
+ }
450
+ }
451
+ }
452
+ return scan;
453
+ }
454
+ /** `ctx.runQuery(...)` / `ctx.runMutation(...)` are method calls, which
455
+ * calledNames (plain identifiers only) does not see. */
456
+ function closureCallsRun(c) {
457
+ return c.nodes.some((n) => /\.run(Query|Mutation)\s*\(/.test(n.getText()));
458
+ }
459
+ /** `ctx.auth.getUserIdentity()` is a method call; calledNames sees plain
460
+ * identifiers only, so read the closure's text for the identity read. */
461
+ function closureReadsIdentity(c) {
462
+ return c.nodes.some((n) => /\.getUserIdentity\s*\(/.test(n.getText()));
463
+ }
464
+ function closureHasMasterOnlyResolved(c) {
465
+ for (const n of c.nodes) {
466
+ let hit = false;
467
+ walk(n, (x) => {
468
+ if (ts.isCallExpression(x) &&
469
+ ts.isIdentifier(unwrap(x.expression)) &&
470
+ unwrap(x.expression).text === "requireResolvedCaller") {
471
+ const t = x.arguments.map((a) => a.getText()).join(",");
472
+ if (/masterOnly/.test(t) && /mcpBoundOnly/.test(t))
473
+ hit = true;
474
+ }
475
+ });
476
+ if (hit)
477
+ return true;
478
+ }
479
+ return false;
480
+ }
481
+ export const R54 = {
482
+ id: R54_ID,
483
+ run: (tree) => {
484
+ const s = scanR54(tree.files);
485
+ const empty = `R-54: no applicable site — ${s.doors} public door(s) read in ${tree.files.filter((f) => isJudgeable(f) && isConvexFile(f)).length} convex file(s); ${s.notApplicable.length} resolve no caller at all (CR-1 / R-6 govern them) and none reaches the organisation-scope resolver or an exit. NOT a clean bill.`;
486
+ return decide("R-54", R54_ID, R54_CHECK, s, empty);
487
+ },
488
+ };
489
+ export const SOURCE_SERVICE_ACCOUNT_CLAIM_DETECTORS = [
490
+ { detector: R54, ruleId: "R-54", standardPin: R54_STANDARD_PIN },
491
+ ];
@@ -131,20 +131,33 @@ export function scanR18(files) {
131
131
  // them into one mutation "would game the regex without changing atomicity"
132
132
  // (okfBundleNode importOkfBundle shape).
133
133
  // An action that would be flagged non-conforming may carry an `atomicity-exception:`
134
- // marker naming EXACTLY ONE of those two shapes. Per Eta's three conditions:
135
- // COND 1 — the disposition is COULD-NOT-JUDGE, never conform. A marker records a
136
- // CLAIM that reaches a human reviewer; it never closes the rule green (same as
137
- // the R-51 marker, #141).
138
- // COND 2 — the marker MUST name which shape it claims, because the two are checked
139
- // differently and the reviewer must know which question to ask.
134
+ // marker naming EXACTLY ONE of those two shapes. Per Eta's three conditions
135
+ // (superseded on the disposition axis by task k179jnhx, §23 — judgement retired
136
+ // as an acceptance kind, 2026-09-02):
137
+ // COND 1 — originally "the disposition is COULD-NOT-JUDGE, never conform: a
138
+ // marker records a CLAIM that reaches a human reviewer" (Eta ruling
139
+ // 2026-08-31, mirroring the R-51 marker #141). The standard's §23 rewrite
140
+ // retires that disposition: a well-formed, COMPLETE claim (COND 2 + COND 3
141
+ // both satisfied — the shape named, and its checkable sub-conditions all
142
+ // present) IS the written justification the marker was always meant to
143
+ // record, exactly as JUSTIFIÉ:/JUSTIFIED: is elsewhere — so it now decides
144
+ // mechanically to PRESENT, never could-not-judge. An INCOMPLETE claim still
145
+ // buys nothing (COND 3, unchanged): the site stays non-conforming until the
146
+ // claim is well-formed.
147
+ // COND 2 — the marker MUST name which shape it claims, because the three are
148
+ // checked differently and the check must know which sub-conditions to ask for.
140
149
  // COND 3 — the dangerous shape is (2): "unbounded loop, each write independently
141
150
  // atomic" is a sentence anyone can write about a split invariant. So shape (2)
142
151
  // is only honoured when the region ALSO names the loop (for/for-of/.forEach/.map)
143
152
  // AND states the independence (no invariant spans two writes). A shape-(2) marker
144
153
  // that names neither the loop nor the independence is INCOMPLETE — it does not buy
145
- // could-not-judge; the site stays non-conforming until the claim is well-formed.
146
- // A well-formed marker → could-not-judge (the reviewer verifies the per-site claim).
147
- // No marker, an unknown shape, or an incomplete shape-(2) claim → non-conforming.
154
+ // present; the site stays non-conforming until the claim is well-formed.
155
+ // A well-formed, COMPLETE marker → present (the claim itself is the recorded
156
+ // justification; the control decides on the claim's structural completeness,
157
+ // never on a person's opinion of whether it is sound).
158
+ // No marker, an unknown shape, or an incomplete claim → non-conforming.
159
+ // could-not-judge is reserved for a site the instrument could not read (never
160
+ // reached here — every site below either parsed a marker or did not).
148
161
  // ---------------------------------------------------------------------------
149
162
  const ACTION_OPEN = /export\s+const\s+(\w+)\s*=\s*action\s*\(/;
150
163
  const NEXT_EXPORT = /export\s+const\s+\w+\s*=/;
@@ -232,16 +245,16 @@ export function scanR29(files) {
232
245
  ? shapeReingest
233
246
  : false;
234
247
  if (shape && shapeComplete) {
235
- // COND 1: a well-formed marker yields COULD-NOT-JUDGE, never conform —
236
- // the claim is deferred to the reviewer, who verifies it per site.
237
- scan.cnj.push({
248
+ // COND 1 (§23 rewrite, task k179jnhx): a well-formed, COMPLETE marker
249
+ // is itself the written justification — decided mechanically, present.
250
+ scan.conform.push({
238
251
  rel: f.rel,
239
252
  n: firstMut || openLine,
240
- note: `action ${inAction} issues ${count} ctx.runMutation calls AND carries an atomicity-exception marker claiming shape "${shape}" — ${shape === SHAPE_PER_ITEM_LOOP
253
+ note: `action ${inAction} issues ${count} ctx.runMutation calls AND carries a COMPLETE atomicity-exception marker claiming shape "${shape}" — ${shape === SHAPE_PER_ITEM_LOOP
241
254
  ? "the region names its loop and states the per-item writes are independently atomic (no invariant spans two)"
242
255
  : shape === SHAPE_GATE_REINGEST
243
256
  ? "a leading gate runMutation, a supersede/dedup keyed on a stable namespace, and a recovering re-insert loop, with the convergence stated explicitly — a re-ingest of the same stable key converges on one current set"
244
- : "exactly two runMutations, the first a gate (require*/assert*/bind*) preceding a single atomic write, which R-29's <=1 bar has no room for"}. R-29 refuses (a reviewer verifies the claim per site) — never a silent green (Eta ruling 2026-08-31)`,
257
+ : "exactly two runMutations, the first a gate (require*/assert*/bind*) preceding a single atomic write, which R-29's <=1 bar has no room for"}. The marker's structural completeness IS the written justification (§23) — decided by the claim's presence, never a person's opinion of it`,
245
258
  });
246
259
  }
247
260
  else if (shape === SHAPE_GATE_REINGEST) {
@@ -1,6 +1,6 @@
1
1
  import type { Detector, DetectorResult } from "../../engine-types.js";
2
2
  /** The vendored-fixture basename used as the anchor file for every doc site. */
3
- export declare const STANDARD_FILE = "backend-standard-4bcd605.md";
3
+ export declare const STANDARD_FILE = "backend-standard-bacc54a.md";
4
4
  export declare const VENDORED_STANDARD_PATH: string;
5
5
  /** The standard all four are pinned against (short hash a9d7877b). */
6
6
  export declare const STANDARD_DOC_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) \u00A714/\u00A711/\u00A710/\u00A73";
@@ -2,7 +2,7 @@
2
2
  // detectors that read THE STANDARD ITSELF as their material — R-42, R-15, R-32,
3
3
  // R-39. Unlike the source-tree detectors (which read convex/ via `tree.files`)
4
4
  // and the oracle detectors (which read the CSV), these read the VENDORED standard
5
- // fixture `fixtures/backend-standard-4bcd605.md` — the material at the pin. The
5
+ // fixture `fixtures/backend-standard-bacc54a.md` — the material at the pin. The
6
6
  // engine's contract types are DERIVED from engine-types.ts, never re-spelled.
7
7
  //
8
8
  // THE LAW (the one that caught R-5): every predicate is derived from the ACTUAL
@@ -12,8 +12,9 @@
12
12
  import { readFileSync } from "node:fs";
13
13
  import { dirname, join } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
+ import { itemCount, withCount } from "../../violation-count.js";
15
16
  /** The vendored-fixture basename used as the anchor file for every doc site. */
16
- export const STANDARD_FILE = "backend-standard-4bcd605.md";
17
+ export const STANDARD_FILE = "backend-standard-bacc54a.md";
17
18
  /** The vendored standard at the pin — DATA on the same footing as the oracle CSV.
18
19
  * Resolved locally (not via coverage.ts) so this module carries no import cycle
19
20
  * with registry.ts. Same file coverage.ts's VENDORED_STANDARD_PATH points at. */
@@ -136,12 +137,12 @@ export function decideDoc(ruleId, id, check, scan, emptyReason) {
136
137
  .map((x) => `${STANDARD_FILE}:${x.n} (${x.note})`)
137
138
  .join("; ");
138
139
  if (nV > 0) {
139
- return {
140
+ return withCount({
140
141
  id,
141
142
  anchor: `${STANDARD_FILE}:${scan.violate[0].n}`,
142
143
  state: "non-conforming",
143
144
  verdict: `${ruleId}: ${nV} STATIC violation(s) in the standard document (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform: ${fmt(scan.conform)}.` : ""}`,
144
- };
145
+ }, itemCount(nV));
145
146
  }
146
147
  if (nJ > 0) {
147
148
  const reason = `${ruleId}: static half clean (${nC} conform); ${nJ} site(s) owe a human/gate verdict — ${fmt(scan.cnj)}. This is NOT a clean bill: GREEN is the gate half surfaced per site, not the detection running. ${breakdown}`;
@@ -24,6 +24,7 @@
24
24
  import { readFileSync } from "node:fs";
25
25
  import { dirname, join } from "node:path";
26
26
  import { fileURLToPath } from "node:url";
27
+ import { itemCount, withCount } from "../../violation-count.js";
27
28
  import { readStandard, STANDARD_FILE, toLines } from "./_doc.js";
28
29
  /** prefix → { the sibling fixture basename, its recorded upstream blob hash }.
29
30
  * The blob hash is the PINS.txt record — a vendored document that cannot be
@@ -154,12 +155,12 @@ export function decideR41(r) {
154
155
  const deadList = r.dead
155
156
  .map((c) => `${c.id} (cited ${site(c)}) not defined in ${SIBLING[c.prefix].file}`)
156
157
  .join("; ");
157
- return {
158
+ return withCount({
158
159
  id: R41_ID,
159
160
  anchor: site(r.dead[0]),
160
161
  state: "non-conforming",
161
162
  verdict: `R-41: ${r.dead.length} DEAD POINTER(S) — a cited frontier id resolves to no definition in its sibling (a dead pointer is a red, not a note). ${counts} Dead: ${deadList}. Cited: ${citedList}.`,
162
- };
163
+ }, itemCount(r.dead.length));
163
164
  }
164
165
  return {
165
166
  id: R41_ID,
@@ -37,6 +37,7 @@
37
37
  // (non-conforming / could-not-judge / present / absent) — no fifth state,
38
38
  // no second mechanism: the detector reads the tree the engine hands it.
39
39
  import * as ts from "typescript";
40
+ import { siteCount, withCount } from "../../violation-count.js";
40
41
  /** The standard this detector is pinned against (the re-pinned instrument pin). */
41
42
  export const R46_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §16 R-46";
42
43
  // ---------------------------------------------------------------------------
@@ -456,12 +457,12 @@ export function decideR46(cov) {
456
457
  }
457
458
  if (missing.length > 0) {
458
459
  const first = missing[0];
459
- return {
460
+ return withCount({
460
461
  id: R46_ID,
461
462
  anchor: `${first.rel}:${first.line}`,
462
463
  state: "non-conforming",
463
464
  verdict: `R-46: ${missing.length} returns validator(s) enumerate a stored document but OMIT a schema-declared field with NO written projection reason (${R46_CHECK}). ${breakdown}\n=== coverage inventory ===\n${inventory}\n=== end inventory ===`,
464
- };
465
+ }, siteCount(missing));
465
466
  }
466
467
  if (cov.findings.length > 0) {
467
468
  const first = cov.findings[0];