@vantageos/backend-doctor 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,285 @@
1
+ import { code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
2
+ // A public reactive read is DEFINED, not read. R-50 governs a query DEFINITION —
3
+ // `export const NAME = <builder>({ ... })` — never a `ctx.db.query(...)` table READ
4
+ // inside a handler (the pre-fix opener `/\bquery\(/` matched `.query(` reads
5
+ // incidentally, naming a helper's internal read as the site while missing the real
6
+ // subscribed queries — Eta k170q03p). The builder is `query` OR a `customQuery`
7
+ // wrapper the tree declares (the standard Convex idiom: `export const authedQuery =
8
+ // customQuery(query, ...)`, then `export const summary = authedQuery({...})`). A
9
+ // rule about subscribed reads that matches only the bare `query` builder is blind on
10
+ // most real Convex codebases, so R-50 DISCOVERS the builders first, and REFUSES
11
+ // (could-not-judge naming the file) when a builder's base cannot be resolved rather
12
+ // than return a green obtained by not looking.
13
+ // `export const X = customQuery(` — the wrapper opener. The BASE identifier may sit
14
+ // on the SAME line or a FOLLOWING one (the idiom often breaks the call across lines:
15
+ // `customQuery(\n query,\n …)`), so the base is resolved by a short look-ahead, not
16
+ // captured here.
17
+ const CUSTOM_QUERY_OPEN = /(?:export\s+)?const\s+([A-Za-z0-9_]+)\s*=\s*customQuery\s*\(/;
18
+ // the first identifier token — used to read the BASE after a `customQuery(` opener.
19
+ const FIRST_IDENT = /([A-Za-z_][A-Za-z0-9_]*)/;
20
+ // `export const X = customMutation(...)` — a write builder, never an R-50 subject.
21
+ const CUSTOM_MUTATION_DEF = /(?:export\s+)?const\s+([A-Za-z0-9_]+)\s*=\s*customMutation\s*\(/;
22
+ // the base builders the platform ships. `query` is the only public read base.
23
+ const BASE_PUBLIC_QUERY = "query";
24
+ const BASE_INTERNAL_QUERY = "internalQuery";
25
+ // An auth/org GATE helper a customQuery customCtx runs before the handler — a
26
+ // require*/ensure*/assert* access check (requireReadAccess, requireWorkspaceId,
27
+ // ensureCurrentOrg, …). Its presence in the wrapper means the wrapper, not the
28
+ // handler, owns the no-org disposition of every query built from it.
29
+ const GATE_HELPER = /\b(?:require|ensure|assert)[A-Z][A-Za-z0-9_]*\s*\(/;
30
+ /** Discover the query/mutation builders a tree declares BEFORE classifying any
31
+ * definition — `query` is always a public read base; a `customQuery(query,…)`
32
+ * wrapper is public; a `customQuery(internalQuery,…)` wrapper is non-subject; any
33
+ * other base is ambiguous (refuse). */
34
+ function discoverBuilders(files) {
35
+ const b = {
36
+ publicQuery: new Set([BASE_PUBLIC_QUERY]),
37
+ nonSubject: new Set([
38
+ BASE_INTERNAL_QUERY,
39
+ "mutation",
40
+ "internalMutation",
41
+ "action",
42
+ "internalAction",
43
+ "httpAction",
44
+ ]),
45
+ gated: new Map(),
46
+ ambiguous: [],
47
+ };
48
+ for (const f of files) {
49
+ if (!isJudgeable(f))
50
+ continue;
51
+ const codeLines = f.lines.filter(code);
52
+ // full-block text (comments included) for the marker check on a builder region.
53
+ const allLines = f.lines;
54
+ for (let i = 0; i < codeLines.length; i++) {
55
+ const l = codeLines[i];
56
+ const cm = CUSTOM_MUTATION_DEF.exec(l.raw);
57
+ if (cm) {
58
+ b.nonSubject.add(cm[1]);
59
+ continue;
60
+ }
61
+ const cq = CUSTOM_QUERY_OPEN.exec(l.raw);
62
+ if (!cq)
63
+ continue;
64
+ const name = cq[1];
65
+ // the BASE is the first identifier AFTER `customQuery(` — on this line's
66
+ // remainder, else on the following code lines (the call is often wrapped).
67
+ const afterOpen = l.raw.slice((cq.index ?? 0) + cq[0].length);
68
+ let base = FIRST_IDENT.exec(afterOpen)?.[1];
69
+ for (let j = i + 1; !base && j < codeLines.length && j <= i + 4; j++) {
70
+ base = FIRST_IDENT.exec(codeLines[j].raw)?.[1];
71
+ }
72
+ if (base === BASE_PUBLIC_QUERY) {
73
+ b.publicQuery.add(name);
74
+ // Does the wrapper's customCtx run an auth/org gate that can throw, with
75
+ // no documented contract? The builder region is the opener line plus the
76
+ // following few CODE lines (the customCtx body is short). The marker is a
77
+ // comment, so check it against the raw region INCLUDING comments.
78
+ const codeRegion = codeLines
79
+ .slice(i, i + 10)
80
+ .map((x) => x.raw)
81
+ .join("\n");
82
+ const startAbs = allLines.indexOf(l);
83
+ // include up to 3 lines ABOVE the opener: a builder's isolation-contract
84
+ // marker is conventionally the comment immediately above its declaration.
85
+ const rawRegion = startAbs >= 0
86
+ ? allLines
87
+ .slice(Math.max(0, startAbs - 3), startAbs + 14)
88
+ .map((x) => x.raw)
89
+ .join("\n")
90
+ : codeRegion;
91
+ const gates = ORG_THROW.test(codeRegion) ||
92
+ ORG_THROW_HELPER.test(codeRegion) ||
93
+ GATE_HELPER.test(codeRegion);
94
+ if (gates && !CONTRACT_MARKER.test(rawRegion))
95
+ b.gated.set(name, { rel: f.rel, n: l.n });
96
+ }
97
+ else if (base === BASE_INTERNAL_QUERY)
98
+ b.nonSubject.add(name);
99
+ else
100
+ b.ambiguous.push({
101
+ rel: f.rel,
102
+ n: l.n,
103
+ name,
104
+ base: base ?? "<unresolved>",
105
+ });
106
+ }
107
+ }
108
+ return b;
109
+ }
110
+ /** `export const NAME = <builder>(` for a builder in `set`. A DEFINITION opener,
111
+ * never a `.query(` read (there is no `const NAME =` before a `ctx.db.query(`). */
112
+ function definitionOpener(raw, set) {
113
+ // longest builder names first so `authedQuery` wins over a `query` substring.
114
+ const names = [...set].sort((a, z) => z.length - a.length);
115
+ const alt = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
116
+ const re = new RegExp(`(?:export\\s+)?const\\s+([A-Za-z0-9_]+)\\s*=\\s*(${alt})\\s*\\(`);
117
+ const m = re.exec(raw);
118
+ return m ? { name: m[1], builder: m[2] } : null;
119
+ }
120
+ // An ORG-ISOLATION THROW: a throw keyed on the caller having no org/identity, or a
121
+ // helper that throws on the missing-org path. This is the behaviour a subscribed
122
+ // client receives as an uncaught Server Error.
123
+ const ORG_THROW = /throw\b[^;]*\b(?:NO_ORG|ORG_REQUIRED|RBAC_DENIED|no org|missing org|organization is required|not in (?:an )?org)/i;
124
+ const ORG_THROW_HELPER = /\b(?:requireWorkspaceId|requireOrgId|requireOrgSlug|getOrgIdOrThrow|ensureCurrentOrg|ensureCurrent)\s*\(/;
125
+ // The NON-throwing, fail-closed org resolver (VP's `withOrgScope` returns a typed
126
+ // empty scope on no identity — never throws) OR a documented isolation contract
127
+ // OR an explicit typed-empty return on the no-org branch. Any of these means the
128
+ // subscribing client always receives a shape it can render.
129
+ const SAFE_RESOLVER = /\bwithOrgScope\s*\(/;
130
+ const CONTRACT_MARKER = /isolation-contract:|client-safe-empty|@client-must-not-subscribe/i;
131
+ /** delimit a function body from its opener at index i by matching braces. */
132
+ function queryBlock(lines, i) {
133
+ let depth = 0;
134
+ let seenOpen = false;
135
+ const body = [];
136
+ for (let j = i; j < lines.length; j++) {
137
+ const l = lines[j];
138
+ body.push(l);
139
+ for (const ch of l.raw) {
140
+ if (ch === "{") {
141
+ depth += 1;
142
+ seenOpen = true;
143
+ }
144
+ else if (ch === "}") {
145
+ depth -= 1;
146
+ }
147
+ }
148
+ if (seenOpen && depth <= 0)
149
+ return { lines: body, unterminated: false };
150
+ }
151
+ return { lines: body, unterminated: seenOpen };
152
+ }
153
+ export function scanR50(files) {
154
+ const scan = emptyScan();
155
+ // Pass 1: discover the tree's query/mutation builders so a customQuery wrapper
156
+ // (authedQuery, orgQuery, …) is recognised, not just the bare `query` base.
157
+ const builders = discoverBuilders(files);
158
+ // A customQuery wrapper whose base is neither query nor internalQuery leaves the
159
+ // public-query surface unknowable — REFUSE (could-not-judge), never a silent green.
160
+ for (const a of builders.ambiguous) {
161
+ scan.cnj.push({
162
+ rel: a.rel,
163
+ n: a.n,
164
+ note: `builder "${a.name}" = customQuery(${a.base}, …) — its base "${a.base}" resolves to neither \`query\` (public) nor \`internalQuery\`, so whether definitions using it are subscribed queries cannot be enumerated; R-50 refuses rather than certify a surface it cannot see (never a silent green)`,
165
+ });
166
+ }
167
+ // A GATED public customQuery builder: its customCtx runs an auth/org gate that can
168
+ // throw, so every subscribed query built from it inherits that throw in the shared
169
+ // wrapper — invisible to a per-handler scan. REFUSE per builder (could-not-judge),
170
+ // naming it and counting its dependent subscribed queries, so a customQuery
171
+ // codebase never reads "R-50 clean" by the opener simply not looking into the
172
+ // wrapper. (The remedy a human confirms: the wrapper returns a typed empty/unauth
173
+ // context on the no-org path, or documents an isolation-contract on the builder.)
174
+ for (const [name, at] of builders.gated) {
175
+ let deps = 0;
176
+ for (const f of files) {
177
+ if (!isJudgeable(f))
178
+ continue;
179
+ for (const l of f.lines) {
180
+ if (!code(l))
181
+ continue;
182
+ const o = definitionOpener(l.raw, new Set([name]));
183
+ if (o)
184
+ deps += 1;
185
+ }
186
+ }
187
+ scan.cnj.push({
188
+ rel: at.rel,
189
+ n: at.n,
190
+ note: `customQuery builder "${name}" gates every query built from it in its customCtx (an auth/org require*/throw), and ${deps} subscribed query(ies) route through it — whether that wrapper THROWS into a subscription or returns a typed empty/unauthorized context on the no-org path is not decidable from the query surface. R-50 refuses (a human confirms the wrapper's no-org shape, or the builder carries an isolation-contract marker) — never a silent green obtained by not looking into the wrapper`,
191
+ });
192
+ }
193
+ for (const f of files) {
194
+ if (!isJudgeable(f))
195
+ continue;
196
+ const lines = f.lines;
197
+ for (let i = 0; i < lines.length; i++) {
198
+ if (!code(lines[i]))
199
+ continue;
200
+ // R-50's subject is a public-query DEFINITION: `const NAME = <publicBuilder>(`.
201
+ // This never matches a `ctx.db.query(...)` read (no `const NAME =` there) nor
202
+ // an internal/mutation/action definition (those builders are not in the set).
203
+ const opener = definitionOpener(lines[i].raw, builders.publicQuery);
204
+ if (!opener)
205
+ continue;
206
+ // A query declared through a GATED customQuery builder inherits its no-org
207
+ // disposition from the shared customCtx wrapper (an auth/org gate that can
208
+ // throw), NOT from this handler — so a per-handler scan cannot see it. The
209
+ // builder-level refusal below covers these; skip the per-handler pass here.
210
+ if (builders.gated.has(opener.builder))
211
+ continue;
212
+ // exclude the builder DEFINITION line itself (`const authedQuery = query(`?
213
+ // no — customQuery defs use `customQuery(`, filtered above; but guard a
214
+ // `const x = query(` that is a re-export, not a `query({config})`): require
215
+ // the block to open a config object, handled by queryBlock below).
216
+ const fn = opener.name;
217
+ const block = queryBlock(lines, i);
218
+ i += block.lines.length - 1;
219
+ if (block.unterminated) {
220
+ scan.cnj.push({
221
+ rel: f.rel,
222
+ n: lines[Math.min(i, lines.length - 1)].n,
223
+ note: `${fn}: a public query block could not be delimited (unbalanced braces) — whether its no-org path throws or returns a typed empty is not statically decidable; a human must judge (never a silent green)`,
224
+ });
225
+ continue;
226
+ }
227
+ const blockText = block.lines
228
+ .filter(code)
229
+ .map((l) => l.raw)
230
+ .join("\n");
231
+ // The org-throw and the resolver are CODE (read from executing lines only,
232
+ // so a commented-out throw does not count). The contract marker is, by
233
+ // definition, a COMMENT — so it is read from the full block text INCLUDING
234
+ // comment lines, not the code-only blockText.
235
+ const blockTextWithComments = block.lines
236
+ .map((l) => l.raw)
237
+ .join("\n");
238
+ const throwsOnNoOrg = ORG_THROW.test(blockText) || ORG_THROW_HELPER.test(blockText);
239
+ const hasResolver = SAFE_RESOLVER.test(blockText);
240
+ const hasMarker = CONTRACT_MARKER.test(blockTextWithComments);
241
+ if (!throwsOnNoOrg && !hasResolver && !hasMarker) {
242
+ // query does neither org-isolation throw nor fail-closed resolve nor
243
+ // declare a contract — not R-50's subject (too ambiguous to judge
244
+ // mechanically; never a violation).
245
+ continue;
246
+ }
247
+ // PRECEDENCE (Eta #13 REVISE seam): the THROW wins over a fail-closed
248
+ // resolver. `withOrgScope` returning a typed empty scope is a property of
249
+ // the RESOLVER, not of the handler — a handler that calls withOrgScope AND
250
+ // then `throw`s on the no-org path still fires that throw into the
251
+ // subscription, so the resolver's presence must NOT clear it. Only a
252
+ // documented CONTRACT_MARKER — a CLAIM the reviewer checks, never a proof —
253
+ // clears a throwing handler (a declared "the layout must not subscribe").
254
+ if (hasMarker) {
255
+ scan.conform.push({
256
+ rel: f.rel,
257
+ n: block.lines[0].n,
258
+ note: `${fn}: public query carries a documented isolation-contract marker (a declared divergence — a CLAIM the reviewer verifies, not a proof of behaviour) — accepted, the layout is asserted not to subscribe on the no-org path`,
259
+ });
260
+ }
261
+ else if (throwsOnNoOrg) {
262
+ scan.violate.push({
263
+ rel: f.rel,
264
+ n: block.lines[0].n,
265
+ note: `${fn}: public query throws on the missing-org path (org-keyed ConvexError or a throw-on-no-org helper) with no documented contract${hasResolver ? " — a `withOrgScope` resolver is ALSO present, but a fail-closed resolver returning a typed empty scope does NOT rescue an explicit throw that still fires into the subscription (Eta #13 seam)" : ""}. A subscribing client receives an uncaught Server Error. Return a typed empty/unauthorized shape (do not throw), or document that the layout must not subscribe`,
266
+ });
267
+ }
268
+ else {
269
+ // hasResolver, no throw, no marker — the fail-closed resolver is the
270
+ // whole no-org handling; the subscribing client receives a typed shape.
271
+ scan.conform.push({
272
+ rel: f.rel,
273
+ n: block.lines[0].n,
274
+ note: `${fn}: public query resolves org via the fail-closed non-throwing resolver (\`withOrgScope\`) and does NOT throw on the no-org path — the subscribing client receives a typed shape, not an uncaught throw`,
275
+ });
276
+ }
277
+ }
278
+ }
279
+ return scan;
280
+ }
281
+ 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.");
282
+ /** In the form the orchestrator spreads into REGISTRY. */
283
+ export const SOURCE_ISOLATION_DETECTORS = [
284
+ { detector: R50, ruleId: "R-50", standardPin: SOURCE_STANDARD_PIN },
285
+ ];
@@ -1,11 +1,26 @@
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-51ec7af1.md";
3
+ export declare const STANDARD_FILE = "backend-standard-4265226e.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";
7
7
  /** Read the vendored standard's raw text. Injectable for tests. */
8
8
  export declare function readStandard(path?: string): string;
9
+ /**
10
+ * The instrument's own readability check. A backend-doctor run reads its pinned
11
+ * standard fixture as material; if that fixture is ABSENT or UNREADABLE the tool
12
+ * has nothing to judge against, and letting `readFileSync` throw exits the CLI
13
+ * with code 1 — indistinguishable from "a violation was found" (Eta #12 REVISE).
14
+ * This returns a REFUSAL the CLI maps to exit 2 (could-not-judge) with a line
15
+ * NAMING what it could not read, mirroring the fleet leak_guard.py form. `ok`
16
+ * means the instrument is readable; a run may then legitimately pass or fail a pole.
17
+ */
18
+ export declare function checkStandardReadable(path?: string): {
19
+ ok: true;
20
+ } | {
21
+ ok: false;
22
+ refusal: string;
23
+ };
9
24
  export interface DocLine {
10
25
  /** 1-based line number in the vendored file. */
11
26
  n: number;
@@ -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-a9d7877b.md` — the material at the pin. The
5
+ // fixture `fixtures/backend-standard-4265226e.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
@@ -13,7 +13,7 @@ import { readFileSync } from "node:fs";
13
13
  import { dirname, join } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  /** The vendored-fixture basename used as the anchor file for every doc site. */
16
- export const STANDARD_FILE = "backend-standard-51ec7af1.md";
16
+ export const STANDARD_FILE = "backend-standard-4265226e.md";
17
17
  /** The vendored standard at the pin — DATA on the same footing as the oracle CSV.
18
18
  * Resolved locally (not via coverage.ts) so this module carries no import cycle
19
19
  * with registry.ts. Same file coverage.ts's VENDORED_STANDARD_PATH points at. */
@@ -25,6 +25,33 @@ export const STANDARD_DOC_PIN = "analysis/vantagepeers/backend-standard/backend-
25
25
  export function readStandard(path = VENDORED_STANDARD_PATH) {
26
26
  return readFileSync(path, "utf8");
27
27
  }
28
+ /**
29
+ * The instrument's own readability check. A backend-doctor run reads its pinned
30
+ * standard fixture as material; if that fixture is ABSENT or UNREADABLE the tool
31
+ * has nothing to judge against, and letting `readFileSync` throw exits the CLI
32
+ * with code 1 — indistinguishable from "a violation was found" (Eta #12 REVISE).
33
+ * This returns a REFUSAL the CLI maps to exit 2 (could-not-judge) with a line
34
+ * NAMING what it could not read, mirroring the fleet leak_guard.py form. `ok`
35
+ * means the instrument is readable; a run may then legitimately pass or fail a pole.
36
+ */
37
+ export function checkStandardReadable(path = VENDORED_STANDARD_PATH) {
38
+ try {
39
+ const text = readFileSync(path, "utf8");
40
+ if (text.trim().length === 0) {
41
+ return {
42
+ ok: false,
43
+ refusal: `REFUSING TO JUDGE: standard fixture ${STANDARD_FILE} is EMPTY — the instrument cannot read its own pin (${path})`,
44
+ };
45
+ }
46
+ return { ok: true };
47
+ }
48
+ catch {
49
+ return {
50
+ ok: false,
51
+ refusal: `REFUSING TO JUDGE: standard fixture ${STANDARD_FILE} is ABSENT or UNREADABLE — the instrument cannot read its own pin (${path})`,
52
+ };
53
+ }
54
+ }
28
55
  /** Split the standard text into 1-based numbered lines. */
29
56
  export function toLines(text) {
30
57
  return text.split("\n").map((raw, i) => ({ n: i + 1, raw }));
@@ -16,7 +16,7 @@
16
16
  // gentler word — it was emptied.
17
17
  import { STANDARD_FILE } from "./detectors/standard/_doc.js";
18
18
  export const NOT_MECHANICALLY_CHECKABLE = {
19
- "R-48": "R-48 authority-totality detector pending — task k17chpr4kzp399j3s7r7edpm4s8cscbz; text pinned at 51ec7af1 so coverage cannot pretend 47 is complete. No mechanical control yet; R-48 is enumerated and visible, never silently dropped.",
19
+ "R-48": "R-48 authority-totality detector pending — task k17chpr4kzp399j3s7r7edpm4s8cscbz; text pinned at 4265226e so coverage cannot pretend 47 is complete. No mechanical control yet; R-48 is enumerated and visible, never silently dropped.",
20
20
  };
21
21
  /**
22
22
  * One `could-not-judge` DetectorResult per NOT_MECHANICALLY_CHECKABLE rule, so
@@ -1,6 +1,6 @@
1
1
  import type { Detector } from "./engine-types.js";
2
2
  /** The standard both pins the whole wave against. */
3
- export declare const STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp)";
3
+ export declare const STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ 4265226eb6698c7dfbae3ced77df564e877908ad (elpiarthera/ElPi-Corp)";
4
4
  export interface RegistryEntry {
5
5
  detector: Detector;
6
6
  ruleId: string;
package/dist/registry.js CHANGED
@@ -8,6 +8,7 @@ import { makeOracleDetector } from "./detectors/shared.js";
8
8
  import { SOURCE_BACKFILL_DETECTORS } from "./detectors/source/backfill.js";
9
9
  import { SOURCE_CONTRACT_DETECTORS } from "./detectors/source/contract.js";
10
10
  import { IDENTITY_DETECTORS } from "./detectors/source/identity.js";
11
+ import { SOURCE_ISOLATION_DETECTORS } from "./detectors/source/isolation.js";
11
12
  import { SOURCE_JOBS_DETECTORS } from "./detectors/source/jobs.js";
12
13
  import { SOURCE_LIFECYCLE_DETECTORS } from "./detectors/source/lifecycle.js";
13
14
  import { SOURCE_READBUDGET_DETECTORS } from "./detectors/source/readbudget.js";
@@ -19,7 +20,7 @@ import { R46_STANDARD_PIN, STANDARD_SOURCE_DETECTORS, } from "./detectors/standa
19
20
  import { STANDARD_DOC_DETECTORS } from "./detectors/standard/standard-doc.js";
20
21
  import { makeR14, makeR38 } from "./detectors/structural.js";
21
22
  /** The standard both pins the whole wave against. */
22
- export const STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp)";
23
+ export const STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ 4265226eb6698c7dfbae3ced77df564e877908ad (elpiarthera/ElPi-Corp)";
23
24
  export const REGISTRY = [
24
25
  ...PER_ROW_PREDICATES.map((spec) => ({
25
26
  detector: makeOracleDetector(spec),
@@ -98,6 +99,15 @@ export const REGISTRY = [
98
99
  // site (the human decides refusal vs a tighter index vs a declared bound),
99
100
  // present on a wide scan that DOES refuse (the tasks.list SCAN_CAP shape).
100
101
  ...SOURCE_REFUSAL_DETECTORS,
102
+ // R-50 — org isolation on a reactively-subscribed read must be a TYPED EMPTY /
103
+ // UNAUTHORIZED RESULT, never an uncaught throw. Reads the convex source: a
104
+ // public `query` (subscribed via useQuery) whose no-org path throws an
105
+ // org-keyed ConvexError / a throw-on-no-org helper, with no fail-closed
106
+ // resolver (`withOrgScope`) or documented contract, hands the subscribing
107
+ // client an uncaught Server Error (the 2026-08-30 vCRM-Preview incident:
108
+ // workspaces:ensureCurrent + prefs:sidebar). Extends the R-11 isolation
109
+ // family; a mutation refusing an unauthorized write (R-16) is out of scope.
110
+ ...SOURCE_ISOLATION_DETECTORS,
101
111
  ...SOURCE_CONTRACT_DETECTORS,
102
112
  ...IDENTITY_DETECTORS,
103
113
  // The four STANDARD-DOCUMENT detectors (R-42/R-15/R-32/R-39): each reads the