@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
@@ -1,14 +1,13 @@
1
1
  import type { Detector } from "../engine-types.js";
2
- import { type OracleRow } from "../oracle.js";
2
+ import { type OracleRow, type WriterTierTable } from "../oracle.js";
3
3
  /**
4
4
  * R-14 — inventory-schema parity, contract → inventory, one direction.
5
5
  * A merged reader/writer authority column is "non-conforming by construction"
6
- * (§12). The oracle's column 9 `rbac_qui` is that single merged column.
6
+ * (§12). Writer authority is a separate column only when the side-car declares it.
7
7
  */
8
- export declare function makeR14(columns?: readonly string[]): Detector;
8
+ export declare function makeR14(columns?: readonly string[], rowsProvider?: () => OracleRow[], sideCar?: () => WriterTierTable): Detector;
9
9
  /**
10
- * R-38 — the completeness gate over the thirteen §2 fields. The oracle cannot
11
- * satisfy the contract because reader and writer authority are not two separate
12
- * fields (both map to `rbac_qui`).
10
+ * R-38 — the completeness gate over the thirteen §2 fields. Writer authority is
11
+ * complete only when the side-car records a tier for every write tool.
13
12
  */
14
- export declare function makeR38(columns?: readonly string[], rowsProvider?: () => OracleRow[]): Detector;
13
+ export declare function makeR38(columns?: readonly string[], rowsProvider?: () => OracleRow[], sideCar?: () => WriterTierTable): Detector;
@@ -1,54 +1,80 @@
1
1
  // The two STRUCTURAL detectors — R-14 and R-38. They judge the inventory SCHEMA
2
- // against the §2 contract, not a single row. Run against THIS oracle they MUST
3
- // return `non-conforming`, because §2 splits authority into two distinct fields
4
- // (reader R-5, writer R-10) and the oracle carries a SINGLE merged `rbac_qui`
5
- // column. Agreeing with the oracle here would be certifying the defect the
6
- // instrument exists to find — so these are NOT tuned to pass.
7
- import { CONTRACT_FIELDS, READER_AUTHORITY_COLUMN, WRITER_AUTHORITY_COLUMN, authorityIsMerged } from "../contract.js";
8
- import { ORACLE_COLUMNS, oracleLabel, readOracle, refuseWithoutOracle, requireOracle, } from "../oracle.js";
2
+ // against the §2 contract, not a single row.
3
+ //
4
+ // §2 splits authority into two contract fields (reader R-5, writer R-10). The
5
+ // oracle carries ONE authority column, `rbac_qui`; writer authority has a column
6
+ // of its own only through the writer-tier side-car (`writer_tier`, joined on
7
+ // `outil` — see resolveAuthoritySplit in src/contract.ts). So:
8
+ // side-car absent, or missing a write tool -> non-conforming (merged/omitted)
9
+ // side-car unreadable -> could-not-judge
10
+ // side-car complete over every write tool -> the split is declared
11
+ // An oracle alone is never green here: agreeing with a merged column would be
12
+ // certifying the defect the instrument exists to find.
13
+ import { CONTRACT_FIELDS, READER_AUTHORITY_COLUMN, resolveAuthoritySplit, SPLIT_WRITER_AUTHORITY_COLUMN, } from "../contract.js";
14
+ import { isInventoryRow, ORACLE_COLUMNS, oracleLabel, readOracle, readWriterTier, refuseWithoutOracle, requireOracle, } from "../oracle.js";
15
+ import { itemCount, withCount } from "../violation-count.js";
16
+ import { WRITE_VERBS } from "./shared.js";
9
17
  const csvAnchor = () => `${oracleLabel()} col#9 rbac_qui`;
18
+ function splitFor(rows, sideCar) {
19
+ const writeTools = rows
20
+ .filter(isInventoryRow)
21
+ .filter((r) => WRITE_VERBS.has(r.by.crud))
22
+ .map((r) => r.by.outil);
23
+ return resolveAuthoritySplit(writeTools, sideCar());
24
+ }
25
+ function unjudgeable(id, reason) {
26
+ const verdict = `${id.split(" ")[0]}: could not judge — ${reason}.`;
27
+ return { id, anchor: csvAnchor(), state: "could-not-judge", verdict, reason: verdict };
28
+ }
10
29
  /**
11
30
  * R-14 — inventory-schema parity, contract → inventory, one direction.
12
31
  * A merged reader/writer authority column is "non-conforming by construction"
13
- * (§12). The oracle's column 9 `rbac_qui` is that single merged column.
32
+ * (§12). Writer authority is a separate column only when the side-car declares it.
14
33
  */
