@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,6 +1,16 @@
1
1
  import type { Detector } from "../../engine-types.js";
2
- import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
2
+ import { type Scan, type Site, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
3
+ export declare const SCOPE_REFUSED_RETURN: RegExp;
4
+ export declare function noOrgEarlyReturn(text: string): boolean;
5
+ /** R-50's scan plus the public queries it found OUTSIDE its scope (no org-throw,
6
+ * no resolver, no marker — standard 4bcd605 :653). The out-of-scope list never
7
+ * reaches a verdict; it only words the empty-subject abstain truthfully. */
8
+ export interface R50Scan {
9
+ scan: Scan;
10
+ outOfScope: Site[];
11
+ }
3
12
  export declare function scanR50(files: SrcFile[]): Scan;
13
+ export declare function scanR50Detailed(files: SrcFile[]): R50Scan;
4
14
  export declare const R50: Detector;
5
15
  /** In the form the orchestrator spreads into REGISTRY. */
6
16
  export declare const SOURCE_ISOLATION_DETECTORS: SourceDetectorEntry[];
@@ -1,4 +1,4 @@
1
- import { code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
1
+ import { code, decide, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, } from "./_scan.js";
2
2
  // A public reactive read is DEFINED, not read. R-50 governs a query DEFINITION —
3
3
  // `export const NAME = <builder>({ ... })` — never a `ctx.db.query(...)` table READ
4
4
  // inside a handler (the pre-fix opener `/\bquery\(/` matched `.query(` reads
@@ -194,6 +194,54 @@ function namedValueDomination(before) {
194
194
  }
195
195
  return false;
196
196
  }
197
+ // Form C (VP shape — the typed-empty PRE-ORG path). `withOrgScope(ctx, {
198
+ // refuseWithoutThrow: true })` marks a signed-in caller with no organisation as
199
+ // `scope.refused` instead of throwing; the handler then answers that caller with a
200
+ // typed value BEFORE any org-throw:
201
+ // const scope = await withOrgScope(ctx, { refuseWithoutThrow: true });
202
+ // if (scope.refused) { return null; } // or `return [];` / `return { … }`
203
+ // if (!scope.isMaster && scope.orgSlug === null) throw new ConvexError("RBAC_DENIED…");
204
+ // The throw below the guard is then unreachable for the no-org caller R-50 protects
205
+ // (a mounted, subscribed shell); it fires only for a caller with no identity at all.
206
+ // NARROW on purpose: the guard must test a `.refused` flag (bare or on a binding)
207
+ // and its body must be a bare `return` of `null`, an empty array, or an object
208
+ // literal — never `return <identifier>`, never a call, never a throw.
209
+ export const SCOPE_REFUSED_RETURN = /if\s*\(\s*(?:[A-Za-z_$][\w$]*\.)?refused\s*\)\s*(?:\{\s*)?return\s+(?:null\b|\[\s*\]|\{)/;
210
+ // Form D (vCRM shape — an EARLY GUARD that answers the no-org caller with a typed
211
+ // value before the org resolver/throw is reached):
212
+ // if (!ctx.workspaceId) { return emptySummary(args.presetKey, CURRENCY); } reports.ts
213
+ // const workspaceId = requireWorkspaceId(ctx.workspaceId); (now unreachable on no-org)
214
+ // if (!ctx.workspaceId || !company || company.workspaceId !== ctx.workspaceId) { return []; } activities.ts
215
+ // NARROW on purpose: the condition must contain a no-org NEGATION as a disjunct and
216
+ // NO `&&` anywhere (a conjunction could leave the no-org caller to fall through),
217
+ // and the guarded statement must be a `return` of `null`, `[]`, an object literal,
218
+ // or a CALL whose name marks the empty/unauthorised case — never `return <ident>`,
219
+ // never an arbitrary call, never a throw.
220
+ const NO_ORG_NEGATION = /!\s*(?:scope\.|ctx\.[A-Za-z0-9_.]*)?(?:org|orgId|orgSlug|workspaceId|workspace)\b|\b(?:org|orgId|orgSlug|workspaceId)\s*(?:===|==)\s*(?:null|undefined)\b/;
221
+ const EARLY_TYPED_RETURN = /^\s*\{?\s*return\s+(?:null\b|\[\s*\]|\{|(?:[A-Za-z0-9_$]+\.)*[A-Za-z0-9_$]*(?:[Ee]mpty|[Uu]nauthori[sz]ed|NoOrg|NoWorkspace)[A-Za-z0-9_$]*\s*\()/;
222
+ export function noOrgEarlyReturn(text) {
223
+ const opener = /\bif\s*\(/g;
224
+ let m;
225
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
226
+ while ((m = opener.exec(text)) !== null) {
227
+ let depth = 1;
228
+ let j = m.index + m[0].length;
229
+ for (; j < text.length && depth > 0; j++) {
230
+ if (text[j] === "(")
231
+ depth += 1;
232
+ else if (text[j] === ")")
233
+ depth -= 1;
234
+ }
235
+ if (depth !== 0)
236
+ return false;
237
+ const cond = text.slice(m.index + m[0].length, j - 1);
238
+ if (cond.includes("&&") || !NO_ORG_NEGATION.test(cond))
239
+ continue;
240
+ if (EARLY_TYPED_RETURN.test(text.slice(j)))
241
+ return true;
242
+ }
243
+ return false;
244
+ }
197
245
  function throwDomination(blockText) {
198
246
  const t1 = ORG_THROW.exec(blockText);
199
247
  const t2 = ORG_THROW_HELPER.exec(blockText);
@@ -204,6 +252,12 @@ function throwDomination(blockText) {
204
252
  // Form B (CRM helper-returned value, guard bound to the value) — decisive on its own.
205
253
  if (namedValueDomination(before))
206
254
  return "dominated";
255
+ // Form C (VP pre-org `scope.refused` typed-empty return) — decisive on its own.
256
+ if (SCOPE_REFUSED_RETURN.test(before))
257
+ return "dominated";
258
+ // Form D (early no-org guard returning a typed value) — decisive on its own.
259
+ if (noOrgEarlyReturn(before))
260
+ return "dominated";
207
261
  // Form A (inline typed-empty object literal + a no-org guard).
208
262
  if (!TYPED_EMPTY_RETURN.test(before))
209
263
  return "none";
@@ -232,6 +286,9 @@ function queryBlock(lines, i) {
232
286
  return { lines: body, unterminated: seenOpen };
233
287
  }
234
288
  export function scanR50(files) {
289
+ return scanR50Detailed(files).scan;
290
+ }
291
+ export function scanR50Detailed(files) {
235
292
  const scan = emptyScan();
236
293
  // Pass 1: discover the tree's query/mutation builders so a customQuery wrapper
237
294
  // (authedQuery, orgQuery, …) is recognised, not just the bare `query` base.
@@ -333,16 +390,37 @@ export function scanR50(files) {
333
390
  const throwsOnNoOrg = ORG_THROW.test(blockText) || ORG_THROW_HELPER.test(blockText);
334
391
  const hasResolver = SAFE_RESOLVER.test(blockText);
335
392
  const hasMarker = CONTRACT_MARKER.test(blockTextWithComments);
393
+ if (!throwsOnNoOrg &&
394
+ !hasResolver &&
395
+ !hasMarker &&
396
+ noOrgEarlyReturn(blockText)) {
397
+ // No throw at all, and the no-org caller is answered by an early guard
398
+ // with a typed value: the standard's green verbatim (4bcd605 :653, "returns
399
+ // a typed empty/unauthorised context on the no-org path"). An R-50
400
+ // subject that conforms — not an out-of-scope read.
401
+ scan.conform.push({
402
+ rel: f.rel,
403
+ n: block.lines[0].n,
404
+ note: `${fn}: public query answers the no-org caller with a typed value from an early no-org guard (no throw anywhere in the handler) — the subscribing client receives a shape`,
405
+ });
406
+ continue;
407
+ }
336
408
  if (!throwsOnNoOrg && !hasResolver && !hasMarker) {
337
- // query does neither org-isolation throw nor fail-closed resolve nor
338
- // declare a contract — not R-50's subject (too ambiguous to judge
339
- // mechanically; never a violation). Recorded so a tree whose ENTIRE
340
- // public-read surface is bare is judged (CNJ-with-sites) rather than
341
- // falsely abstained as "no public query matched".
409
+ // A query that does neither an org-isolation throw nor a fail-closed
410
+ // resolve nor declares a contract is OUT OF R-50's SCOPE — the pinned
411
+ // standard says so in its detector spec (backend-standard-4bcd605.md
412
+ // :653, "*out of scope:* … a query that does neither org-throw nor
413
+ // fail-closed resolve (not this rule's subject)"). R-50 asks how a
414
+ // REFUSAL is shaped; a read with no guard has no refusal to shape.
415
+ // Whether such a read resolves its caller AT ALL is CR-1's question
416
+ // (src/caller-resolution.ts — an unresolved public read is a CR-1
417
+ // NO-IDENTITY violation and drives the exit code), so the instrument
418
+ // still goes red on it, under the control that owns it. Counted only
419
+ // to name the empty-subject abstain truthfully.
342
420
  ungated.push({
343
421
  rel: f.rel,
344
422
  n: block.lines[0].n,
345
- note: `${fn}: public query with no org-throw, no fail-closed resolver, and no isolation-contract marker — an UNMARKED public read. No org-isolation handling and no client contract is visible in convex/; an instrument that abstains here protects nothing (a fully-unmarked read is exactly the surface a subscribing client can hit before an org exists). NON-CONFORMING until the author resolves org via a fail-closed non-throwing path (\`withOrgScope\`), returns a typed empty/unauthorized shape, or records an isolation-contract marker`,
423
+ note: `${fn}: public query with no org-throw, no fail-closed resolver, no isolation-contract marker — not an R-50 subject (standard 4bcd605 :653); caller resolution is CR-1's control`,
346
424
  });
347
425
  continue;
348
426
  }
@@ -403,25 +481,28 @@ export function scanR50(files) {
403
481
  }
404
482
  }
405
483
  }
406
- // A tree whose ENTIRE public-read surface is ungated (no throw / resolver / marker
407
- // on ANY query) leaves the scan otherwise empty. The legacy abstain then claimed
408
- // "no public query matched" — FALSE when bare public queries exist, and a silent
409
- // pass over a fully-ungated backend. Surface those reads as could-not-judge sites
410
- // so the verdict is CNJ-with-sites, not a misleading empty abstain. Gated on "scan
411
- // otherwise empty" so a tree that already produced subjects (VP: 20 conform) is
412
- // byte-for-byte unchanged.
413
- // An UNMARKED bare public read (no throw / resolver / marker) is NON-CONFORMING,
414
- // never an abstention — whether the scan is otherwise empty (a fully-unmarked
415
- // backend) OR mixed (bare reads alongside gated subjects). Laurent doctrine: "an
416
- // instrument that cannot go red protects nothing; CNJ is for UNREADABLE input
417
- // only." A bare read is fully READABLE — its two absences (no isolation, no
418
- // contract) are the defect, not a could-not-read. Push to violate; the author's
419
- // exit is a resolver, a typed-empty return, or a recorded isolation-contract marker.
420
- for (const u of ungated)
421
- scan.violate.push(u);
422
- return scan;
484
+ // Unmarked public reads are NOT pushed anywhere: they are outside R-50's scope
485
+ // (standard 4bcd605 :653). Until 0.1.16 they were pushed to `violate` (the Day-158
486
+ // "an instrument that cannot go red protects nothing" correction), which predates
487
+ // CR-1; CR-1 now carries exactly that obligation for a read that resolves no
488
+ // caller, so keeping it here too double-counted it under a rule whose text
489
+ // excludes it. A tree whose ONLY public reads are unmarked abstains with the
490
+ // empty-subject reason below, naming the count — never a clean bill.
491
+ return { scan, outOfScope: ungated };
423
492
  }
424
- export const R50 = srcDetector("R-50", "R-50 org isolation on a reactively-subscribed read returns a typed empty/unauthorized result, never an uncaught throw", "a public query that resolves org via a fail-closed non-throwing path or documents its client contract — it never throws on the missing-org path a subscribing client cannot catch", scanR50, "R-50: no public query matched in the scanned tree — no reactive read to certify. NOT a clean bill.");
493
+ const R50_ID = "R-50 org isolation on a reactively-subscribed read returns a typed empty/unauthorized result, never an uncaught throw";
494
+ const R50_CHECK = "a public query that resolves org via a fail-closed non-throwing path or documents its client contract — it never throws on the missing-org path a subscribing client cannot catch";
495
+ export const R50 = {
496
+ id: R50_ID,
497
+ run: (tree) => {
498
+ const { scan, outOfScope } = scanR50Detailed(tree.files);
499
+ const n = outOfScope.length;
500
+ const empty = n > 0
501
+ ? `R-50: no R-50 subject in the scanned tree — ${n} public query(ies) matched, none org-throws, resolves via withOrgScope, or carries an isolation-contract marker (out of scope per standard 4bcd605 :653; caller resolution is CR-1's control). NOT a clean bill.`
502
+ : "R-50: no public query matched in the scanned tree — no reactive read to certify. NOT a clean bill.";
503
+ return decide("R-50", R50_ID, R50_CHECK, scan, empty);
504
+ },
505
+ };
425
506
  /** In the form the orchestrator spreads into REGISTRY. */
426
507
  export const SOURCE_ISOLATION_DETECTORS = [
427
508
  { detector: R50, ruleId: "R-50", standardPin: SOURCE_STANDARD_PIN },
@@ -1,3 +1,4 @@
1
+ import { hasJustificationTokenInText } from "../oracle-axis2.js";
1
2
  import { base, code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
2
3
  // ---------------------------------------------------------------------------
3
4
  // R-19 — a table holding personal data declares a retention period AND exposes
@@ -92,7 +93,9 @@ export function scanR19(files) {
92
93
  // file that collects unbounded or logs nothing is the violation.
93
94
  // ---------------------------------------------------------------------------
94
95
  const JOB_FILE = /recurring|monitor|sweep|poll|resolver|cleanup|digest|cron/i;
95
- const RUN_BOUND = /\.take\s*\(|_cap\b|\blimit\b/i;
96
+ // `.paginate(` is a per-run bound too: a page of `numItems` + a self-reschedule is
97
+ // the platform's continuation pattern the standard names for R-20 (4bcd605 :256).
98
+ const RUN_BOUND = /\.take\s*\(|\.paginate\s*\(|_cap\b|\blimit\b/i;
96
99
  const OUTCOME_LOG = /console\.(log|error|warn|info)\s*\(/;
97
100
  // ACTUAL job content — a Convex data operation, a scheduler/cron registration, or
98
101
  // a scheduled-function handler. Eta ruling (2026-08-31): a file is a background-job
@@ -108,10 +111,171 @@ function isJobFile(f) {
108
111
  // require job CONTENT on an executing line, not a filename match alone.
109
112
  return f.lines.some((l) => code(l) && JOB_CONTENT.test(l.raw));
110
113
  }
114
+ // ---- REGISTRATION files are followed to their handlers ---------------------
115
+ //
116
+ // The misread: R-20 judged FILE BY FILE, so `convex/crons.ts` — which only
117
+ // REGISTERS schedules (`crons.interval("…", { minutes: 15 },
118
+ // internal.recurringTasks.processDueTasks, {})`) and does no work — was scored as an
119
+ // unbounded job because the registration file itself carries no `.take(`. The job
120
+ // is the HANDLER the registration names. A file whose job content is only cron
121
+ // registrations is therefore not judged as a job; each registration becomes a site
122
+ // judged on its handler's own body, following `ctx.runMutation/runAction/runQuery
123
+ // (internal.…)` delegations up to R20_MAX_HOP hops (a cron action that delegates
124
+ // its bounded drain to an internalMutation is bounded). A registration whose
125
+ // handler (or a delegate needed for the verdict) cannot be found in the tree is
126
+ // could-not-judge naming it — never green, never an accusation on a guess.
127
+ const CRON_FILE = /\bcronJobs\s*\(/;
128
+ const CRON_REGISTRATION = /\bcrons\.(?:interval|cron|hourly|daily|weekly|monthly)\s*\(/g;
129
+ const INTERNAL_REF = /\binternal\.([A-Za-z0-9_$.]+)/;
130
+ const DELEGATION = /ctx\.run(?:Mutation|Action|Query)\s*\(\s*internal\.([A-Za-z0-9_$.]+)/g;
131
+ const R20_MAX_HOP = 2;
132
+ /** A file whose executing job content is ONLY schedule registration. */
133
+ function isRegistrationOnly(f) {
134
+ const text = f.lines
135
+ .filter(code)
136
+ .map((l) => l.raw)
137
+ .join("\n");
138
+ if (!CRON_FILE.test(text))
139
+ return false;
140
+ return !/ctx\.db\.|\.collect\s*\(|\.paginate\s*\(|ctx\.scheduler\./.test(text);
141
+ }
142
+ /** Resolve `a.b.fn` (an `internal.` path) to the handler block's text + site. */
143
+ function findHandler(files, path) {
144
+ const parts = path.split(".");
145
+ if (parts.length < 2)
146
+ return undefined;
147
+ const fn = parts[parts.length - 1];
148
+ const mod = parts.slice(0, -1).join("/");
149
+ const file = files.find((f) => f.rel === `${mod}.ts` || f.rel.endsWith(`/${mod}.ts`));
150
+ if (!file)
151
+ return undefined;
152
+ const open = new RegExp(`export\\s+const\\s+${fn}\\s*=`);
153
+ const lines = file.lines;
154
+ const i = lines.findIndex((l) => code(l) && open.test(l.raw));
155
+ if (i < 0)
156
+ return undefined;
157
+ let depth = 0;
158
+ let started = false;
159
+ const body = [];
160
+ for (let j = i; j < lines.length; j++) {
161
+ if (code(lines[j]))
162
+ body.push(lines[j].raw);
163
+ for (const ch of lines[j].raw) {
164
+ if (ch === "{") {
165
+ depth++;
166
+ started = true;
167
+ }
168
+ else if (ch === "}")
169
+ depth--;
170
+ }
171
+ if (started && depth <= 0)
172
+ break;
173
+ }
174
+ return { rel: file.rel, n: lines[i].n, text: body.join("\n") };
175
+ }
176
+ function judgeHandler(files, path, depth, seen) {
177
+ const h = findHandler(files, path);
178
+ if (!h)
179
+ return { bound: false, log: false, unresolved: [path] };
180
+ const out = {
181
+ bound: RUN_BOUND.test(h.text),
182
+ log: OUTCOME_LOG.test(h.text),
183
+ unresolved: [],
184
+ };
185
+ if ((out.bound && out.log) || depth >= R20_MAX_HOP)
186
+ return out;
187
+ DELEGATION.lastIndex = 0;
188
+ let m = DELEGATION.exec(h.text);
189
+ while (m !== null) {
190
+ const next = m[1];
191
+ if (!seen.has(next)) {
192
+ seen.add(next);
193
+ const sub = judgeHandler(files, next, depth + 1, seen);
194
+ out.bound ||= sub.bound;
195
+ out.log ||= sub.log;
196
+ out.unresolved.push(...sub.unresolved);
197
+ }
198
+ m = DELEGATION.exec(h.text);
199
+ }
200
+ return out;
201
+ }
202
+ /** Each `crons.<kind>(…, internal.x.fn, …)` registration in a registration file,
203
+ * judged on its handler. */
204
+ function scanRegistrations(f, files, scan) {
205
+ const codeLines = f.lines.filter(code);
206
+ const text = codeLines.map((l) => l.raw).join("\n");
207
+ // map a text offset back to its source line number.
208
+ const starts = [];
209
+ let off = 0;
210
+ for (const l of codeLines) {
211
+ starts.push(off);
212
+ off += l.raw.length + 1;
213
+ }
214
+ const lineAt = (o) => {
215
+ let k = 0;
216
+ while (k + 1 < starts.length && starts[k + 1] <= o)
217
+ k++;
218
+ return codeLines[k].n;
219
+ };
220
+ CRON_REGISTRATION.lastIndex = 0;
221
+ let m = CRON_REGISTRATION.exec(text);
222
+ while (m !== null) {
223
+ const n = lineAt(m.index);
224
+ const ref = INTERNAL_REF.exec(text.slice(m.index, m.index + 400));
225
+ if (!ref) {
226
+ scan.cnj.push({
227
+ rel: f.rel,
228
+ n,
229
+ note: "schedule registration names no `internal.<module>.<fn>` handler readable at the site — its bound and observability cannot be followed (could-not-judge)",
230
+ });
231
+ }
232
+ else {
233
+ const path = ref[1];
234
+ const j = judgeHandler(files, path, 0, new Set([path]));
235
+ const h = findHandler(files, path);
236
+ const at = h ? `${h.rel}:${h.n}` : "not found";
237
+ if (j.bound && j.log) {
238
+ scan.conform.push({
239
+ rel: f.rel,
240
+ n,
241
+ note: `scheduled handler internal.${path} (${at}) bounds its run and logs its outcome (followed from the registration, delegations included)`,
242
+ });
243
+ }
244
+ else if (j.unresolved.length > 0) {
245
+ scan.cnj.push({
246
+ rel: f.rel,
247
+ n,
248
+ note: `scheduled handler internal.${path} (${at}): ${j.bound ? "bounded" : "no bound found"}${j.log ? ", logs" : ", no log found"} — but ${j.unresolved.map((u) => `internal.${u}`).join(", ")} could not be found in the tree, so the verdict cannot be completed (could-not-judge)`,
249
+ });
250
+ }
251
+ else if (!j.bound) {
252
+ scan.violate.push({
253
+ rel: f.rel,
254
+ n,
255
+ note: `scheduled handler internal.${path} (${at}) declares no per-run bound (.take/_CAP/limit) in its body or its delegates — an unbounded run does not ship`,
256
+ });
257
+ }
258
+ else {
259
+ scan.cnj.push({
260
+ rel: f.rel,
261
+ n,
262
+ note: `scheduled handler internal.${path} (${at}) bounds its run but no outcome log (console.*) found — observability not statically confirmed`,
263
+ });
264
+ }
265
+ }
266
+ m = CRON_REGISTRATION.exec(text);
267
+ }
268
+ }
111
269
  export function scanR20(files) {
112
270
  const scan = emptyScan();
113
271
  for (const f of files) {
114
- if (!isJudgeable(f) || !isJobFile(f))
272
+ if (!isJudgeable(f))
273
+ continue;
274
+ if (isRegistrationOnly(f)) {
275
+ scanRegistrations(f, files, scan);
276
+ continue;
277
+ }
278
+ if (!isJobFile(f))
115
279
  continue;
116
280
  let boundLine = 0;
117
281
  let logLine = 0;
@@ -137,11 +301,21 @@ export function scanR20(files) {
137
301
  note: "background job declares no per-run bound (.take/_CAP/limit) — an unbounded run does not ship",
138
302
  });
139
303
  }
304
+ else if (hasJustificationTokenInText(f.lines.map((l) => l.raw).join("\n"))) {
305
+ // judgement retired (task k179jnhx, §23): a bound-but-unlogged job is
306
+ // flagged; the FILE's carried JUSTIFIÉ:/JUSTIFIED: token is the written
307
+ // justification — decided by presence, never a person's opinion of it.
308
+ scan.conform.push({
309
+ rel: f.rel,
310
+ n: boundLine,
311
+ note: "background job bounds its run; no outcome log (console.*) found, but the file carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed",
312
+ });
313
+ }
140
314
  else {
141
- scan.cnj.push({
315
+ scan.violate.push({
142
316
  rel: f.rel,
143
317
  n: boundLine,
144
- note: "background job bounds its run but no outcome log (console.*) found — observability not statically confirmed",
318
+ note: "background job bounds its run but no outcome log (console.*) found, and no written JUSTIFIÉ:/JUSTIFIED: token in the file — observability owes a fix or a recorded justification",
145
319
  });
146
320
  }
147
321
  }
@@ -1,3 +1,4 @@
1
+ import { hasJustificationTokenInText } from "../oracle-axis2.js";
1
2
  import { code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
2
3
  // An ADMIN-PROVISIONABLE resource: a config/template row an admin creates —
3
4
  // profiles, clients, tenants, templates, mappings, filter rules, allowlists.
@@ -114,6 +115,7 @@ export function scanR43(files) {
114
115
  const scan = emptyScan();
115
116
  const dels = deletePaths(files);
116
117
  const { creates, optOutFiles } = adminCreates(files);
118
+ const textByRel = new Map(files.map((f) => [f.rel, f.lines.map((l) => l.raw).join("\n")]));
117
119
  for (const c of creates.values()) {
118
120
  const del = dels.get(c.table);
119
121
  if (del) {
@@ -130,11 +132,21 @@ export function scanR43(files) {
130
132
  note: `admin resource ${c.table} has no delete/GC path but carries a written out-of-scope reason (allow-no-delete-path) in ${c.rel}`,
131
133
  });
132
134
  }
135
+ else if (hasJustificationTokenInText(textByRel.get(c.rel) ?? "")) {
136
+ // judgement retired (task k179jnhx, §23): the file's carried
137
+ // JUSTIFIÉ:/JUSTIFIED: token IS the written out-of-scope reason,
138
+ // decided by presence, never by a person's opinion of soundness.
139
+ scan.conform.push({
140
+ rel: c.rel,
141
+ n: c.n,
142
+ note: `admin resource ${c.table} has no delete/GC path, but ${c.rel} carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed`,
143
+ });
144
+ }
133
145
  else {
134
- scan.cnj.push({
146
+ scan.violate.push({
135
147
  rel: c.rel,
136
148
  n: c.n,
137
- note: `admin resource ${c.table} is create/upsert-provisioned but exposes NO delete/GC path and carries no written out-of-scope reason — admin-resource lifecycle asymmetry (a resource you can create but never remove, orphaning any dependents)`,
149
+ note: `admin resource ${c.table} is create/upsert-provisioned but exposes NO delete/GC path and carries no written out-of-scope reason (allow-no-delete-path / JUSTIFIÉ: / JUSTIFIED:) — admin-resource lifecycle asymmetry owes a fix or a recorded justification`,
138
150
  });
139
151
  }
140
152
  }
@@ -0,0 +1,26 @@
1
+ import type { Detector, DetectorResult } from "../../engine-types.js";
2
+ import { type Site, type SrcFile } from "./_scan.js";
3
+ export declare const CR2_ID = "CR-2 no application defines its own identity primitive (tenant resolution, membership, scope filtering, credential validation, identification by name); identity lives only in @vantageos/cloud-identity";
4
+ export declare const KIND: {
5
+ readonly tenant: "resolves a caller to a tenant or agent";
6
+ readonly membership: "asserts membership or role";
7
+ readonly scope: "filters by the caller's scope";
8
+ readonly credential: "validates a credential";
9
+ readonly name: "identifies an agent by name";
10
+ };
11
+ export type Kind = keyof typeof KIND;
12
+ /** The written justification, read FROM THE TARGET SOURCE at the site. */
13
+ export declare const EXEMPT_MARKER: RegExp;
14
+ export interface LocalIdentityScan {
15
+ /** files read (judged + unjudgeable), the population size. */
16
+ files: number;
17
+ violate: Site[];
18
+ exempt: Site[];
19
+ cnj: Site[];
20
+ }
21
+ export declare function scanLocalIdentity(files: SrcFile[]): LocalIdentityScan;
22
+ export declare function localIdentityResult(scan: LocalIdentityScan): DetectorResult;
23
+ /** The rendered section: EVERY violation, exemption and abstention is named. */
24
+ export declare function localIdentitySection(scan: LocalIdentityScan): string;
25
+ /** CR-2 as an engine Detector. */
26
+ export declare const CR2: Detector;