@vantageos/backend-doctor 0.1.2 → 0.1.3
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.
- package/README.md +1 -1
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.js +1 -1
- package/dist/detectors/source/write-isolation.d.ts +5 -0
- package/dist/detectors/source/write-isolation.js +198 -0
- package/dist/detectors/standard/_doc.d.ts +1 -1
- package/dist/detectors/standard/_doc.js +2 -2
- package/dist/not-checkable.js +1 -1
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +8 -1
- package/fixtures/{backend-standard-4265226e.md → backend-standard-dcec1ba.md} +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ A `could-not-judge` rule is an **abstention** — a human verdict is owed, never
|
|
|
81
81
|
|
|
82
82
|
Every input is pinned and, where it is a vendored document, proven byte-identical to its upstream git blob (an un-hashable vendored doc is an authority granted to oneself).
|
|
83
83
|
|
|
84
|
-
**The single authority for which standard fixture is active is the loader constant** `STANDARD_FILE` in [`src/detectors/standard/_doc.ts`](./src/detectors/standard/_doc.ts) — currently `backend-standard-
|
|
84
|
+
**The single authority for which standard fixture is active is the loader constant** `STANDARD_FILE` in [`src/detectors/standard/_doc.ts`](./src/detectors/standard/_doc.ts) — currently `backend-standard-dcec1ba.md` (49 rules — adds R-50, org isolation on a subscribed read is a typed empty result, never an uncaught throw; canonical source: ElPi-Corp `analysis/vantagepeers/backend-standard/backend-standard.md`). Everything else is provenance, NOT the active pin: `PINS.txt` and any other `fixtures/backend-standard-<sha>.md` still in the tree (e.g. the superseded `backend-standard-a9d7877b.md`, 47 rules — it lacks R-48) are kept only to show what was scored before. If any of them disagrees with `STANDARD_FILE`, `STANDARD_FILE` wins. Read the active pin from `STANDARD_FILE`, never from `PINS.txt` or a number typed elsewhere — following a stale `PINS.txt` would silently score the superseded 47-rule document and never surface R-48. See [`PINS.txt`](./PINS.txt) for the engine, backend snapshot, and MCP-family doc + VR inventory pins.
|
|
85
85
|
|
|
86
86
|
## Discipline (how a detector is proven)
|
|
87
87
|
|
package/dist/coverage.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* canonical 48-rule standard (51ec7af1) to 2bb402f7, which closes R-7/R-8/R-37/R-40
|
|
5
5
|
* as a written-justification-token control (§23) — the paired standard PR the four
|
|
6
6
|
* decisive detectors below track. Still 48 rules; the pin moves forward with them. */
|
|
7
|
-
export declare const STANDARD_SHA = "
|
|
7
|
+
export declare const STANDARD_SHA = "dcec1ba31091accfe9b8840fab736815306307c5";
|
|
8
8
|
export declare const VENDORED_STANDARD_PATH: string;
|
|
9
9
|
/**
|
|
10
10
|
* Derive the full rule list R-1..R-42 from the VENDORED standard fixture —
|
package/dist/coverage.js
CHANGED
|
@@ -18,8 +18,8 @@ const here = dirname(fileURLToPath(import.meta.url));
|
|
|
18
18
|
* canonical 48-rule standard (51ec7af1) to 2bb402f7, which closes R-7/R-8/R-37/R-40
|
|
19
19
|
* as a written-justification-token control (§23) — the paired standard PR the four
|
|
20
20
|
* decisive detectors below track. Still 48 rules; the pin moves forward with them. */
|
|
21
|
-
export const STANDARD_SHA = "
|
|
22
|
-
export const VENDORED_STANDARD_PATH = join(here, "..", "fixtures", "backend-standard-
|
|
21
|
+
export const STANDARD_SHA = "dcec1ba31091accfe9b8840fab736815306307c5";
|
|
22
|
+
export const VENDORED_STANDARD_PATH = join(here, "..", "fixtures", "backend-standard-dcec1ba.md");
|
|
23
23
|
/**
|
|
24
24
|
* Derive the full rule list R-1..R-42 from the VENDORED standard fixture —
|
|
25
25
|
* never typed, never a git show against a local repo. Reads the `**R-NN`
|
|
@@ -29,7 +29,7 @@ export const VR_ORACLE_PATH = join(here, "..", "..", "fixtures", "vr-by-tool-day
|
|
|
29
29
|
* §2 [shared] contract columns are DERIVED from this file, never hardcoded.
|
|
30
30
|
* Defined locally (not imported from coverage.ts) to avoid a registry ↔ coverage
|
|
31
31
|
* import cycle. */
|
|
32
|
-
export const VENDORED_STANDARD_PATH = join(here, "..", "..", "fixtures", "backend-standard-
|
|
32
|
+
export const VENDORED_STANDARD_PATH = join(here, "..", "..", "fixtures", "backend-standard-dcec1ba.md");
|
|
33
33
|
/** Load any backend inventory CSV generically from its OWN header — the shared
|
|
34
34
|
* fields are DERIVED from these real headers, never a prose illustration. */
|
|
35
35
|
export function loadInventory(path) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Detector } from "../../engine-types.js";
|
|
2
|
+
import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
|
|
3
|
+
export declare function scanR51(files: SrcFile[]): Scan;
|
|
4
|
+
export declare const R51: Detector;
|
|
5
|
+
export declare const SOURCE_WRITE_ISOLATION_DETECTORS: SourceDetectorEntry[];
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
|
|
2
|
+
const CUSTOM_MUTATION_OPEN = /(?:export\s+)?const\s+([A-Za-z0-9_]+)\s*=\s*customMutation\s*\(/;
|
|
3
|
+
const CUSTOM_QUERY_DEF = /(?:export\s+)?const\s+([A-Za-z0-9_]+)\s*=\s*customQuery\s*\(/;
|
|
4
|
+
const FIRST_IDENT = /([A-Za-z_][A-Za-z0-9_]*)/;
|
|
5
|
+
const BASE_PUBLIC_MUTATION = "mutation";
|
|
6
|
+
const BASE_INTERNAL_MUTATION = "internalMutation";
|
|
7
|
+
// A no-org throw a write inherits: an org-keyed ConvexError, or a write/org gate
|
|
8
|
+
// helper that throws when no org is resolved.
|
|
9
|
+
const ORG_THROW = /throw\b[^;]*\b(?:NO_ORG|ORG_REQUIRED|RBAC_DENIED|no org|missing org|organization is required|not in (?:an )?org)/i;
|
|
10
|
+
const WRITE_GATE_HELPER = /\b(?:requireWriteAccess|requireWorkspaceId|requireOrgId|getEffectiveWorkspaceId|getOrgIdOrThrow|ensureCurrentOrg)\s*\(/;
|
|
11
|
+
// a gate helper generally (any require*/ensure*/assert* access check in a wrapper).
|
|
12
|
+
const GATE_HELPER = /\b(?:require|ensure|assert)[A-Z][A-Za-z0-9_]*\s*\(/;
|
|
13
|
+
// a TYPED unauthorized RESULT — the write returns a shape instead of throwing.
|
|
14
|
+
const TYPED_UNAUTH_RETURN = /return\s+\{[^}]*\b(?:unauthorized|forbidden|noOrg|no_org|denied|ok\s*:\s*false)\b/i;
|
|
15
|
+
const CONTRACT_MARKER = /write-contract:|client-must-not-write-before-org|isolation-contract:/i;
|
|
16
|
+
function discoverWriteBuilders(files) {
|
|
17
|
+
const b = {
|
|
18
|
+
publicMutation: new Set([BASE_PUBLIC_MUTATION]),
|
|
19
|
+
nonSubject: new Set([
|
|
20
|
+
BASE_INTERNAL_MUTATION,
|
|
21
|
+
"query",
|
|
22
|
+
"internalQuery",
|
|
23
|
+
"action",
|
|
24
|
+
"internalAction",
|
|
25
|
+
"httpAction",
|
|
26
|
+
]),
|
|
27
|
+
gated: new Map(),
|
|
28
|
+
ambiguous: [],
|
|
29
|
+
};
|
|
30
|
+
for (const f of files) {
|
|
31
|
+
if (!isJudgeable(f))
|
|
32
|
+
continue;
|
|
33
|
+
const codeLines = f.lines.filter(code);
|
|
34
|
+
const allLines = f.lines;
|
|
35
|
+
for (let i = 0; i < codeLines.length; i++) {
|
|
36
|
+
const l = codeLines[i];
|
|
37
|
+
// a customQuery wrapper is a READ builder — not R-51's subject.
|
|
38
|
+
if (CUSTOM_QUERY_DEF.test(l.raw))
|
|
39
|
+
continue;
|
|
40
|
+
const cm = CUSTOM_MUTATION_OPEN.exec(l.raw);
|
|
41
|
+
if (!cm)
|
|
42
|
+
continue;
|
|
43
|
+
const name = cm[1];
|
|
44
|
+
const afterOpen = l.raw.slice((cm.index ?? 0) + cm[0].length);
|
|
45
|
+
let base = FIRST_IDENT.exec(afterOpen)?.[1];
|
|
46
|
+
for (let j = i + 1; !base && j < codeLines.length && j <= i + 4; j++) {
|
|
47
|
+
base = FIRST_IDENT.exec(codeLines[j].raw)?.[1];
|
|
48
|
+
}
|
|
49
|
+
if (base === BASE_PUBLIC_MUTATION) {
|
|
50
|
+
b.publicMutation.add(name);
|
|
51
|
+
const codeRegion = codeLines
|
|
52
|
+
.slice(i, i + 10)
|
|
53
|
+
.map((x) => x.raw)
|
|
54
|
+
.join("\n");
|
|
55
|
+
const startAbs = allLines.indexOf(l);
|
|
56
|
+
const rawRegion = startAbs >= 0
|
|
57
|
+
? allLines
|
|
58
|
+
.slice(Math.max(0, startAbs - 3), startAbs + 14)
|
|
59
|
+
.map((x) => x.raw)
|
|
60
|
+
.join("\n")
|
|
61
|
+
: codeRegion;
|
|
62
|
+
const gates = ORG_THROW.test(codeRegion) ||
|
|
63
|
+
WRITE_GATE_HELPER.test(codeRegion) ||
|
|
64
|
+
GATE_HELPER.test(codeRegion);
|
|
65
|
+
if (gates && !CONTRACT_MARKER.test(rawRegion))
|
|
66
|
+
b.gated.set(name, { rel: f.rel, n: l.n });
|
|
67
|
+
}
|
|
68
|
+
else if (base === BASE_INTERNAL_MUTATION)
|
|
69
|
+
b.nonSubject.add(name);
|
|
70
|
+
else
|
|
71
|
+
b.ambiguous.push({
|
|
72
|
+
rel: f.rel,
|
|
73
|
+
n: l.n,
|
|
74
|
+
name,
|
|
75
|
+
base: base ?? "<unresolved>",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return b;
|
|
80
|
+
}
|
|
81
|
+
function definitionOpener(raw, set) {
|
|
82
|
+
const names = [...set].sort((a, z) => z.length - a.length);
|
|
83
|
+
const alt = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
84
|
+
const re = new RegExp(`(?:export\\s+)?const\\s+([A-Za-z0-9_]+)\\s*=\\s*(${alt})\\s*\\(`);
|
|
85
|
+
const m = re.exec(raw);
|
|
86
|
+
return m ? { name: m[1], builder: m[2] } : null;
|
|
87
|
+
}
|
|
88
|
+
function mutationBlock(lines, i) {
|
|
89
|
+
let depth = 0;
|
|
90
|
+
let seenOpen = false;
|
|
91
|
+
const body = [];
|
|
92
|
+
for (let j = i; j < lines.length; j++) {
|
|
93
|
+
const l = lines[j];
|
|
94
|
+
body.push(l);
|
|
95
|
+
for (const ch of l.raw) {
|
|
96
|
+
if (ch === "{") {
|
|
97
|
+
depth += 1;
|
|
98
|
+
seenOpen = true;
|
|
99
|
+
}
|
|
100
|
+
else if (ch === "}")
|
|
101
|
+
depth -= 1;
|
|
102
|
+
}
|
|
103
|
+
if (seenOpen && depth <= 0)
|
|
104
|
+
return { lines: body, unterminated: false };
|
|
105
|
+
}
|
|
106
|
+
return { lines: body, unterminated: seenOpen };
|
|
107
|
+
}
|
|
108
|
+
export function scanR51(files) {
|
|
109
|
+
const scan = emptyScan();
|
|
110
|
+
const builders = discoverWriteBuilders(files);
|
|
111
|
+
for (const a of builders.ambiguous) {
|
|
112
|
+
scan.cnj.push({
|
|
113
|
+
rel: a.rel,
|
|
114
|
+
n: a.n,
|
|
115
|
+
note: `write builder "${a.name}" = customMutation(${a.base}, …) — its base "${a.base}" resolves to neither \`mutation\` nor \`internalMutation\`, so whether definitions using it are public writes cannot be enumerated; R-51 refuses rather than certify a surface it cannot see`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
for (const [name, at] of builders.gated) {
|
|
119
|
+
let deps = 0;
|
|
120
|
+
for (const f of files) {
|
|
121
|
+
if (!isJudgeable(f))
|
|
122
|
+
continue;
|
|
123
|
+
for (const l of f.lines) {
|
|
124
|
+
if (!code(l))
|
|
125
|
+
continue;
|
|
126
|
+
if (definitionOpener(l.raw, new Set([name])))
|
|
127
|
+
deps += 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
scan.cnj.push({
|
|
131
|
+
rel: at.rel,
|
|
132
|
+
n: at.n,
|
|
133
|
+
note: `customMutation builder "${name}" gates every write built from it in its customCtx (requireWriteAccess/an org gate that can throw), and ${deps} public write(s) route through it — whether a client can reach one BEFORE an org is resolved (so the no-org throw is an uncaught Server Error, not a refusal) is not decidable from convex/. R-51 refuses (a human confirms the shell cannot issue these pre-org, or the write returns a typed unauthorized result / carries a write-contract marker) — never a silent PASS`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
for (const f of files) {
|
|
137
|
+
if (!isJudgeable(f))
|
|
138
|
+
continue;
|
|
139
|
+
const lines = f.lines;
|
|
140
|
+
for (let i = 0; i < lines.length; i++) {
|
|
141
|
+
if (!code(lines[i]))
|
|
142
|
+
continue;
|
|
143
|
+
const opener = definitionOpener(lines[i].raw, builders.publicMutation);
|
|
144
|
+
if (!opener)
|
|
145
|
+
continue;
|
|
146
|
+
// a write built through a GATED customMutation wrapper is covered by the
|
|
147
|
+
// builder-level refusal above — skip the per-handler pass.
|
|
148
|
+
if (builders.gated.has(opener.builder))
|
|
149
|
+
continue;
|
|
150
|
+
const block = mutationBlock(lines, i);
|
|
151
|
+
i += block.lines.length - 1;
|
|
152
|
+
const fn = opener.name;
|
|
153
|
+
if (block.unterminated) {
|
|
154
|
+
scan.cnj.push({
|
|
155
|
+
rel: f.rel,
|
|
156
|
+
n: block.lines[0].n,
|
|
157
|
+
note: `${fn}: a public mutation block could not be delimited (unbalanced braces) — whether its no-org path throws or returns a typed unauthorized result is not statically decidable; a human must judge (never a silent PASS)`,
|
|
158
|
+
});
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const blockText = block.lines
|
|
162
|
+
.filter(code)
|
|
163
|
+
.map((l) => l.raw)
|
|
164
|
+
.join("\n");
|
|
165
|
+
const blockAll = block.lines.map((l) => l.raw).join("\n");
|
|
166
|
+
const throwsOnNoOrg = ORG_THROW.test(blockText) || WRITE_GATE_HELPER.test(blockText);
|
|
167
|
+
const typedUnauth = TYPED_UNAUTH_RETURN.test(blockText);
|
|
168
|
+
const hasMarker = CONTRACT_MARKER.test(blockAll);
|
|
169
|
+
if (!throwsOnNoOrg && !typedUnauth && !hasMarker)
|
|
170
|
+
continue; // not a subject
|
|
171
|
+
if (hasMarker || (typedUnauth && !throwsOnNoOrg)) {
|
|
172
|
+
scan.conform.push({
|
|
173
|
+
rel: f.rel,
|
|
174
|
+
n: block.lines[0].n,
|
|
175
|
+
note: `${fn}: public mutation returns a typed unauthorized result on the no-org path (or documents a write-contract) — a pre-org client receives a shape it can render, not an uncaught Server Error`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
else if (throwsOnNoOrg) {
|
|
179
|
+
// The React shell is not in convex/, so the doctor CANNOT decide whether a
|
|
180
|
+
// client reaches this write BEFORE an org (an R-51 uncaught-Server-Error
|
|
181
|
+
// crash) or only an imperative caller does (an R-16 refusal it catches).
|
|
182
|
+
// It therefore REFUSES (could-not-judge) naming the write — never a silent
|
|
183
|
+
// PASS, and never a false `violate` on a server-side R-16 refusal. Pi's
|
|
184
|
+
// spec: "doctor refuses (CNJ) when it cannot judge the client; CNJ != vert".
|
|
185
|
+
scan.cnj.push({
|
|
186
|
+
rel: f.rel,
|
|
187
|
+
n: block.lines[0].n,
|
|
188
|
+
note: `${fn}: public mutation throws on the missing-org path (org-keyed ConvexError or requireWriteAccess/requireWorkspaceId) with no typed-unauthorized return and no documented contract. Whether a client shell issues this write BEFORE an org is resolved (an uncaught Server Error — the R-51 defect) or only an imperative caller reaches it (an R-16 refusal it catches) is not decidable from convex/. R-51 refuses (return a typed unauthorized result, or a write-contract marker if a client cannot reach it pre-org) — never a silent PASS`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return scan;
|
|
194
|
+
}
|
|
195
|
+
export const R51 = srcDetector("R-51", "R-51 a public mutation returns a typed unauthorized result on the no-org path, never an uncaught throw a pre-org client shell cannot render", "a public write either returns a typed unauthorized result / documents its client contract, or a human confirms a pre-org client cannot reach it — it never throws NO_ORG on a path an ordinary render can hit", scanR51, "R-51: no public mutation matched in the scanned tree — no write to certify. NOT a clean bill.");
|
|
196
|
+
export const SOURCE_WRITE_ISOLATION_DETECTORS = [
|
|
197
|
+
{ detector: R51, ruleId: "R-51", standardPin: SOURCE_STANDARD_PIN },
|
|
198
|
+
];
|
|
@@ -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-
|
|
3
|
+
export declare const STANDARD_FILE = "backend-standard-dcec1ba.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-
|
|
5
|
+
// fixture `fixtures/backend-standard-dcec1ba.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-
|
|
16
|
+
export const STANDARD_FILE = "backend-standard-dcec1ba.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. */
|
package/dist/not-checkable.js
CHANGED
|
@@ -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
|
|
19
|
+
"R-48": "R-48 authority-totality detector pending — task k17chpr4kzp399j3s7r7edpm4s8cscbz; text pinned at dcec1ba 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
|
package/dist/registry.d.ts
CHANGED
|
@@ -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 @
|
|
3
|
+
export declare const STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ dcec1ba31091accfe9b8840fab736815306307c5 (elpiarthera/ElPi-Corp)";
|
|
4
4
|
export interface RegistryEntry {
|
|
5
5
|
detector: Detector;
|
|
6
6
|
ruleId: string;
|
package/dist/registry.js
CHANGED
|
@@ -14,13 +14,14 @@ import { SOURCE_LIFECYCLE_DETECTORS } from "./detectors/source/lifecycle.js";
|
|
|
14
14
|
import { SOURCE_READBUDGET_DETECTORS } from "./detectors/source/readbudget.js";
|
|
15
15
|
import { SOURCE_REFUSAL_DETECTORS } from "./detectors/source/refusal.js";
|
|
16
16
|
import { SOURCE_SECURITY_DETECTORS } from "./detectors/source/security.js";
|
|
17
|
+
import { SOURCE_WRITE_ISOLATION_DETECTORS } from "./detectors/source/write-isolation.js";
|
|
17
18
|
import { SOURCE_WRITESAFETY_DETECTORS } from "./detectors/source/writesafety.js";
|
|
18
19
|
import { R41, SIBLING_PIN } from "./detectors/standard/r41-frontier.js";
|
|
19
20
|
import { R46_STANDARD_PIN, STANDARD_SOURCE_DETECTORS, } from "./detectors/standard/r46-returns-validator.js";
|
|
20
21
|
import { STANDARD_DOC_DETECTORS } from "./detectors/standard/standard-doc.js";
|
|
21
22
|
import { makeR14, makeR38 } from "./detectors/structural.js";
|
|
22
23
|
/** The standard both pins the whole wave against. */
|
|
23
|
-
export const STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @
|
|
24
|
+
export const STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ dcec1ba31091accfe9b8840fab736815306307c5 (elpiarthera/ElPi-Corp)";
|
|
24
25
|
export const REGISTRY = [
|
|
25
26
|
...PER_ROW_PREDICATES.map((spec) => ({
|
|
26
27
|
detector: makeOracleDetector(spec),
|
|
@@ -108,6 +109,12 @@ export const REGISTRY = [
|
|
|
108
109
|
// workspaces:ensureCurrent + prefs:sidebar). Extends the R-11 isolation
|
|
109
110
|
// family; a mutation refusing an unauthorized write (R-16) is out of scope.
|
|
110
111
|
...SOURCE_ISOLATION_DETECTORS,
|
|
112
|
+
// R-51 — the WRITE counterpart of R-50: a public mutation whose no-org path
|
|
113
|
+
// throws NO_ORG (requireWriteAccess/getEffectiveWorkspaceId) into a pre-org
|
|
114
|
+
// client shell is a VIOLATION unless it returns a typed unauthorised result or
|
|
115
|
+
// documents a client contract; a gated customMutation wrapper (writeMutation) is
|
|
116
|
+
// refused (could-not-judge) since client reachability is not visible from convex/.
|
|
117
|
+
...SOURCE_WRITE_ISOLATION_DETECTORS,
|
|
111
118
|
...SOURCE_CONTRACT_DETECTORS,
|
|
112
119
|
...IDENTITY_DETECTORS,
|
|
113
120
|
// The four STANDARD-DOCUMENT detectors (R-42/R-15/R-32/R-39): each reads the
|
|
@@ -334,6 +334,7 @@ The five: **production-ready · enterprise-grade · highly secure · token-effic
|
|
|
334
334
|
| R-47 an incompletable read refuses, never returns a partial set | production-ready · highly secure |
|
|
335
335
|
| R-48 authority total over its rows; a declared tier unused does not exist | highly secure · multi-organisation multi-tenant · enterprise-grade |
|
|
336
336
|
| R-50 org isolation on a subscribed read is a typed empty result, never an uncaught throw | highly secure · multi-organisation multi-tenant · production-ready |
|
|
337
|
+
| R-51 a public write returns a typed unauthorised result on no-org, never an uncaught throw into a pre-org client | highly secure · multi-organisation multi-tenant · production-ready |
|
|
337
338
|
|
|
338
339
|
**Deletion candidates — rules serving none of the five: none.**
|
|
339
340
|
|
|
@@ -649,7 +650,10 @@ R-5 and R-10 make each mutation ask the right question caller-by-caller — *may
|
|
|
649
650
|
**Detector spec.** *control kind:* static (+ delivery gate on the tier-usage half). *reads:* per governed table (derived from `schema.ts`), the set of caller tiers each mutation on it accepts, derived from the delivered mutation code — never a list the check keeps about itself; and the R-5 tier vocabulary from the pinned standard. *red:* a table whose union of accepting tiers cannot reach a row created by a non-actor (a `"system"`/webhook/migration/scheduler creator with no tier that governs it); OR a tier declared in the pinned vocabulary and used by zero mutations. *green:* every governed table has at least one tier reaching all its rows including non-actor-created ones, and every declared tier is used by at least one mutation. *kind:* judgement — deriving each mutation's accepting tiers and the tier-usage census is mechanical; whether a table's rows are genuinely all reachable (the non-actor-creator case, and whether an unreachable row is an accepted deliberate lock) is human, so the detector names the unreachable-row tables and the unused tiers as a work list and returns could-not-judge on the disposition, never present/absent by vacuity. **Coverage fails on itself:** the table list is derived from the schema; every table is ANALYSED or SKIPPED-with-a-written-reason; a table neither analysed nor exempted, or one the check cannot parse, FAILS — the whole point of this rule is a defect that reports nothing.
|
|
650
651
|
|
|
651
652
|
**R-50 — org isolation on a reactively-subscribed read is a TYPED EMPTY / UNAUTHORISED RESULT, never an uncaught throw.** A public `query` a client subscribes to reactively (a layout's `useQuery`) that resolves the caller's organisation and, on the missing-org path, **throws** — an org-keyed `ConvexError`, or a `requireOrgId`/`requireWorkspaceId`/`ensureCurrent` helper that throws — hands the subscribing client an **uncaught Server Error** it has no imperative call site to catch. The isolation is correct in intent and wrong in shape: a subscription cannot `try/catch` a query the way a mutation's caller can, so the throw surfaces as a crashed render. Such a read returns a **typed empty / unauthorised result** the client can render (the fail-closed org resolver — VP's `withOrgScope` returns an empty scope on no identity, never throws), or it documents that the layout must not subscribe. This is the 2026-08-30 vCRM-Preview incident: `workspaces:ensureCurrent` and `prefs:sidebar` threw `ConvexError` uncaught while the app shell subscribed, and the page rendered "NoOrg copy, no org button" over an unhandled throw — the operator saw a broken screen, not a refusal. **Frontier — this EXTENDS the R-11 isolation family, it does not restate it.** **R-11** governs *where* the scope key is applied — an in-query index predicate, never a post-query `.filter` — a correctness/cost property of a query that already knows its org. R-50 governs the case *before* that: the caller has **no** org, and the question is the **shape of the answer**, not the locus of the predicate — a query fully R-11-conformant still fails R-50 if its no-org branch throws into a subscription. **R-16** is the error contract for a read that *may* refuse: R-50 is the narrower law that a **reactively-subscribed** read may not refuse by *throwing at all* — because its consumer is a subscription, the only honest refusal is a typed unauthorised *value*, not an exception. And R-50 is deliberately **narrower than "any org throw"**: a **mutation** that refuses an unauthorised write via `requireOrgAdmin` throwing `RBAC_DENIED` is **correct** and out of scope — a write refusal is caught by its imperative caller, never surfaced as an uncaught subscription error. Conflating the two would flag every legitimate admin-gated mutation; R-50 governs reactive reads only. *(Source: the 2026-08-30 vCRM-Preview operator report — `workspaces:ensureCurrent` + `prefs:sidebar` uncaught `ConvexError` on a subscribed layout; and VP's own `convex/lib/auth.ts` `withOrgScope`, the fail-closed non-throwing resolver that returns an empty scope rather than throw, the shape this rule requires.)*
|
|
652
|
-
**Detector spec.** *control kind:* static (source scan over the public query surface). *reads:* the target's `convex/` source; every public `query` opener (not `internalQuery`, not `mutation`/`action`) and its brace-delimited handler body. *red:* a public query whose body throws on the missing-org path (an org-keyed `ConvexError` — `NO_ORG`/`ORG_REQUIRED`/`RBAC_DENIED` — or a throw-on-no-org helper) with **no** fail-closed non-throwing resolver (`withOrgScope`) and **no** documented isolation contract marker in the same handler. *green:* a public query that carries the fail-closed resolver or a documented client contract — the subscribing client always receives a typed shape. *out of scope:* a `mutation`/`action` (imperative caller can catch) and a query that does neither org-throw nor fail-closed resolve (not this rule's subject). *kind:* mechanical — the presence of an org-isolation throw and the presence of a fail-closed resolver / contract marker are both read from the delivered source; `could-not-judge` is reserved for a query block whose braces cannot be delimited, never for "a person should read this". **Coverage fails on itself:** a tree with no public query returns could-not-judge naming the empty scan, never a clean bill.
|
|
653
|
+
**Detector spec.** *control kind:* static (source scan over the public query surface). *reads:* the target's `convex/` source; every public `query` opener (not `internalQuery`, not `mutation`/`action`) and its brace-delimited handler body. *red:* a public query whose body throws on the missing-org path (an org-keyed `ConvexError` — `NO_ORG`/`ORG_REQUIRED`/`RBAC_DENIED` — or a throw-on-no-org helper) with **no** fail-closed non-throwing resolver (`withOrgScope`) and **no** documented isolation contract marker in the same handler. *green:* a public query that carries the fail-closed resolver or a documented client contract — the subscribing client always receives a typed shape. *out of scope:* a `mutation`/`action` (imperative caller can catch — but see **R-51** for the write a client shell issues before an org exists) and a query that does neither org-throw nor fail-closed resolve (not this rule's subject). *kind:* mechanical — the presence of an org-isolation throw and the presence of a fail-closed resolver / contract marker are both read from the delivered source; `could-not-judge` is reserved for a query block whose braces cannot be delimited, never for "a person should read this". **Coverage fails on itself:** a tree with no public query returns could-not-judge naming the empty scan, never a clean bill.
|
|
654
|
+
|
|
655
|
+
**R-51 — a public MUTATION whose no-org path throws NO_ORG into a pre-org client is a VIOLATION, not a refusal.** The write counterpart of R-50. A signed-in product write (`companies.create`, `contacts.create`, `fields.createDefinition`, `ask.send`) that resolves the org through `getEffectiveWorkspaceId` / `requireWriteAccess` and **throws** `ConvexError` NO_ORG when no org is resolved hands the client an **uncaught Server Error** on an ordinary render path — because the app **shell can issue the write before an org exists**. In the 2026-08-30 vCRM incident the reads were typed-empty (R-50 clean), so `/app` looked alive, and then every write on it threw NO_ORG: the second half of the same crash, which R-50 could not name because it excluded mutations. Such a write returns a **typed unauthorised result** the client can render, or it documents (a `write-contract:` marker) that a client cannot reach it before an org. **Frontier — R-51 is NOT R-16 and NOT R-50.** **R-16** is the error contract for a write that legitimately **refuses a chosen unauthorised action** — a `{code,field/scope,message}` throw is correct there, because an imperative caller catches it. R-51 is the narrower case where the caller is the **shell's ordinary render**, reaching the write **before** an org is resolved: the throw is then not a refusal of a chosen action but a crash, and the honest shape is a typed value, not an exception (or the write is specified unreachable pre-org). **R-50** is the same defect on the READ side (a subscribed query); R-51 is the WRITE side R-50's own frontier pointed at ("a `mutation`/`action` … out of scope"), so no gap-table cell is left blank. Because the React shell is **not in `convex/`**, the detector cannot see whether a client reaches a write pre-org, so it **never silent-PASSes a throwing write**: it refuses (could-not-judge) naming the write / its `customMutation` wrapper when it cannot judge the client obligation, and is red only on a bare throw with no typed-unauthorised return and no contract. *(Source: the 2026-08-30 vCRM operator report — four signed-in writes throwing `ConvexError` NO_ORG through `convex/model/access.ts` `requireWriteAccess` + `getEffectiveWorkspaceId`, while R-50-clean reads made the page look alive.)*
|
|
656
|
+
**Detector spec.** *control kind:* static (source scan over the public mutation surface). *reads:* the target's `convex/` source; every public `mutation` opener and every write built through a `customMutation` wrapper (the `writeMutation = customMutation(mutation, customCtx(requireWriteAccess))` idiom), and its brace-delimited handler / the wrapper's `customCtx`. *red:* a bare public mutation whose no-org path throws (an org-keyed `ConvexError` or `requireWriteAccess`/`requireWorkspaceId`/`getEffectiveWorkspaceId`) with **no** typed-unauthorised return and **no** documented `write-contract` marker. *green:* a public mutation that returns a typed unauthorised result on the no-org path, or carries a documented client contract. *could-not-judge (never a silent PASS):* a write built through a **gated** `customMutation` wrapper (its `customCtx` runs a throwing write/org gate) — the throw lives in the shared wrapper and client reachability is not visible from `convex/`, so the detector names the wrapper and counts its dependent writes; also an unresolvable-base wrapper, and an undelimitable block. *out of scope:* a `query` (that is R-50), and a `customMutation(internalMutation, …)` wrapper. *kind:* mechanical. **Coverage fails on itself:** a tree with no public mutation returns could-not-judge naming the empty scan, never a clean bill.
|
|
653
657
|
|
|
654
658
|
---
|
|
655
659
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vantageos/backend-doctor",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "A conformance instrument that measures the VantagePeers backend against the pinned backend standard. The THIRD doctor built ON the @vantageos/mcp-doctor engine — never a third engine. It consumes runDoctor/renderTable and adds only detectors.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cli.js",
|