15
- export function makeR14(columns = ORACLE_COLUMNS) {
34
+ export function makeR14(columns = ORACLE_COLUMNS, rowsProvider = readOracle, sideCar = readWriterTier) {
35
+ const id = "R-14 inventory-schema parity";
16
36
  return {
17
- id: "R-14 inventory-schema parity",
37
+ id,
18
38
  // The verdict is about the oracle's schema, so with no oracle bound there is
19
39
  // no subject: refuse (could-not-judge), never a verdict on the tool's own constant.
20
- run: () => refuseWithoutOracle("R-14 inventory-schema parity", () => {
40
+ run: () => refuseWithoutOracle(id, () => {
21
41
  requireOracle();
22
- const authorityColumns = columns.filter((c) => c === "rbac_qui");
23
- if (authorityIsMerged() || authorityColumns.length < 2) {
24
- return {
25
- id: "R-14 inventory-schema parity",
42
+ const missing = CONTRACT_FIELDS.flatMap((f) => f.columns.filter((c) => !columns.includes(c)).map((c) => `${f.field}→${c}`));
43
+ const split = splitFor(rowsProvider(), sideCar);
44
+ if (split.kind === "unjudgeable")
45
+ return unjudgeable(id, split.reason);
46
+ if (split.kind === "merged" || missing.length > 0) {
47
+ return withCount({
48
+ id,
26
49
  anchor: csvAnchor(),
27
50
  state: "non-conforming",
28
- verdict: "R-14: the inventory holds a SINGLE merged authority column `rbac_qui` (col#9), " +
29
- "but §2 splits authority into two distinct contract fields — reader authority (R-5) " +
30
- "and writer authority (R-10). A merged authority column is non-conforming by construction: " +
31
- "the reader/writer divergence R-10 and R-7 exist to record cannot be written in it.",
32
- };
51
+ verdict: "R-14: §2 splits authority into two distinct contract fields — reader authority (R-5) " +
52
+ `and writer authority (R-10) — but the inventory's only authority column is \`${READER_AUTHORITY_COLUMN}\` (col#9)` +
53
+ (split.kind === "merged" ? `: ${split.reason}. ` : ". ") +
54
+ "A merged authority column is non-conforming by construction: the reader/writer divergence " +
55
+ "R-10 and R-7 exist to record cannot be written in it." +
56
+ (missing.length ? ` Also missing: ${missing.join(", ")}.` : ""),
57
+ }, itemCount(1));
33
58
  }
34
59
  return {
35
- id: "R-14 inventory-schema parity",
36
- anchor: oracleLabel(),
60
+ id,
61
+ anchor: `${oracleLabel()} + ${split.sideCar}`,
37
62
  state: "present",
38
- verdict: "R-14: every §2 contract field present; reader and writer authority are two separate columns.",
63
+ verdict: `R-14: every §2 contract field present; reader authority is \`${READER_AUTHORITY_COLUMN}\`, writer authority is ` +
64
+ `\`${SPLIT_WRITER_AUTHORITY_COLUMN}\` in ${split.sideCar}, recorded for all ${split.writeTools} write tools.`,
39
65
  };
40
66
  }),
41
67
  };
42
68
  }
43
69
  /**
44
- * R-38 — the completeness gate over the thirteen §2 fields. The oracle cannot
45
- * satisfy the contract because reader and writer authority are not two separate
46
- * fields (both map to `rbac_qui`).
70
+ * R-38 — the completeness gate over the thirteen §2 fields. Writer authority is
71
+ * complete only when the side-car records a tier for every write tool.
47
72
  */
48
- export function makeR38(columns = ORACLE_COLUMNS, rowsProvider = readOracle) {
73
+ export function makeR38(columns = ORACLE_COLUMNS, rowsProvider = readOracle, sideCar = readWriterTier) {
74
+ const id = "R-38 thirteen-field completeness";
49
75
  return {
50
- id: "R-38 thirteen-field completeness",
51
- run: () => refuseWithoutOracle("R-38 thirteen-field completeness", () => {
76
+ id,
77
+ run: () => refuseWithoutOracle(id, () => {
52
78
  requireOracle();
53
79
  const colSet = new Set(columns);
54
80
  const missing = [];
@@ -58,29 +84,35 @@ export function makeR38(columns = ORACLE_COLUMNS, rowsProvider = readOracle) {
58
84
  missing.push(`${f.field}→${col}`);
59
85
  }
60
86
  }
61
- const authorityMerged = READER_AUTHORITY_COLUMN === WRITER_AUTHORITY_COLUMN && colSet.has(READER_AUTHORITY_COLUMN);
62
- if (authorityMerged || missing.length > 0) {
63
- const rows = rowsProvider();
87
+ const rows = rowsProvider();
88
+ const split = splitFor(rows, sideCar);
89
+ if (split.kind === "unjudgeable")
90
+ return unjudgeable(id, split.reason);
91
+ if (split.kind === "merged" || missing.length > 0) {
92
+ const uncovered = split.kind === "merged" ? new Set(split.uncovered) : new Set();
64
93
  const sample = rows
65
- .filter((r) => !r.malformed && r.by.rbac_qui && r.by.table !== "TOTAL")
94
+ .filter((r) => isInventoryRow(r) && r.by.rbac_qui && (uncovered.size === 0 || uncovered.has(r.by.outil)))
66
95
  .slice(0, 3)
67
96
  .map((r) => `row#${r.n} "${r.by.outil || r.by.table}" rbac_qui="${r.by.rbac_qui}"`)
68
97
  .join("; ");
69
- return {
70
- id: "R-38 thirteen-field completeness",
98
+ return withCount({
99
+ id,
71
100
  anchor: csvAnchor(),
72
101
  state: "non-conforming",
73
- verdict: `R-38: the oracle cannot satisfy the thirteen-field §2 contract — reader authority (R-5) and ` +
74
- `writer authority (R-10) are not two separate fields; both map to the single column ` +
75
- `\`rbac_qui\` (col#9).${missing.length ? ` Also missing: ${missing.join(", ")}.` : ""} ` +
76
- `Evidence: ${sample}.`,
77
- };
102
+ verdict: "R-38: the oracle cannot satisfy the thirteen-field §2 contract — reader authority (R-5) and " +
103
+ `writer authority (R-10) are not two separate fields for every tool; writer authority falls back to ` +
104
+ `\`${READER_AUTHORITY_COLUMN}\` (col#9)` +
105
+ (split.kind === "merged" ? `: ${split.reason}.` : ".") +
106
+ (missing.length ? ` Also missing: ${missing.join(", ")}.` : "") +
107
+ ` Evidence: ${sample}.`,
108
+ }, itemCount(1));
78
109
  }
79
110
  return {
80
- id: "R-38 thirteen-field completeness",
81
- anchor: oracleLabel(),
111
+ id,
112
+ anchor: `${oracleLabel()} + ${split.sideCar}`,
82
113
  state: "present",
83
- verdict: "R-38: all thirteen §2 contract fields present per table, each within its vocabulary.",
114
+ verdict: "R-38: all thirteen §2 contract fields present per table; writer authority is " +
115
+ `\`${SPLIT_WRITER_AUTHORITY_COLUMN}\` in ${split.sideCar} for all ${split.writeTools} write tools.`,
84
116
  };
85
117
  }),
86
118
  };
@@ -1,3 +1,4 @@
1
+ import type { DetectorResult } from "./engine-types.js";
1
2
  /**
2
3
  * The deferral bucket. R-1..R-47 each carry an executable control (registry.ts),
3
4
  * so none is deferred to "a human decides". R-48 (authority-totality, the #130
@@ -9,7 +10,6 @@
9
10
  * moves to registry.ts and this entry is removed. The export is consumed by
10
11
  * coverage.ts / coverage-cli.ts / cli.ts.
11
12
  */
12
- import type { DetectorResult } from "./engine-types.js";
13
13
  export declare const NOT_MECHANICALLY_CHECKABLE: Record<string, string>;
14
14
  /**
15
15
  * One `could-not-judge` DetectorResult per NOT_MECHANICALLY_CHECKABLE rule, so
@@ -15,8 +15,20 @@
15
15
  // input is vendored. They were built, not relabelled. The bucket was not renamed to a
16
16
  // gentler word — it was emptied.
17
17
  import { STANDARD_FILE } from "./detectors/standard/_doc.js";
18
+ /**
19
+ * The deferral bucket. R-1..R-47 each carry an executable control (registry.ts),
20
+ * so none is deferred to "a human decides". R-48 (authority-totality, the #130
21
+ * class) was added to the standard at the 51ec7af1 re-pin AFTER its detector; its
22
+ * text is now pinned so coverage enumerates 48, but no detector exists yet — so it
23
+ * is reported here as NOT_MECHANICALLY_CHECKABLE with a written reason rather than
24
+ * silently omitted, which would let coverage pretend 47 is the whole document. The
25
+ * detector is task k17chpr4kzp399j3s7r7edpm4s8cscbz (after D5); when it lands, R-48
26
+ * moves to registry.ts and this entry is removed. The export is consumed by
27
+ * coverage.ts / coverage-cli.ts / cli.ts.
28
+ */
18
29
  export const NOT_MECHANICALLY_CHECKABLE = {
19
30
  "R-48": "R-48 authority-totality detector pending — task k17chpr4kzp399j3s7r7edpm4s8cscbz; text pinned at 4bcd605 so coverage cannot pretend 47 is complete. No mechanical control yet; R-48 is enumerated and visible, never silently dropped.",
31
+ "R-57": "R-57 no local identity primitive is MEASURED, under the doctor-owned id CR-2 (src/detectors/source/local-identity.ts, PR #61): the standard at bacc54a numbers as R-57 the rule CR-2 already enforces. Its count is reported once, under the key CR-2, because the vantage-peers deploy-gate baseline reads CR-2; reporting it under both ids would count one defect as two rules. Renaming CR-2 to R-57 in the evidence output is a follow-up that must move that baseline in the same delivery. R-57 is enumerated and visible here, never silently dropped, and never reported present.",
20
32
  };
21
33
  /**
22
34
  * 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 @ 4bcd605656ceff52d4f1593a9176cd6b988471be (elpiarthera/ElPi-Corp)";
3
+ export declare const STANDARD_PIN = "vantagepeers/backend-standard/backend-standard.md @ bacc54a53d5f6f9f20e5254908e2b46807902faa (elpiarthera/ElPi-Corp main; the door-audit rules R-54..R-57, merged as ElPi-Corp #239 = 7d7df89 before the 2026-10-10 history rewrite, same blob 85d8530e)";
4
4
  export interface RegistryEntry {
5
5
  detector: Detector;
6
6
  ruleId: string;
package/dist/registry.js CHANGED
@@ -7,13 +7,18 @@ import { PER_ROW_PREDICATES } from "./detectors/predicates.js";
7
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
+ import { SOURCE_CREDENTIAL_HYGIENE_DETECTORS } from "./detectors/source/credential-hygiene.js";
11
+ import { SOURCE_CROSS_TENANT_WRITE_DETECTORS } from "./detectors/source/cross-tenant-write.js";
10
12
  import { IDENTITY_DETECTORS } from "./detectors/source/identity.js";
13
+ import { SOURCE_IDENTITY_BY_ID_DETECTORS } from "./detectors/source/identity-by-id.js";
11
14
  import { SOURCE_ISOLATION_DETECTORS } from "./detectors/source/isolation.js";
12
15
  import { SOURCE_JOBS_DETECTORS } from "./detectors/source/jobs.js";
16
+ import { SOURCE_NARROW_THEN_BOUND_DETECTORS } from "./detectors/source/narrow-then-bound.js";
13
17
  import { SOURCE_LIFECYCLE_DETECTORS } from "./detectors/source/lifecycle.js";
14
18
  import { SOURCE_READBUDGET_DETECTORS } from "./detectors/source/readbudget.js";
15
19
  import { SOURCE_REFUSAL_DETECTORS } from "./detectors/source/refusal.js";
16
20
  import { SOURCE_SECURITY_DETECTORS } from "./detectors/source/security.js";
21
+ import { SOURCE_SERVICE_ACCOUNT_CLAIM_DETECTORS } from "./detectors/source/service-account-claim.js";
17
22
  import { SOURCE_WRITE_ISOLATION_DETECTORS } from "./detectors/source/write-isolation.js";
18
23
  import { SOURCE_WRITESAFETY_DETECTORS } from "./detectors/source/writesafety.js";
19
24
  import { R41, SIBLING_PIN } from "./detectors/standard/r41-frontier.js";
@@ -21,8 +26,9 @@ import { R46_STANDARD_PIN, STANDARD_SOURCE_DETECTORS, } from "./detectors/standa
21
26
  import { STANDARD_DOC_DETECTORS } from "./detectors/standard/standard-doc.js";
22
27
  import { makeR14, makeR38 } from "./detectors/structural.js";
23
28
  import { STATE_RANK } from "./state-rank.js";
29
+ import { countOf, sumCounts, withCount } from "./violation-count.js";
24
30
  /** The standard both pins the whole wave against. */
25
- export const STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ 4bcd605656ceff52d4f1593a9176cd6b988471be (elpiarthera/ElPi-Corp)";
31
+ export const STANDARD_PIN = "vantagepeers/backend-standard/backend-standard.md @ bacc54a53d5f6f9f20e5254908e2b46807902faa (elpiarthera/ElPi-Corp main; the door-audit rules R-54..R-57, merged as ElPi-Corp #239 = 7d7df89 before the 2026-10-10 history rewrite, same blob 85d8530e)";
26
32
  /**
27
33
  * Merge two detectors that judge halves of the SAME rule into one result: the
28
34
  * worst state wins (non-conforming > could-not-judge > absent > present, the
@@ -40,7 +46,7 @@ export function mergeHalves(ruleId, a, b) {
40
46
  const reasons = [ra, rb]
41
47
  .filter((r) => r.state === "could-not-judge")
42
48
  .map((r) => r.reason ?? r.verdict);
43
- return {
49
+ const merged = {
44
50
  id: b.id,
45
51
  anchor: decider.anchor,
46
52
  state: decider.state,
@@ -49,6 +55,13 @@ export function mergeHalves(ruleId, a, b) {
49
55
  ? { reason: reasons.join(" || ") }
50
56
  : {}),
51
57
  };
58
+ // The per-rule count of a red merge is the sum over its RED halves — the
59
+ // halves whose `N site(s) VIOLATE` the merged verdict prints. An abstaining
60
+ // half contributes nothing; a red half with no count leaves it uncounted.
61
+ if (merged.state !== "non-conforming")
62
+ return merged;
63
+ const c = sumCounts([ra, rb].filter((r) => r.state === "non-conforming").map(countOf));
64
+ return c === undefined ? merged : withCount(merged, c);
52
65
  },
53
66
  };
54
67
  }
@@ -61,7 +74,9 @@ export const REGISTRY = [
61
74
  { detector: makeR14(), ruleId: "R-14", standardPin: STANDARD_PIN },
62
75
  { detector: makeR38(), ruleId: "R-38", standardPin: STANDARD_PIN },
63
76
  // Axis-2 coherence detectors (R-7/R-8/R-37): each performs its mechanical half
64
- // IN FULL and returns could-not-judge, naming the sites a human verdict is owed.
77
+ // IN FULL and decides on the written justification token's PRESENCE in the
78
+ // remediation cell (task k179jnhx, §23) — non-conforming without the token,
79
+ // present with it; could-not-judge only on a row the instrument cannot read.
65
80
  {
66
81
  detector: makeOracleDetector(r7),
67
82
  ruleId: "R-7",
@@ -145,8 +160,45 @@ export const REGISTRY = [
145
160
  // documents a client contract; a gated customMutation wrapper (writeMutation) is
146
161
  // refused (could-not-judge) since client reachability is not visible from convex/.
147
162
  ...SOURCE_WRITE_ISOLATION_DETECTORS,
163
+ // R-52 — a write that reaches a row carrying no tenant value through a
164
+ // caller-choosable key (an argument, or a stored field the caller set
165
+ // through a public write) is dominated by a guard reading a server-stamped
166
+ // tenant. Reads schema.ts tenant columns and every public mutation/action
167
+ // handler on the AST (the vantage-peers 45e40c8 tasks.complete auto-link).
168
+ ...SOURCE_CROSS_TENANT_WRITE_DETECTORS,
169
+ // R-53 — an actor and a target are identified by ID through
170
+ // @vantageos/cloud-identity: per public write door, the principal's
171
+ // source (a), the admission predicate (b), the unresolved branch (c);
172
+ // per helper / MCP gate, a local identity layer (d). Reads convex/ and
173
+ // mcp-server/src (the PR #1464 ten doors + org-less bulk_complete path).
174
+ ...SOURCE_IDENTITY_BY_ID_DETECTORS,
148
175
  ...SOURCE_CONTRACT_DETECTORS,
149
176
  ...IDENTITY_DETECTORS,
177
+ // R-54 — a door the fleet service account can reach acts only inside an
178
+ // organisation the transport verified for this call. Reads the convex source:
179
+ // a public door that resolves its caller through the organisation-scope
180
+ // resolver and declares no claim argument (A), or declares one but never
181
+ // refuses the claimless service-account call (B), is red; a claim-bound door
182
+ // and the three listed exits are present; an unreadable caller resolution is
183
+ // could-not-judge. The door audit's cause 1 (standard bacc54a §4 R-54).
184
+ ...SOURCE_SERVICE_ACCOUNT_CLAIM_DETECTORS,
185
+ // R-55 — a read narrows by every predicate before it bounds the result, and an
186
+ // aggregate is computed over the narrowed set. Reads the convex source: a
187
+ // bounded read (.take / .paginate over a DB query, or a re-bounding .slice)
188
+ // whose rows are narrowed AFTER the bound by a caller-dependent predicate is
189
+ // red; a fill loop or a refusal on a full window between the bound and the
190
+ // narrowing is listed as covered. An unbounded .collect() is R-11 / R-47's.
191
+ // The door audit's cause 4 (standard bacc54a §4 R-55).
192
+ ...SOURCE_NARROW_THEN_BOUND_DETECTORS,
193
+ // R-56 — credentials at rest, refresh tokens, bearers and verification tokens.
194
+ // Reads the convex source: a credential-named column stored without a digest or
195
+ // as the digest of a chosen secret (1); a refresh-token table read by hash with
196
+ // no consuming door (2); a bearer table with no revocation door (3a) or a mint
197
+ // that neither revokes prior bearers nor caps them (3b); a request-supplied
198
+ // commit identifier matched by substring without a full-length check (4) or a
199
+ // response echoing stored content (4e). The door audit's cause 5
200
+ // (standard bacc54a §4 R-56).
201
+ ...SOURCE_CREDENTIAL_HYGIENE_DETECTORS,
150
202
  // The four STANDARD-DOCUMENT detectors (R-42/R-15/R-32/R-39): each reads the
151
203
  // VENDORED standard itself, performs its mechanical/static half in full, and
152
204
  // returns could-not-judge naming the sites a human/gate verdict is owed on.
@@ -0,0 +1,85 @@
1
+ import type { State } from "./engine-types.js";
2
+ export declare const RULE_CLASS_PATH: string;
3
+ export type GateClass = "mechanical" | "judgement";
4
+ export interface RuleClassFile {
5
+ standard_sha: string;
6
+ standard_kind_to_class: Record<string, string>;
7
+ rules: Record<string, {
8
+ class: string;
9
+ basis: string;
10
+ }>;
11
+ }
12
+ export declare function loadRuleClassFile(path?: string): RuleClassFile;
13
+ /**
14
+ * The build kind the PINNED STANDARD itself states per rule: every row of the
15
+ * section 15 table (`| R-N | mechanical|judgement|process | … |`), then, for a rule the
16
+ * table does not list, the `*kind:* <word>` token inside that rule's own text (up
17
+ * to the next rule definition). A rule the standard classifies nowhere is absent
18
+ * from the map — classifyRules reports it as unclassified.
19
+ */
20
+ export declare function parseStandardKinds(path?: string): Map<string, string>;
21
+ export interface Unclassified {
22
+ id: string;
23
+ reason: string;
24
+ }
25
+ export interface Classification {
26
+ classOf: Map<string, GateClass>;
27
+ basisOf: Map<string, string>;
28
+ /** could-not-judge: rules the classification cannot place. Never defaulted. */
29
+ unclassified: Unclassified[];
30
+ /** sidecar entries naming no rule of this run — reported, a stale sidecar. */
31
+ stale: string[];
32
+ }
33
+ /**
34
+ * Place every id in exactly one class, or name it unclassified with its reason.
35
+ * `ids` is the derived rule set PLUS the doctor-owned ids (CR-1). An R- id is
36
+ * cross-checked against the standard; a non-R id (CR-1) is not in the standard and
37
+ * stands on the sidecar alone.
38
+ */
39
+ export declare function classifyRules(ids: readonly string[], file?: RuleClassFile, standardKinds?: Map<string, string>, standardSha?: string): Classification;
40
+ export interface MechanicalCount {
41
+ /** the gate's count, or `null` when it cannot be certified (could-not-judge). */
42
+ count: number | null;
43
+ mechanicalViolations: string[];
44
+ judgementViolations: string[];
45
+ /** could-not-judge rule states — NOT violations; reported, never folded into 0. */
46
+ mechanicalAbstentions: string[];
47
+ judgementAbstentions: string[];
48
+ unclassified: Unclassified[];
49
+ /** every reason the count is null (empty when it is an integer). */
50
+ cannotCertify: string[];
51
+ }
52
+ /**
53
+ * Count the non-conforming rules whose class is mechanical. The count is `null`
54
+ * — never 0 — when any rule is unclassified (its violation, present or future,
55
+ * could be mechanical) or when the instrument names a self-defect that voids the
56
+ * run (`instrumentDefects`, e.g. incomplete coverage or a dirty judged tree).
57
+ */
58
+ export declare function countMechanical(ruleState: ReadonlyMap<string, State>, classification: Classification, instrumentDefects?: readonly string[]): MechanicalCount;
59
+ /** The human line printed next to the VERDICT line. */
60
+ export declare function mechanicalLine(m: MechanicalCount): string;
61
+ /** The deploy hook's evidence schema, key for key and nothing else. */
62
+ export interface Evidence {
63
+ sha: string;
64
+ cli_commit: string;
65
+ convex_path: string;
66
+ exit_code: number;
67
+ checked: number;
68
+ total: number;
69
+ mechanical_violations: number | null;
70
+ /**
71
+ * Each RED mechanical rule -> the non-conforming count its report line prints
72
+ * (sites for a source rule, rows for an oracle rule). Its key count equals
73
+ * `mechanical_violations`; null whenever that is null or a red rule carries no
74
+ * count (src/violation-count.ts). Additive: the seven keys above are unchanged.
75
+ */
76
+ mechanical_rule_counts: Record<string, number> | null;
77
+ /**
78
+ * Per site-based red mechanical rule: module -> count, where a convex/ file's
79
+ * module is its basename without extension and any other file's is its path
80
+ * without extension (`mcp-server/src/tools`). Values sum to that rule's
81
+ * `mechanical_rule_counts` entry. Row-based rules are absent. Null with the above.
82
+ */
83
+ mechanical_rule_module_counts: Record<string, Record<string, number>> | null;
84
+ }
85
+ export declare function buildEvidence(e: Evidence): Evidence;
@@ -0,0 +1,218 @@
1
+ // The GATE CLASS of every rule — mechanical or judgement — and the one count the
2
+ // deploy gate refuses on: `mechanical_violations`.
3
+ //
4
+ // The vantage-peers deploy hook (`enforce-backend-doctor-before-deploy.py`) reads
5
+ // `qa/backend-doctor-<sha>.json` and refuses ONLY on mechanical non-conformance,
6
+ // never on a judgement/process rule. This module is where that distinction is made,
7
+ // and it is made from DATA, never from TS logic:
8
+ //
9
+ // - the per-rule class lives in the sidecar `fixtures/rule-classes-<pin>.json`
10
+ // (one entry per numbered rule plus CR-1, each with a one-phrase basis);
11
+ // - every R- entry is CROSS-CHECKED against the pinned standard's own build kind
12
+ // (the section 15 "Build kind" column, and the `*kind:*` token of a Detector spec
13
+ // for a rule the table does not list — R-50/R-51), through the sidecar's
14
+ // `standard_kind_to_class` vocabulary (`process` -> judgement);
15
+ // - a rule with no entry, an entry outside the two classes, an entry with no
16
+ // basis, a rule the standard gives no build kind, or a sidecar class that
17
+ // DISAGREES with the standard is UNCLASSIFIED — a could-not-judge. It is never
18
+ // defaulted to either class; the count it would feed becomes `null`.
19
+ //
20
+ // COUNT GRANULARITY — by RULE, the same granularity as the TALLY and the VERDICT
21
+ // line: a rule whose reduced state is `non-conforming` is one violation, however
22
+ // many sites it names. CR-1 (doctor-owned, outside the pinned rule set) counts as
23
+ // one rule.
24
+ import { readFileSync } from "node:fs";
25
+ import { dirname, join } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { STANDARD_SHA, VENDORED_STANDARD_PATH } from "./coverage.js";
28
+ const here = dirname(fileURLToPath(import.meta.url));
29
+ export const RULE_CLASS_PATH = join(here, "..", "fixtures", "rule-classes-bacc54a.json");
30
+ const GATE_CLASSES = new Set(["mechanical", "judgement"]);
31
+ export function loadRuleClassFile(path = RULE_CLASS_PATH) {
32
+ return JSON.parse(readFileSync(path, "utf8"));
33
+ }
34
+ /**
35
+ * The build kind the PINNED STANDARD itself states per rule: every row of the
36
+ * section 15 table (`| R-N | mechanical|judgement|process | … |`), then, for a rule the
37
+ * table does not list, the `*kind:* <word>` token inside that rule's own text (up
38
+ * to the next rule definition). A rule the standard classifies nowhere is absent
39
+ * from the map — classifyRules reports it as unclassified.
40
+ */
41
+ export function parseStandardKinds(path = VENDORED_STANDARD_PATH) {
42
+ const blob = readFileSync(path, "utf8");
43
+ const out = new Map();
44
+ const start = blob.indexOf("## 15. Build column");
45
+ if (start !== -1) {
46
+ const after = blob.indexOf("\n## ", start + 1);
47
+ const section = blob.slice(start, after === -1 ? undefined : after);
48
+ for (const line of section.split("\n")) {
49
+ const m = /^\|\s*(R-\d+)\s*\|\s*(mechanical|judgement|process)\s*\|/.exec(line);
50
+ if (m)
51
+ out.set(m[1], m[2]);
52
+ }
53
+ }
54
+ // Detector-spec `*kind:*` for rules outside the table (none at bacc54a: the table lists all 56; kept for a future rule defined outside it).
55
+ const lines = blob.split("\n");
56
+ let current;
57
+ for (const line of lines) {
58
+ const def = /^\*\*(R-\d+)\b/.exec(line);
59
+ if (def)
60
+ current = def[1];
61
+ if (current === undefined || out.has(current))
62
+ continue;
63
+ const k = /\*kind:\*\s*(mechanical|judgement|process)\b/.exec(line);
64
+ if (k)
65
+ out.set(current, k[1]);
66
+ }
67
+ return out;
68
+ }
69
+ /**
70
+ * Place every id in exactly one class, or name it unclassified with its reason.
71
+ * `ids` is the derived rule set PLUS the doctor-owned ids (CR-1). An R- id is
72
+ * cross-checked against the standard; a non-R id (CR-1) is not in the standard and
73
+ * stands on the sidecar alone.
74
+ */
75
+ export function classifyRules(ids, file = loadRuleClassFile(), standardKinds = parseStandardKinds(), standardSha = STANDARD_SHA) {
76
+ const classOf = new Map();
77
+ const basisOf = new Map();
78
+ const unclassified = [];
79
+ const pinMismatch = file.standard_sha !== standardSha
80
+ ? `sidecar is pinned to standard ${String(file.standard_sha).slice(0, 12)}, the instrument to ${standardSha.slice(0, 12)}`
81
+ : undefined;
82
+ for (const id of ids) {
83
+ if (pinMismatch) {
84
+ unclassified.push({ id, reason: pinMismatch });
85
+ continue;
86
+ }
87
+ const entry = file.rules?.[id];
88
+ if (entry === undefined) {
89
+ unclassified.push({ id, reason: "no class in the sidecar" });
90
+ continue;
91
+ }
92
+ if (!GATE_CLASSES.has(entry.class)) {
93
+ unclassified.push({
94
+ id,
95
+ reason: `sidecar class ${JSON.stringify(entry.class)} is neither mechanical nor judgement`,
96
+ });
97
+ continue;
98
+ }
99
+ if (typeof entry.basis !== "string" || entry.basis.trim() === "") {
100
+ unclassified.push({
101
+ id,
102
+ reason: "sidecar class carries no basis phrase",
103
+ });
104
+ continue;
105
+ }
106
+ if (/^R-\d+$/.test(id)) {
107
+ const kind = standardKinds.get(id);
108
+ if (kind === undefined) {
109
+ unclassified.push({
110
+ id,
111
+ reason: "the pinned standard states no build kind for this rule",
112
+ });
113
+ continue;
114
+ }
115
+ const expected = file.standard_kind_to_class?.[kind];
116
+ if (expected !== entry.class) {
117
+ unclassified.push({
118
+ id,
119
+ reason: `sidecar says ${entry.class}, the pinned standard says ${kind} (-> ${expected ?? "no mapping"}) — CONFLICT`,
120
+ });
121
+ continue;
122
+ }
123
+ }
124
+ classOf.set(id, entry.class);
125
+ basisOf.set(id, entry.basis);
126
+ }
127
+ const wanted = new Set(ids);
128
+ const stale = Object.keys(file.rules ?? {}).filter((id) => !wanted.has(id));
129
+ return { classOf, basisOf, unclassified, stale };
130
+ }
131
+ const byRuleId = (a, b) => {
132
+ const prefix = (s) => s.replace(/\d+$/, "");
133
+ const n = (s) => Number(/\d+$/.exec(s)?.[0] ?? 0);
134
+ return prefix(a).localeCompare(prefix(b)) || n(a) - n(b);
135
+ };
136
+ /**
137
+ * Count the non-conforming rules whose class is mechanical. The count is `null`
138
+ * — never 0 — when any rule is unclassified (its violation, present or future,
139
+ * could be mechanical) or when the instrument names a self-defect that voids the
140
+ * run (`instrumentDefects`, e.g. incomplete coverage or a dirty judged tree).
141
+ */
142
+ export function countMechanical(ruleState, classification, instrumentDefects = []) {
143
+ const out = {
144
+ count: null,
145
+ mechanicalViolations: [],
146
+ judgementViolations: [],
147
+ mechanicalAbstentions: [],
148
+ judgementAbstentions: [],
149
+ unclassified: classification.unclassified,
150
+ cannotCertify: [...instrumentDefects],
151
+ };
152
+ const unclassifiedIds = new Set(classification.unclassified.map((u) => u.id));
153
+ for (const [id, state] of ruleState) {
154
+ const cls = classification.classOf.get(id);
155
+ if (cls === undefined) {
156
+ if (!unclassifiedIds.has(id)) {
157
+ out.unclassified = [
158
+ ...out.unclassified,
159
+ {
160
+ id,
161
+ reason: "a result exists for a rule the classification never saw",
162
+ },
163
+ ];
164
+ unclassifiedIds.add(id);
165
+ }
166
+ continue;
167
+ }
168
+ if (state === "non-conforming") {
169
+ (cls === "mechanical"
170
+ ? out.mechanicalViolations
171
+ : out.judgementViolations).push(id);
172
+ }
173
+ else if (state === "could-not-judge") {
174
+ (cls === "mechanical"
175
+ ? out.mechanicalAbstentions
176
+ : out.judgementAbstentions).push(id);
177
+ }
178
+ }
179
+ for (const list of [
180
+ out.mechanicalViolations,
181
+ out.judgementViolations,
182
+ out.mechanicalAbstentions,
183
+ out.judgementAbstentions,
184
+ ])
185
+ list.sort(byRuleId);
186
+ if (out.unclassified.length > 0) {
187
+ out.cannotCertify.push(`${out.unclassified.length} rule(s) unclassified: ${out.unclassified.map((u) => `${u.id} (${u.reason})`).join("; ")}`);
188
+ }
189
+ if (out.cannotCertify.length === 0)
190
+ out.count = out.mechanicalViolations.length;
191
+ return out;
192
+ }
193
+ /** The human line printed next to the VERDICT line. */
194
+ export function mechanicalLine(m) {
195
+ const head = m.count === null
196
+ ? "MECHANICAL VIOLATIONS: COULD-NOT-JUDGE (no count certified; the evidence file records null)"
197
+ : `MECHANICAL VIOLATIONS: ${m.count}`;
198
+ const list = (ids) => (ids.length ? ids.join(", ") : "none");
199
+ const lines = [
200
+ `${head} — mechanical [${list(m.mechanicalViolations)}]; judgement violations (printed, not counted) [${list(m.judgementViolations)}]; abstentions (not violations, not 0): mechanical [${list(m.mechanicalAbstentions)}], judgement [${list(m.judgementAbstentions)}]`,
201
+ ];
202
+ for (const why of m.cannotCertify)
203
+ lines.push(` CANNOT CERTIFY: ${why}`);
204
+ return lines.join("\n");
205
+ }
206
+ export function buildEvidence(e) {
207
+ return {
208
+ sha: e.sha,
209
+ cli_commit: e.cli_commit,
210
+ convex_path: e.convex_path,
211
+ exit_code: e.exit_code,
212
+ checked: e.checked,
213
+ total: e.total,
214
+ mechanical_violations: e.mechanical_violations,
215
+ mechanical_rule_counts: e.mechanical_rule_counts,
216
+ mechanical_rule_module_counts: e.mechanical_rule_module_counts,
217
+ };
218
+ }