@vantageos/backend-doctor
v0.6.0
Published
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.
Readme
backend-doctor
A conformance instrument. It scores a Convex backend against the VantagePeers backend standard — a pinned, frozen fixture, not a document authored here — and prints, per rule, the sites that fail it or the sites a human verdict is owed on. It consumes the @vantageos/mcp-doctor engine (runDoctor + renderTable) — it never re-implements it and touches no engine file; the contract types are derived from the engine's own signatures.
The standard is vendored under fixtures/backend-standard-<pin>.md, byte-identical to its upstream git blob; the canonical source lives in ElPi-Corp at vantagepeers/backend-standard/backend-standard.md (pin bacc54a, 56 rules — on ElPi-Corp main; it adds R-54..R-57 and the MCP rule S-28, merged as ElPi-Corp #239 = 7d7df89 before the 2026-10-10 history rewrite, same file blob 85d8530e; see PINS.txt). The doctor never authors the standard — it scores against the frozen copy.
What it checks — every rule, one bucket, derived not typed
Every mechanical rule of the standard carries an executable control. There is no deferral bucket: a rule is either CHECKED, or NOT_MECHANICALLY_CHECKABLE named with a written reason (never silently dropped). The rule count is whatever the pinned fixture derives — it is not typed in this README, because a typed count is stale the moment the pin moves (a rule with no detector yet must show as not-checkable against the true denominator, not vanish under a smaller one). Read the live coverage from the tool, never from prose:
# the coverage proof — prints "<N> CHECKED + <M> NOT_MECHANICALLY_CHECKABLE = <N+M> of <N+M>"
# and exits 1 on any gap. This line, not this README, is the count.
npx --yes @vantageos/backend-doctor@<version> --coverage # or, from a checkout: npx tsx src/coverage-cli.tsThe detectors read four kinds of material, each a comparison target, never an authority:
| Kind | Reads | Rules (examples) |
|---|---|---|
| oracle-CSV (Axis-2) | the per-tool inventory the target's own .backend-doctor/vp-by-tool.csv (or --oracle <file>); absent => could-not-judge | R-1..R-13, R-7, R-8, R-37 |
| writer-tier side-car | vp-writer-tier.csv beside the VP oracle (or --writer-tier <file>), joined on outil; absent => R-10 could-not-judge, file named | R-10 |
| oracle-CSV (Axis-1) | VP oracle vs the VR inventory .backend-doctor/vr-by-tool.csv (or --vr-oracle <file>) | R-40 |
| source-tree | the real convex/ source and mcp-server/src (live path, or fixtures/vp-convex/ in CI) | R-6, R-11, R-16..R-36, R-50..R-56 |
| standard-document | the vendored standard + the four MCP-family docs | R-14, R-15, R-32, R-38, R-39, R-41, R-42 |
The acceptance property — surfacing, not deferring
A rule whose control has a mechanical detection half and a human/gate half does not report present ("held") or absent ("N/A") while its subject exists in the material. It runs the mechanical half in full and returns could-not-judge naming the concrete sites a human or delivery-gate verdict is owed on — a work list where there was silence. Green is the human half surfaced per site, never the detection merely running.
Run it
backend-doctor is its own tool (own package @vantageos/backend-doctor, own bin), like react-doctor / mcp-doctor. The supported invocation is the published, scoped package — so the instrument that judged is the instrument named, and every station runs the same pinned version without cloning:
npx --yes @vantageos/backend-doctor@<version> <path-to-a-convex-backend>
# <path> may be the repo root or its convex/ dir: both are normalised to the repo root
# (nearest package.json/.git) and produce identical results. --version / --help are supported.Do not type a stale version as the only pin — read the current one from the registry, never from this README:
npm view @vantageos/backend-doctor version # the current published version
npx --yes @vantageos/backend-doctor@$(npm view @vantageos/backend-doctor version) <path-to-a-convex-backend>Until the scoped package is first published (npm view @vantageos/backend-doctor returns 404 until then), the npx @vantageos/… form above cannot resolve — run from a checkout (below) in the meantime. Once published, the scoped package is the supported path from that version onward.
For development of the doctor itself, or to run it before the package is published (not the long-term supported path), from a checkout:
npm ci && npm run build
node dist/cli.js <path-to-a-convex-backend> # the bin; the live verdict + named sites + tally
npx tsx src/cli.ts <path-to-a-convex-backend> # same, straight from source
npx tsx src/coverage-cli.ts # the coverage proof; exits 1 on any gap
npm test # the detector suite (RED-first, both poles, mutation bite proofs)
npx tsc --noEmitnode dist/cli.js refuses to run (exit 2) when the dist/ it runs from was compiled from different source than the checkout's src/: npm run build stamps dist/build-stamp.json with a sha256 of src/**/*.ts, and the CLI recomputes it before reading anything (src/build-stamp.ts). A gate that must not depend on a build step runs from source (npx tsx src/cli.ts). An installed package has no src/ and is identified by its version.
The verdict, the tally, and the exit code (D6)
The CLI prints a machine tally — the four rule states (present / non-conforming / could-not-judge / absent) counted by code from the detector results and asserted to sum to the derived rule count (the coverage denominator, never a typed number). No human ever counts rows (the eyeballed-distribution defect is closed). The tally is over rules (one worst-state per rule); the printed line also names the raw result count so the two are never confused.
It then prints a severity-mapped verdict in react-doctor's shape. Each rule's severity is derived from the standard's §13 objectives table (never hardcoded):
| objective tag (§13) | severity |
|---|---|
| multi-organisation multi-tenant (tenant-boundary) | critical |
| highly secure (without multi-tenant) | major-high |
| enterprise-grade / production-ready | major |
| token-efficient only | minor |
Verdict mapping, computed from the tally (major = major-high + major):
- 0 critical + 0 major violations → approve (exit 0)
- 0 critical + 1..5 major violations → revise recommended
- 0 critical + >5 major violations → revise mandatory
- ≥1 critical violation → revise mandatory, no bypass
A could-not-judge rule is an abstention — a human verdict is owed, never a silent approve; any abstention blocks approve, and an abstention on a critical rule is reported first. The exit code preserves the engine's honesty (and cross-checks @vantageos/mcp-doctor's computeExitCode): 1 = a hard violation (≥1 critical, or any major violation); 2 = a human verdict owed with no hard violation (an abstention — never a clean 0); 0 = approve. approve is never printed on an exit that means abstention.
CR-1 — caller resolution on the exposed surface (doctor-owned, CRITICAL)
Alongside the numbered rules the CLI renders a CALLER RESOLUTION (CR-1) section answering one question per registration: does this public registration resolve its caller before serving or writing data?
- Population is the exposed surface itself, derived from the target tree's own
convex/_generated/api.d.ts— never a hand-written list and never a pattern over argument names.internal*/httpActionare excluded and the exclusion is proven per site from the builder token on that line. The count classified must equal the count exposed; a discrepancy is reported as CR-1's own bug. - Four classes, and every member is named:
RESOLVES/FAIL-OPEN/NO-IDENTITY/NO-DATA.FAIL-OPENand unjustifiedNO-IDENTITY/NO-DATAare mechanical violations and the gate refuses on them (exit 1). - The only exit is a token in the target source,
allow-no-caller-resolution: <reason>, read at the registration. There is no exemption list, no baseline and no tolerated count —FAIL-OPENhas no token exit at all. - It asks whether the caller is RESOLVED, never whether a refusal is shaped correctly. That second question is R-50/R-51's; a handler with no guard has nothing to throw, so it cannot fail a rule about throwing.
- On a tree that exposes nothing it reports no applicable site, never a count.
CR-1 is NOT one of the numbered rules in the pinned standard, so it is not in src/registry.ts and does not enter the coverage line: registering an id the vendored standard does not define would make coverage.ts report a phantom rule. Its upstream number is pending: the earlier proposal "R-52" is withdrawn because the standard at 2825122 gives R-52 to the cross-tenant-write rule and R-53 to identity-by-ID (§16); CR-1 takes the next free number when it is upstreamed, in the standard's own repository. Source and rationale: src/caller-resolution.ts.
CR-2 — no local identity primitive (doctor-owned, mechanical, ceiling 0)
Operator order 2026-10-09: "No application, no MCP server, and no starter defines its own primitive for: resolving a caller to a tenant, asserting membership of an organisation, filtering rows by a caller's scope, or validating a master credential." Identity lives only in @vantageos/cloud-identity. The CLI renders a LOCAL IDENTITY (CR-2) section; every site is named by file:line.
- One site per function that defines a primitive (a helper, an MCP tool handler, or a Convex registration whose own handler does it) in
convex/andmcp-server/src. Each site names every kind it holds, with its line: tenant (resolves a caller to a tenant or agent), membership (a gate on a role / master field, a membership-table read, or an identity refusal the app builds itself: aRBAC_DENIED-style code or package refusal reason, the set read from the installed@vantageos/cloud-identity), scope (a read of a roster / allow-list member of the caller's scope, the member set derived from the target tree's own types, or a membership test over a caller's tenant / name / scope list), credential (timingSafeEqual, token hashing,jose/jsonwebtokenverification), name (an agent identified by name: an index keyed on a name field, a comparison through a name normaliser). - R-53 is reused, not re-derived: its (d) local identity layers, its doors whose principal is resolved by the handler itself, its clause-1 recipient-by-name sites and its (c) master-on-absence branches become CR-2 sites of the matching kind. What R-53 already sanctions (a
getUserIdentity()value that reaches only a package resolver, the org-ID adapter read) is not a site. - The package's caller resolvers come from the package, not from the doctor. A
getUserIdentity()value is sanctioned only when it reaches, as first argument, a function the file imports from@vantageos/cloud-identityAND that the installed package tags@callerResolver: the JSDoc block immediately aboveexport declare function <name>innode_modules/@vantageos/cloud-identity/dist/**/*.d.ts, found at the scan root or its nearest ancestor (src/detectors/source/caller-resolvers.ts). cloud-identity 0.20.0 tagsresolveActingPrincipal,resolvePersonPrincipalandresolveCallerFromClaims. A local function of the same name, or an untagged package export, is not a sink. When the package is absent, has nodist/, or tags nothing (every release before 0.20.0), the fixed pairresolveActingPrincipal/resolvePersonPrincipalis used. Either way the section printscaller-resolver sinks: DERIVED from …orcaller-resolver sinks: FALLBACK to the fixed set … — <why>on every run. R-53 (including its recipient-by-name reading) and R-6's package-principal credit read the same set. - EXEMPT, and reported as exempt: a file of
@vantageos/cloud-identityitself, and a site carryingallow-local-identity: <reason of 8+ characters>in its leading comment or body. Each exempt site is listed with its reason. - STORAGE FOR THE PACKAGE, reported and not counted (0.4.7): a membership-table read whose value flows only into an argument of a call imported from
@vantageos/cloud-identity(directly, through a reader closure handed to such a call, or through aconstthat is only passed, mapped without a decision, or checked against its own.take(B + 1)budget). The same read also used locally (a role branch, an id comparison, a presence test, a local function) stays counted. Source:src/detectors/source/local-identity.ts, header "STORAGE FOR THE PACKAGE". - COULD-NOT-JUDGE: a file with a syntax error is named and judged nowhere (exit 2 when nothing else is red); an empty population is never a clean bill.
- Ceiling 0: CR-2 is mechanical (
fixtures/rule-classes-d02c931.json). Any violation is counted inmechanical_rule_counts/mechanical_rule_module_countsand exits 1.
CR-2 is the standard's R-57, reported under its doctor-owned key. It was authored before the standard numbered it; the standard at bacc54a defines the same rule as R-57. The count stays under the key CR-2 because the vantage-peers deploy-gate baseline (.claude/config/backend-doctor-baseline.json) reads CR-2; reporting it under both ids would count one defect as two rules, so R-57 itself is listed under NOT_MECHANICALLY_CHECKABLE with that reason. Renaming the key to R-57 is a follow-up that must move that baseline in the same delivery. Source and rationale: src/detectors/source/local-identity.ts.
CR-3 — write/read asymmetry for a scoped caller (doctor-owned, mechanical, ceiling 0)
A row a scoped caller can write must be readable back by that same caller. src/detectors/source/write-read-asymmetry.ts compares, per table with a tenant column in the scanned schema.ts, the public doors that insert into it with the public queries that read it. Three clauses, each site tagged:
[claims]— every public mutation inserting into table T accepts every transport-verified claim any public query reading T accepts. The claims are the three the standard names in R-54 clause 1 (verifiedActor,verifiedOrg,verifiedPerson; the sameCLAIMSconstant as R-54, read through the same args reader, so an args spread from a tree const is followed). A write door that cannot be told which organisation a service-account call is for cannot stamp it.[fields]— the tenant columns an insert sets contain the tenant columns T's read doors filter on (.withIndex(_, q => q.eq("col", …)),.filter(q => q.eq(q.field("col"), …))).[absent]— the tenant value an insert sets cannot be absent because of caller input without a refusal on its path: an unconditionalundefined/null, an optional door argument (assigned, shorthand, or spread fromargs), or a conditional spread guarded by one. Absence that follows the caller's resolved scope (a nullable helper, a branch onisMaster) is the fleet row and is judged by[claims].
Routed writes. An insert behind ctx.runMutation(ref), ctx.runAction(ref), ctx.scheduler.runAfter(delay, ref) or ctx.scheduler.runAt(when, ref) is the calling door's own write: ref (api./internal./anyApi. path, makeFunctionReference("m:f"), or a const bound to one) is resolved to its registration and followed, transitively. runQuery is not followed (a query cannot insert).
Could-not-judge, always named: a run/scheduler call whose reference the scan cannot resolve to a registration in the tree (a dynamic reference, a components.* function, no such registration, a callee taken without a call or called through a bare identifier); a file with a parse error; no schema.ts in the tree; a query/insert table that is not a string literal; args spread from a validator the tree does not hold; an insert whose object or spread the walk cannot resolve (only when a required tenant column is then missing); a tenant parameter no call in the door's closure supplies; a read filter whose callback or column is not a literal. A tree with no applicable site is could-not-judge, never a clean bill. Doors that call requireServiceAccount, and tables no public query reads, are listed under OUT OF SCOPE in the section, not dropped.
The only exit is allow-write-read-asymmetry: <reason of 8+ characters> on a comment line of the door's statement; prose that merely quotes the token is not a declaration. Exempt doors are listed with their reason.
CR-3 is proposed as R-63 in the backend standard (ElPi-Corp). The key stays CR-3 until the doctor re-pins to a standard that defines it, as CR-2 did for R-57. Adopting this release makes mechanical_violations count CR-3 as a rule: a repository with asymmetric doors goes red until each is fixed or declared.
Proof: tests/source-cr3-write-read-asymmetry.test.ts (npx vitest run -t "write read asymmetry"), on material vendored byte-exact from vantage-peers 010edc9 (before PR #1577) and 83fa7ac (its head) (tests/fixtures/write-read-asymmetry-vp/.pin): the real tasks:create is refused before, passes after, and one landed mutation per claim form and per stamp form is refused on the fixed material.
R-31 — the read budget of a bulk mutation
- R-31 (
src/detectors/source/readbudget.ts): a.collect()inside a mutation is non-conforming unless it reads an index-equality range carrying// read-bound: <reason>; a bounded read (.take(n)/.paginate) conforms when the mutation self-reschedules withctx.scheduler.runAfter, or when it is a caller-driven drain — the.paginateresult flows to one return statement of the handler itself (a nested callback's return does not count) carrying both the continuation cursor and theisDoneflag (any field names), so the caller re-invokes it page by page, and noctx.runMutation/ctx.runActionin the same transaction carries that cursor or names the mutation; every other bounded read is could-not-judge.
R-54, R-55, R-56 — the door audit's causes 1, 4 and 5
Standard bacc54a §4. Each is mechanical, has no justification-token exit, and counts in mechanical_violations. Their material is the door audit of vantage-peers 16f0907 (docs/audit/2026-10-09-door-audit.md, branch argus/door-audit-16f0907); the real files are vendored byte-exact in tests/fixtures/vp-16f0907/ (.pin).
- R-54 (
src/detectors/source/service-account-claim.ts): a public door that resolves its caller through the organisation-scope resolver (withOrgScope, or any in-tree function that calls it) must declare a transport-verified claim argument (verifiedActor/verifiedOrg/verifiedPerson) AND refuse the call that is the service account and carries no claim. Red (A): no claim argument. Red (B): a claim argument but no such refusal, so its absence leaves the call master. A refusal is athrow(or a refusingreturn) whose path condition holds the service-account state and the claim's absence (a local derived from the claim counts; a refusal of the OTHER population does not). Exits, each listed and none silent: (a) service-account-only (requireServiceAccount, orrequireResolvedCallerwithmasterOnlyandmcpBoundOnly); (b) callers resolve only through a verified session (requireOrgAdmin, a bare identity read) and the door never reaches the scope resolver; (c)fleet-wide: <reason of 8+ characters>. Could-not-judge: an action whose scope arrives through a run call, a resolver-shaped callee defined outside the tree, a claim argument spread from a validator the scan cannot find, a door registered through a wrapper. A door that resolves no caller at all is outside the rule (CR-1 / R-6) and not counted. Clause 2 of the rule (the claim confines every row) is a dynamic property and is NOT decided here. - R-55 (
src/detectors/source/narrow-then-bound.ts): in a public door and the helpers it reaches, a read bounded by.take/.paginate(or re-bounded by.slice(0, n)) whose rows are then narrowed by a caller-dependent predicate (a filter reading the door's arguments or the resolved scope, afilterBy…helper, an in-tree helper that narrows its parameter, a loop that skips on such a test) is red. Listed as covered, not silent: a fill loop that holds both the bound and the narrowing, and a refusal on a full window (a throw naming the cap, overflow or truncation) between the latest bound and the narrowing. An unbounded.collect()is R-11 / R-47's. - R-56 (
src/detectors/source/credential-hygiene.ts), four clauses, one site tag each: (1) a credential-named column (code,token,secret,apiKey, …) stored without a digest, or a digest of a chosen secret (a door argument named like a secret, an environment secret) that reaches a stored row; (2) a table named like a refresh-token store, read by a hash index, with no function that reads it by hash AND marks the token consumed/revoked or deletes it; (3a) a table whose inserted rows carrytokenHashandexpiresAtwith no function that revokes or deletes a row of it, (3b) a mint into such a table (no code or refresh-token linkage) that neither revokes the principal's earlier bearers nor caps them; (4) a request-supplied commit/hash/token identifier matched to stored text byincludes/startsWith/indexOfwith no 40- or 64-digit check, and (4e) a verification response that echoes stored content. The entropy of an externally generated secret and the binding of a minted bearer to the session's claims are NOT decided here. - R-57 is measured by the doctor-owned CR-2 (
src/detectors/source/local-identity.ts) and reported under the keyCR-2(see the CR-2 section). R-57 itself is listed under NOT_MECHANICALLY_CHECKABLE with that reason and is never reported present.
R-52 and R-53 — the write reaches the right row, and the actor is who it is by ID
- R-52 (
src/detectors/source/cross-tenant-write.ts): a write that reaches a row with no tenant value (no tenant column, or an optional one) through a value the caller chose — an argument, or a stored field a public write set from its arguments, such astask.project— must be dominated by a guard reading a server-stamped tenant: the target's own tenant when it has a column, a fleet-only decision when it has none. Tenant columns are the standard'sorgId/tenantId/workspaceId/orgSlug, or anamespacechecked against the caller's allowed prefixes (ElPi-Corp #228). The namespace condition is decided per table from its public doors (every query/mutation/action that reads or writes it): every door runs a prefix test (a function taking a namespace parameter and testing it with.startsWith, closed over calls — vantage-peersisNamespaceAllowedForScope— called on a namespace value: the argument at its namespace parameter derives fromargs.namespace, a row's.namespaceor a binding of one, never a constant), refuses every non-master, or reads the table only on the true side of anisMastertest -> tenant column; no door does -> the table is untenanted; mixed, or no public door -> every R-52 site targeting it is could-not-judge, never green. On vantage-peers2faa399,memoriesis mixed (nine doors checked,oauth.ts:provisionOrganizationreads it throughfindSeatNameCollisionunchecked), so its six sites are could-not-judge. - R-53 (
src/detectors/source/identity-by-id.ts): every public write door resolves its principal through@vantageos/cloud-identity(never aclient_org_mappinglookup in product code, save the one read the ruling of task k1742q0vm05x2f5pqawnz1eah18fp01p admits: keyed by the Clerk org ID —.withIndex("by_clerk_org_id", (q) => q.eq("clerkOrgId", K))or.filter((q) => q.eq(q.field("clerkOrgId"), K)), then.unique()/.first()— whereKis the org ID the package hands theorgKindOfadapter (its first parameter, or a helper parameter every call site fills with it), inside anorgKindOfororganisationByIdmember of a lookups object passed toresolveActingPrincipal/assertTargetBelongsTo— inline, through a local, or as the return of a named builder that only assembles (no branch, no throw, no expression statement) — deciding nothing but its own return, anorganisationByIdanswering only{ id, active }read from the row or null; a slug or name key,withOrgScope/lookupOrgMapping, and a read whose result reaches anything else stay red; agetUserIdentity()read is admitted only when its value, followed through locals, destructuring and helpers, ends solely as the first argument ofresolveActingPrincipal/resolvePersonPrincipal, since the package verifies nothing about the credential itself), admits by comparing stored IDs (inline, or through the package's ownassertTargetBelongsTo(principal, { orgId: row.orgId[, ownerId: row.ownerId] }, …)— synchronous at cloud-identitya6e5780or awaited with a lookups argument at PR #21 — whose!okbranch throws or returns before the write; never a name field against a typed or credential-carried name), and refuses an unresolved principal; any helper or MCP tool handler that compares names to admit, or resolves identity itself, is a local identity layer.
Neither rule has a justification-token exit. Both are mechanical and count in mechanical_violations.
The deploy-gate evidence — --json-evidence and mechanical_violations
The vantage-peers deploy hook (enforce-backend-doctor-before-deploy.py) refuses a Convex deploy unless qa/backend-doctor-<sha>.json pins the deployed commit and carries an integer mechanical_violations == 0. It refuses on mechanical non-conformance only, never on a judgement/process rule. The CLI writes that file:
npx tsx src/cli.ts <repo>/convex --json-evidence <repo>/qa/ # a directory -> backend-doctor-<sha>.jsonIt writes exactly {"sha","cli_commit","convex_path","exit_code","checked","total","mechanical_violations","mechanical_rule_counts","mechanical_rule_module_counts"} (the last two are additive; the first seven are unchanged):
sha=git -C <convex_path>/.. rev-parse HEAD; no sha, no file (exit 2).cli_commit= this checkout's HEAD (+dirtywhensrc/orfixtures/differ from it), ornpm @vantageos/backend-doctor@<version>for an installed copy.exit_code= the code the process actually exits with;checked/total= the coverage line.mechanical_violations= the number of rules whose reduced state isnon-conformingand whose class is mechanical (CR-1 and CR-2 each count as one rule). The same count is printed asMECHANICAL VIOLATIONS: Nright under the VERDICT line, with the per-rule breakdown.mechanical_rule_counts= an object mapping each red mechanical rule id to its non-conforming count, the number its report line prints: sites for a source rule (R-53: 113 site(s) VIOLATE->"R-53": 113), rows for an oracle rule (R-2: 6 of 106 applicable oracle rows VIOLATE->"R-2": 6), registrations for CR-1, defining functions for CR-2. Its key count equalsmechanical_violations;{}on a clean run. The count is attached by the reduction that formats the printed line, from the same variable (src/violation-count.ts), never parsed back from the text. A rule reduced from two red halves (R-31) sums its red halves; a single structural finding (R-14, R-38) counts 1. Printed asMECHANICAL RULE COUNTS: R-2 6, …under the MECHANICAL line.nullwhenmechanical_violationsis null, or when a red mechanical rule's producer attached no count (printed asMECHANICAL RULE COUNTS: COULD-NOT-JUDGE — no count attached for …): a partial map would read as complete.mechanical_rule_module_counts= for each site-based red mechanical rule (source scans, R-46, CR-1, CR-2),{ module: count }summing to that rule'smechanical_rule_countsentry. Aconvex/file's module is its basename without extension (convex/tasks.ts->tasks,convex/lib/auth.ts->auth); a file outsideconvex/keeps its path without extension (R-53 also readsmcp-server/src:mcp-server/src/tools). Row- and finding-based rules (R-2, R-8, R-13, R-28 and every other oracle rule, R-14, R-37, R-38, R-40, R-41, the standard-document rules) are absent.nullwithmechanical_rule_counts.
The class is data, not code. fixtures/rule-classes-d02c931.json gives every numbered rule plus CR-1 and CR-2 exactly one class with a one-phrase basis (42 mechanical = the standard's 40 + CR-1 + CR-2; 16 judgement = 13 judgement + 3 process). Each R- entry is cross-checked at run time against the pinned standard's own build kind (§15 table, which at bacc54a lists all 56 rules; a Detector spec's *kind:* token is the fallback for a rule the table omits), process mapping to judgement. Consequence: R-6, R-11, R-29, R-33, R-34, R-35 now COUNT (ElPi-Corp #225 settled them mechanical), as do R-52 to R-57; R-16..R-20 and the other §15 judgement rules print but are not counted, exactly as the standard classifies them.
Never a silent 0. Abstentions (could-not-judge) are not violations: they are listed apart on the MECHANICAL line and keep the exit code at 2 when no violation exists. The count becomes null (the hook reads it as INCOMPLETE and refuses) when it cannot be certified: a rule with no class, a sidecar class that contradicts the standard, a sidecar pinned to another standard, incomplete coverage, a failed CRUD self-check, or a judged convex/ tree that differs from the commit sha names. Each reason is printed as CANNOT CERTIFY: ….
Pins
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).
The single authority for which standard fixture is active is the loader constant STANDARD_FILE in src/detectors/standard/_doc.ts — currently backend-standard-d02c931.md (56 rules — adds R-54, a door the service account can reach acts inside a transport-verified organisation and refuses a claimless call; R-55, a read narrows before it bounds; R-56, credentials as digests, single-use refresh tokens, revocable bearers, full-length verification tokens; and R-57, no local identity primitive; canonical source: ElPi-Corp vantagepeers/backend-standard/backend-standard.md). bacc54a is on ElPi-Corp main (gh api repos/elpiarthera/ElPi-Corp/compare/bacc54a53d5f6f9f20e5254908e2b46807902faa...main -> ahead, behind 0); the PR #239 merge 7d7df89 carries the same blob but left main in the 2026-10-10 history rewrite (PINS.txt). The previous pin backend-standard-2825122.md (52 rules) and the older ones stay in the tree as provenance. 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 for the engine, backend snapshot, and MCP-family doc + VR inventory pins.
Discipline (how a detector is proven)
Every detector is written RED-first against the real material (never the standard's prose illustration — the false-green law), proven to bite by a mutation injected on material it did not choose and grep-asserted as landed on an executing line before any verdict is read, and proven at both poles (a conforming site must pass; a detector that only ever fires is as broken as one that never does). Cross-document rules additionally carry a positive control per document so an unlisted identifier prefix cannot manufacture mass false positives.
