@vantageos/backend-doctor 0.1.14 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -5
- package/dist/args.d.ts +16 -0
- package/dist/args.js +72 -0
- package/dist/build-stamp.d.ts +13 -0
- package/dist/build-stamp.js +76 -0
- package/dist/build-stamp.json +4 -0
- package/dist/caller-resolution.d.ts +222 -0
- package/dist/caller-resolution.js +1198 -0
- package/dist/cli.js +249 -17
- package/dist/contract.d.ts +25 -7
- package/dist/contract.js +52 -7
- package/dist/coverage-cli.js +8 -0
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.d.ts +5 -3
- package/dist/detectors/oracle-axis1.js +28 -18
- package/dist/detectors/oracle-axis2.d.ts +6 -0
- package/dist/detectors/oracle-axis2.js +37 -22
- package/dist/detectors/predicates.d.ts +3 -0
- package/dist/detectors/predicates.js +93 -19
- package/dist/detectors/shared.js +8 -7
- package/dist/detectors/source/_ast.d.ts +92 -0
- package/dist/detectors/source/_ast.js +353 -0
- package/dist/detectors/source/_scan.js +4 -3
- package/dist/detectors/source/backfill.js +71 -18
- package/dist/detectors/source/contract.js +21 -13
- package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
- package/dist/detectors/source/cross-tenant-write.js +1408 -0
- package/dist/detectors/source/guard-helper-flow.d.ts +134 -0
- package/dist/detectors/source/guard-helper-flow.js +1216 -0
- package/dist/detectors/source/identity-by-id.d.ts +80 -0
- package/dist/detectors/source/identity-by-id.js +1783 -0
- package/dist/detectors/source/identity.d.ts +58 -2
- package/dist/detectors/source/identity.js +1158 -28
- package/dist/detectors/source/isolation.d.ts +11 -1
- package/dist/detectors/source/isolation.js +106 -25
- package/dist/detectors/source/jobs.js +178 -4
- package/dist/detectors/source/lifecycle.js +14 -2
- package/dist/detectors/source/local-identity.d.ts +26 -0
- package/dist/detectors/source/local-identity.js +661 -0
- package/dist/detectors/source/package-principal.d.ts +9 -0
- package/dist/detectors/source/package-principal.js +177 -0
- package/dist/detectors/source/r6-shapes.d.ts +20 -0
- package/dist/detectors/source/r6-shapes.js +285 -0
- package/dist/detectors/source/readbudget.d.ts +1 -0
- package/dist/detectors/source/readbudget.js +358 -4
- package/dist/detectors/source/recipient-by-id.d.ts +40 -0
- package/dist/detectors/source/recipient-by-id.js +1108 -0
- package/dist/detectors/source/refusal.d.ts +14 -0
- package/dist/detectors/source/refusal.js +75 -2
- package/dist/detectors/source/security.d.ts +16 -0
- package/dist/detectors/source/security.js +680 -33
- package/dist/detectors/source/write-isolation.d.ts +8 -1
- package/dist/detectors/source/write-isolation.js +64 -30
- package/dist/detectors/source/writesafety.js +27 -14
- package/dist/detectors/standard/_doc.d.ts +1 -1
- package/dist/detectors/standard/_doc.js +5 -4
- package/dist/detectors/standard/r41-frontier.js +3 -2
- package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
- package/dist/detectors/standard/r46-returns-validator.js +48 -16
- package/dist/detectors/structural.d.ts +6 -7
- package/dist/detectors/structural.js +84 -48
- package/dist/not-checkable.d.ts +1 -1
- package/dist/not-checkable.js +11 -0
- package/dist/oracle.d.ts +92 -3
- package/dist/oracle.js +229 -12
- package/dist/registry.d.ts +8 -1
- package/dist/registry.js +74 -8
- package/dist/report.d.ts +11 -1
- package/dist/report.js +15 -2
- package/dist/rule-class.d.ts +85 -0
- package/dist/rule-class.js +218 -0
- package/dist/state-rank.d.ts +5 -0
- package/dist/state-rank.js +9 -0
- package/dist/tally.d.ts +13 -5
- package/dist/tally.js +15 -13
- package/dist/target.d.ts +32 -0
- package/dist/target.js +173 -0
- package/dist/violation-count.d.ts +48 -0
- package/dist/violation-count.js +94 -0
- package/fixtures/backend-standard-2825122.md +722 -0
- package/fixtures/backend-standard-4dcd3e0.md +722 -0
- package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
- package/fixtures/cr1-classes/surface.ts +206 -0
- package/fixtures/cr1-empty/nothing.ts +1 -0
- package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
- package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
- package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
- package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
- package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
- package/fixtures/rule-classes-2825122.json +228 -0
- package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
- package/package.json +2 -2
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
# The Backend Standard — what every VantageOS backend must hold
|
|
2
|
+
|
|
3
|
+
A VantageOS backend, like an MCP server, exists to be **production-ready, enterprise-grade, highly secure, token-efficient, and multi-organisation multi-tenant**. Those five terms are the standard's whole purpose; they are stated identically at the head of the MCP standard, because one product whose two standards claim different purposes is how a seam opens. Every rule below traces to at least one of the five (§13); a rule serving none is deleted, not kept.
|
|
4
|
+
|
|
5
|
+
This standard stands on **two sources**, and names which one each rule rests on.
|
|
6
|
+
|
|
7
|
+
- **Source one — two per-tool audits**, read row by row through the findings document `findings-from-two-tool-audits.md`: VantagePeers and VantageRegistry, the two backends the fleet built against no written standard. The audit measures what that produced; §1–§10 are what the measurement demands. But a standard whose only material is two products can forbid only what those two products happened to get wrong — it is silent on every subject they never exercised, and **a standard silent on a subject forbids nothing about it.**
|
|
8
|
+
- **Source two — established backend practice**, cited per rule (§7): the obligations a backend must hold to satisfy the five objectives whether or not our two products exercised them — schema migration, idempotency, personal-data lifecycle, background work, inbound webhooks, secret handling. These do not come from the audit; they come from named external practice, and each such rule cites it. Every subject a careful reader raises ends as a **numbered rule** or as a **written out-of-scope reason** (§10); a subject merely absent is the defect this closes.
|
|
9
|
+
|
|
10
|
+
**The platform is not neutral.** Every backend the fleet ships runs on one platform (Convex), and several rules here mean something only because of how that platform works — R-11's "index-range predicate, not a post-query filter" is unenforceable prose until it is named against the platform's query builder. A platform-neutral standard sounds principled and lets every platform-shaped failure through. §11 therefore names, per rule, the platform mechanism a rule depends on, the platform limits that are the platform's rather than ours, and what the standard demands of tables a platform component owns rather than we do. Each item there ends as a rule, a refinement naming the mechanism, or a written exclusion — no fourth outcome. The platform's own guidance is cited; it is never pasted, because a standard states what *we* require and cites what the platform provides.
|
|
11
|
+
|
|
12
|
+
**The frontier with the MCP standard.** This backend standard and the MCP standard (`resources/references/mcp-standard.md`) split four axes down the middle — who may call a tool, what operation it performs, how it searches, how it paginates — the MCP standard stating the *declaration owed on the served surface*, this one the *enforcement owed in the data layer*. The two share **one vocabulary of four names**, arbitrated once and bound to verbatim on both sides. The **error contract (§6)** is the fifth shared obligation: the MCP standard requires a refusal be readable at the caller, and cites this standard as the root of that guarantee — so this standard must carry it, and did not until this revision. **Duplication and the catalogue token ceiling are the MCP standard's alone**, named here only to mark the frontier.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 1. The two axes of divergence
|
|
17
|
+
|
|
18
|
+
A standard that only stops one backend drifting from another misses the harder defect. There are two axes, and the second is the one a rule-list of generic obligations never closes:
|
|
19
|
+
|
|
20
|
+
- **Axis 1 — between two backends.** VantagePeers and VantageRegistry expose the same *kinds* of tool and were free to implement each differently. The audits measured this: VP `189` rows over `18` columns (`123` MCP-exposed), VR `88` rows over `17` columns (`77` MCP-exposed). On the verbs that matter most the two share no vocabulary — VP ships `RPC 21` and `TRANSITION 11`, VR ships `UPSERT 19` and neither.
|
|
21
|
+
- **Axis 2 — between two tables, or two operations of one table, inside ONE backend.** A read and a write on one table were free to enforce tenancy by different keys, one search to bound its result where its neighbour did not. Invisible to a generic rule because each tool passes it in isolation; visible only by comparison against its neighbour. It is the dominant defect on both backends (§3, §4).
|
|
22
|
+
|
|
23
|
+
The instrument against both axes is a **per-table contract every table fills in the same fields, with no field left implicit** — divergence by omission becomes visible on sight. §2 is that contract; §3–§8 are the rules it carries.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 2. The per-table contract
|
|
28
|
+
|
|
29
|
+
Every table declares, for itself and for every tool that reads or writes it, all of the following fields. A table that leaves a field implicit is non-conforming; a table that fills a field differently from its neighbour is a divergence to be justified or removed (R-37 — the check that surfaces it per tool, not only the omission). The **five** fields marked **[shared]** bind to the arbitrated vocabulary the two standards share — four shared *names*, with **authority split into two contract fields, reader and writer**, because R-10 requires them a distinct tier each; a single merged authority field is the very merge R-14 calls non-conforming by construction.
|
|
30
|
+
|
|
31
|
+
| Contract field | Audit column | What it declares |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| **exposure** | `surface` | `public` (MCP surface), `internal`, or `library-owned` (a component owns it — see R-26). |
|
|
34
|
+
| **operation verb** **[shared]** | `crud` | one value from the closed verb set (R-2). `rpc` does not publish; a verb carries its fixed `(effect, cardinality)`; a multi-table write keeps its verb and declares every table it writes. |
|
|
35
|
+
| **reader authority** **[shared]** | `rbac_qui` | who may read, one tier from `public` / `org-member` / `org-admin` / `fleet-internal` / `master`, resolved from a verified identity (R-5). |
|
|
36
|
+
| **writer authority** **[shared]** | `rbac_qui` | who may write, a **distinct** tier from the same set, resolved from a verified identity (R-10). |
|
|
37
|
+
| **retrieval mechanism** **[shared]** | `search_mecanisme` | one value from `indexed-range` / `indexed-bm25` / `indexed-vector(cosine,dims,topK)` / `indexed-hybrid-rrf` / `unindexed-scan`; every list declares one; `unindexed-scan` is refused at publication (R-4). |
|
|
38
|
+
| **pagination** **[shared]** | `search_mecanisme` | cursor yes/no, `default`, `max` — every list and search — enforced by the tool, never inherited from the store (R-3). |
|
|
39
|
+
| **isolation locus** | `scope_enforcement` | an index-range predicate **inside the query**, never a **post-query handler filter** (R-11). |
|
|
40
|
+
| **boundary key** | `namespace_ou_org` | derived from a verified identity claim, never a caller-supplied argument (R-6). |
|
|
41
|
+
| **read/write coherence** | `rbac_coherence_table` | reads and writes on this table use the SAME tenant model; an incoherent pair carries a written reason or does not ship (R-7). |
|
|
42
|
+
| **vector boundary** | `search_mecanisme` | an `indexed-vector` tool declares its namespace and `topK` (R-12). |
|
|
43
|
+
| **token cost** | `tokens_estime_contexte` | the estimated context cost of the tool's response (R-9). |
|
|
44
|
+
| **remediation** | `rbac_adjustment_needed` | the fix a non-conforming tool owes, named per tool (R-28). |
|
|
45
|
+
| **disposition & lifecycle** | `table_purpose` + `table_conserver_supprimer` + `statut_suppression` | what the table is for, whether it stays, and — if removed — its survivor and the change that removed it (R-13, R-27). |
|
|
46
|
+
|
|
47
|
+
`backend-doctor` reads each field per table and refuses a table that omits one (**R-38** — the completeness gate over the thirteen §2 fields). **Duplication (`doublon_alias`) is recorded in the audit but its rule is the MCP standard's** (§5, frontier).
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 3. The four shared names — operation, retrieval, pagination, authority
|
|
52
|
+
|
|
53
|
+
These bind verbatim to the arbitrated vocabulary. The MCP standard declares them on the served surface; this standard enforces them in the data layer.
|
|
54
|
+
|
|
55
|
+
**R-2 — operation verb, closed set, `rpc` refused (Finding 1).** Reading the `21` VP `RPC` rows one by one, they are at least seven kinds — a pure function touching no data (`validate_task_payload`), a bulk write of unbounded rows selected by a filter (`bulk_complete_tasks`), an aggregate that only reads (`billing_summary_by_project`), an external effect (`send_message`) — all under one label, so nothing published tells a caller which tools write. The closed set: reads `list` · `get` · `search` · `aggregate`; writes `create` · `update` · `delete` · `upsert` · `transition` (a constrained status change, distinct from `update`; VP ships `11`) · `bulk-write` (writes N rows selected by a **filter**, its own verb because its blast radius is unbounded — see R-24); neither `pure` · `introspect`; outside the data layer `external-effect`, declared with what it touches (see R-25). `rpc` does not publish. `(effect, cardinality)` is derived from the verb, fixed per verb. A multi-table write keeps its verb and declares every table. VR's `UPSERT` maps to `upsert`.
|
|
56
|
+
|
|
57
|
+
**R-4 — retrieval mechanism, closed set, unindexed scan refused (Finding 4).** The closed set is `indexed-range` (a range scan over an index — what VR's one correct search does, and the shape the nineteen VP list tools with an **empty** mechanism cell must declare, because a list is a retrieval too) · `indexed-bm25` · `indexed-vector(cosine, dims, topK)` · `indexed-hybrid-rrf` · `unindexed-scan`. Every list and every search declares one; `unindexed-scan` is refused at publication — the `.collect()`+`.includes()` full-table load (`search_components`) is the failure that ends a product at its first real customer. **A retrieval reads only the fields the operation uses.** Over-reading has two axes — rows and fields — and this rule governs both: `.collect()` that loads every field of every row when the consuming code uses two of them over-reads on the field axis exactly as an unbounded scan over-reads on the row axis. The aborting backfill needed two columns of `briefingNotes` yet loaded every field of every row, and the unused `content` field alone consumed the whole per-transaction read budget; the fix is a projection of the fields the loop uses, never the whole document. (The row axis — bounding *how many* rows a scan reads — is R-31's read budget; this clause is the field axis, reading *only the columns used* of each row.)
|
|
58
|
+
|
|
59
|
+
**R-3 — pagination, tool-enforced (Finding 5).** Not one of the fourteen VP search tools can return a second page (`pas de curseur`), and eleven accept an **unbounded** maximum inherited from the backing store. Every list and every search declares a cursor, a `default`, and a `max`, all enforced by the tool and never inherited from a store; exhausted and truncated are distinguishable in the response; an unbounded maximum does not publish.
|
|
60
|
+
|
|
61
|
+
**R-5 — reader authority, resolved from identity (Finding 6).** Every tool declares its reader authority as one tier from `public` / `org-member` / `org-admin` / `fleet-internal` / `master`. VP's eight raw `rbac_qui` values collapse to these five. The tier is resolved from a verified identity, never a caller-supplied value — a tier read from an argument is not a tier.
|
|
62
|
+
|
|
63
|
+
**R-10 — writer authority, a distinct resolved tier (Finding 6).** Every tool declares its writer authority as a tier from the same set, **distinct** from the reader tier and resolved from a verified identity. Of `52` public writes, `32` check the caller's stated name against an allow-list — "is this a known caller", not "may this caller write this row" — and exactly `2` derive the organisation from a verified identity and refuse when absent. Reader and writer are two fields, or the `read=public write=admin` divergence VR carries `66` times cannot be recorded.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## 4. Isolation in the data layer — locus, source, coherence (Finding 6, Axis 2)
|
|
68
|
+
|
|
69
|
+
Over the `123` MCP-exposed VP tools, seven isolation mechanisms, the two worst dominant: **`40` tools read across every organisation and filter in the handler *after* the query**, `5` have no check at all. The write path is worse — `32` of `52` writes stand on an allow-list of names, `1` filters after the query, only `2` fail closed on a verified identity.
|
|
70
|
+
|
|
71
|
+
**R-6 (mechanical).** A write derives its org/tenant boundary from a verified identity claim and refuses when absent — never from a caller-supplied argument. VP `create_briefing_note` carries `namespace_ou_org = arg=createdBy`: the boundary is the argument, so there is no boundary. **What a control reads:** the provenance of every boundary value the write stores in its row or uses to bound it, traced back through the handler. **What makes it refuse:** a boundary value that traces to an argument — directly, or laundered through a rename, a destructure, a local variable, a spread, or a helper that returns its argument unchanged; the trace follows the value, never the name, so renaming `args.createdBy` to `owner` changes nothing. **What makes it pass — a recognised identity resolver, even when an argument goes into it.** A boundary obtained from a recognised identity resolver is conformant even when an argument is passed INTO that resolver, provided both hold: (a) the resolver honours the argument only from a verified principal — it refuses (throws, or returns the fail-closed empty scope) whenever no verified identity stands behind the argument; and (b) the value written derives from the resolver's verified OUTPUT, never from the argument that was passed in. VP's `requireAuthenticatedCaller` (an asserted actor is bound to the resolved identity, a mismatch refused) and `resolveVerifiedPerson` (a person proof accepted only from the service account) are the worked form. A resolver that hands its argument back when no verified principal stands behind it is a rename, and the boundary is still the argument. What makes a resolver *recognised* is R-53's: it resolves the principal by ID through the shared identity package, never by a name. **Not an argument boundary at all:** a field derived from the target resource itself or from the resolved caller — `lastActedBy` set from the resolved actor, `task.assignedTo` read from the stored row being written — is a fact about that row or that caller, not a caller-supplied boundary, and R-6 does not fire on it. Using a caller-chosen field of one row (`task.project`) to REACH another row that carries no tenant value is a different class, and it is R-52's. *(Refinement source: vantage-peers PR #1458 — 10 tests under a scoped member identity, plus Argus's human-path probes — proved unsteerable the three sites the R-6 grep flagged at vantage-peers @ `45e40c8` (`convex/missions.ts:232`, `convex/recurringTasks.ts:216`, `convex/tasks.ts:2453`): each takes its scope from `resolveVerifiedPerson` / `requireAuthenticatedCaller`. The refusal of argument-derived boundaries is unchanged; this states what passes.)*
|
|
72
|
+
|
|
73
|
+
**R-7 (mechanical, both halves).** Reads and writes on one table use the same tenant model. `rbac_coherence_table = INCOHERENT …` is the non-conformance (VP `105`, VR `66`). **Disposition is mechanical, not human:** an incoherent pair publishes only when it carries a **written justification token** (`JUSTIFIÉ:`/`JUSTIFIED:`) in its remediation cell; an `INCOHERENT` row with no such token is **non-conforming** (it owes a fix or a recorded justification), and only an unreadable/unparseable row is could-not-judge. The control decides on the token's **presence**, never on a person's opinion of the justification's soundness — the token being present is the whole of the acceptance (see §23, the disposition control).
|
|
74
|
+
|
|
75
|
+
**R-8 (mechanical, both halves).** A tool with no scope check publishes only when it carries an explicit written stateless/read-nothing justification token (`STATELESS:`/`JUSTIFIÉ:`). VP `AUCUN(public_no_scope_check)` (`5` rows) is flagged mechanically; a flagged row with no such token is **non-conforming** — soundness beyond the token's presence is not a control the standard accepts, so no human verdict is owed and none is deferred (§23).
|
|
76
|
+
|
|
77
|
+
**R-11 (mechanical).** The boundary is an **index-range predicate inside the query** — `.withIndex("by_org", q => q.eq("orgId", identity.orgId))` — never a **post-query handler filter**. The `40` VP `filterByOrgScope(in-handler, post-query)` rows are the defect: the query has already read rows the caller may not see, and correctness rests on a filter nothing enforces structurally — a leak and a cost at once. This field has no MCP-standard twin; the locus is invisible on the served surface. *(R-11 depends on the platform's query-builder mechanism; the platform task states which mechanism.)*
|
|
78
|
+
|
|
79
|
+
**R-52 (mechanical) — a write that reaches a row carrying no tenant value, through a value the caller can choose, checks the tenant on that target before it writes.** The class: a mutation — or an action or internal function it runs or schedules — patches, replaces or deletes a target row, inserts a row attributed to it, or performs an external effect addressed through it, where (1) the target carries no tenant value: its table has no tenant column (`orgId`/`tenantId`/`workspaceId`), or the column is optional and unset, so the row is shared by every tenant; and (2) the target is keyed or selected by a value the caller can choose — an argument, directly or laundered through a rename (the trace R-6 applies), or a field of a caller-owned row that the caller set through a public write (`task.project`, `task.title`). Such a write is non-conforming unless a **tenant check on the target** stands between the selection and the write: a guard that reads a **server-stamped** tenant — the target's own tenant column, or, where the target has none, the verified tenant of the caller or of the source row (the server-stamped `task.orgId`, never a caller-chosen field) — and refuses or skips the write unless that tenant may reach the target. A row with no tenant value is reachable only by a tenant the code names as fleet-scoped. **Why R-6 cannot see it.** R-6 asks where the boundary of the row being written comes from. Here the target has no boundary to derive, and the selector is not an argument but a stored field the caller wrote earlier, so every R-6 cell reads clean while the write lands in another tenant. **Source instance.** At vantage-peers `45e40c89a6080ef7743cfe8951502645f2e6e56f`, the `convex/tasks.ts` `complete` auto-link looked up `githubRepoMapping` by `task.project` and patched the global `issues` row (`status: "fixed"`, `fixedBy`, `fixCommits`, `linkedTaskIds`): an org-a member marked org-b's issue fixed. The IRP branch of the same handler, through the same lookup, inserted a `fixPatterns` row and scheduled a GitHub comment on org-b's repository. `issues`, `githubRepoMapping` and `fixPatterns` carry no tenant column; `backend-doctor` @ `c59cca4` named none of it. **Conformant shape.** vantage-peers PR #1459 (@ `93a7bba`): a gate reading the server-stamped `task.orgId` admits an unstamped (fleet) task or an active operator-org task and refuses every client org before both branches; its RED on `45e40c8` — `expected { …(17) } to deeply equal { …(13) }`, the org-b issue having gained four fields — is the write this rule forbids. **Frontier.** **R-6** governs the boundary written INTO a row (it comes from a verified identity); R-52 governs which row a write REACHES when that row has no boundary to read — a write can satisfy R-6 and fail R-52, as `complete` did. **R-11** governs where a read's tenant predicate runs; R-52 governs whether a write's target is checked at all. **R-26** governs a library-owned table at its seam; R-52 governs app-owned tables. *(Source: incident — vantage-peers @ `45e40c8` `convex/tasks.ts` `complete` auto-link and its IRP branch; the RED/GREEN of PR #1459.)*
|
|
80
|
+
**Detector spec.** *control kind:* static (source scan of the write surface against the schema). *reads:* `schema.ts` — per table, whether a tenant column exists and whether it is optional; per table, which fields a public write sets from its arguments (what makes a stored field caller-chosen); every public `mutation`/`action` handler and every internal function it reaches through `ctx.runMutation`/`ctx.runAction`/`ctx.scheduler.runAfter`; per write (`ctx.db.patch`/`replace`/`delete`/`insert`, a scheduled external effect), the provenance of the id or index key that selected its target. *red:* a write whose target carries no tenant value, selected by an argument-derived value or a caller-chosen stored field, with no guard on its path that reads a server-stamped tenant and refuses or skips the write. *green:* such a guard dominates the write; or the selector is not caller-choosable (a verified resolver's output, a server-stamped field, a constant); or the target table carries a required tenant column (then R-6 and R-11 govern). *could-not-judge:* only a selector whose provenance the walk cannot resolve, a guard whose block cannot be delimited, or an unreadable `schema.ts` — never a readable site. *kind:* mechanical — the tenant-column test, the caller-choosable trace and the guard's presence are all read from the delivered source. There is **no justification-token exit**: a write into another tenant is not a divergence to be justified (§23 governs divergences); the only exits from red are the tenant check and a selector the caller cannot choose. **Coverage fails on itself:** a tree whose schema declares no table without a tenant value reports no applicable site, naming the tables it read, never a clean bill.
|
|
81
|
+
|
|
82
|
+
**R-53 (mechanical) — an actor and a target are identified by ID, resolved through the shared identity package; a name never selects or authorises a row.** Four clauses, one number.
|
|
83
|
+
|
|
84
|
+
1. **The principal is resolved by ID.** The acting principal is resolved from the verified credential to its stored ID and its organisation's ID, through the shared identity package (`@vantageos/cloud-identity`), never by the product's own code. A typed or matched name (`callerOrchestrator`, `assignedTo`, an agent name carried in a claim and matched against a stored name) never selects a row and never authorises a write; a name is a display label only. A service account acting for an agent names that agent by ID, and the package resolves it within the service account's own organisation.
|
|
85
|
+
2. **The target is checked by ID.** Before any write, the target row is checked to belong to the resolved principal or its organisation by comparing stored IDs (the row's organisation ID or owner ID against the resolved ones), never by comparing a name field of the row with a name the caller typed or the credential carries.
|
|
86
|
+
3. **Refusal by default.** A principal or organisation the package cannot resolve is refused with the error contract (R-16). It is never passed through as master, as a service account acting for whoever it names, or as a name match.
|
|
87
|
+
4. **No local identity layer.** A product that resolves or checks identity in its own code is non-conforming by that fact, even where its comparison happens to be correct today: a name comparison standing in for identity, or a local copy of the package's resolver. A product-side wrapper that delegates resolution to the package and only adapts its result is not a re-implementation; a wrapper that decides identity itself (compares names, falls back to a default principal) is. Identity changes in one place, the shared package.
|
|
88
|
+
|
|
89
|
+
**Source instance.** On vantage-peers PR #1464's branch (@ `ce75179`), an org-a agent named `eta`, calling by name as the service account, landed on org-b's `eta` task at ten doors of `convex/tasks.ts`: `complete`, `update`, `start`, `pause`, `resume`, `blockTask`, `failTask`, `deleteTask`, `checkout`, `correctSegment`. `complete`, `update` and `deleteTask` did so even when a `verifiedActor` was presented. The MCP gates (`checkActorBinding`, `fromAllowList`) compared the typed name with the bearer's agent and never read the target, so all ten calls reached Convex. On the same branch, the org-less `bulk_complete` path let a bearer with no resolvable organisation fall into the name-only master path (Argus). `backend-doctor` named none of it. **Conformant shape.** The principal comes from the package's resolver as an ID and an organisation ID; each of the ten doors compares the target task's stored `orgId` (and owner ID where the door is owner-only) with those IDs before it writes; a bearer the package cannot place in an organisation is refused at `bulk_complete` and everywhere else. No merged delivery carries this shape yet; this rule names it, not a pull request. **Frontier.** **R-6** governs where the boundary written INTO a row comes from, and credits a *recognised identity resolver*; R-53 is what makes a resolver recognised: it resolves by ID through the shared package. A site R-6 passes on a resolver that matches names is still red under R-53. **R-52** governs a write whose target carries NO tenant value and is reached through a caller-chosen key; R-53 governs a target that DOES carry an organisation or owner value and is admitted by a name instead of by that ID. **CR-1** (backend-doctor's caller-resolution check, outside the numbered rules) asks whether a public registration resolves ANY caller before it serves; R-53 asks HOW the caller was resolved and whether the target was checked against it: a door that resolves a caller by name passes CR-1 and fails R-53. *(Source: incident — vantage-peers PR #1464 @ `ce75179`, the ten `convex/tasks.ts` doors and the org-less `bulk_complete` path; the operator order of 2026-10-05: an actor or a target is identified by its ID, never by its name, and resolving and checking identity is the shared package's job, not each door's.)*
|
|
90
|
+
**Detector spec.** *control kind:* static (source scan of the write surface in `convex/` and of every gate in `mcp-server/src/`). *reads:* every public `mutation`/`action` handler and every internal function it reaches; every bearer-auth branch and gate in the MCP server; the import graph to `@vantageos/cloud-identity`; per write, the predicate that admitted the caller to the target and the selector of the target. *red:* (a) a principal ID or organisation taken from anything other than the package's resolver output (a typed name argument, a name claim matched against a stored name field); (b) a write whose admission predicate compares a name field (`assignedTo`, `createdBy`, `callerOrchestrator`, `name`) with a caller-typed or credential-carried name, or has no predicate on the target at all, instead of comparing the target's stored organisation or owner ID with the resolved ID; (c) a branch that continues when the package returns no principal or no organisation (as master, as the service account, or on a name match) instead of refusing; (d) a function outside the package that decides identity (compares names to authenticate, or re-implements the package's resolution). *green:* the principal is the package resolver's output; every write's admission compares stored IDs with the resolved IDs; the unresolved branch refuses. A name may appear only as display (written to a label field, rendered, logged), never as an operand of an admission comparison. *could-not-judge:* only a predicate or branch the parser cannot delimit, or a package import that cannot be resolved; never a readable site. *kind:* mechanical. There is **no justification-token exit**: identity by name is not a divergence to be justified (§23 governs divergences); the only exit from red is resolution and comparison by ID. **Coverage fails on itself:** a tree with no write and no gate reports no applicable site, naming the doors it read, never a clean bill.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 5. Exposure, vector boundary, cost, purpose — the remaining audit fields
|
|
95
|
+
|
|
96
|
+
**R-1 (mechanical).** Every table declares its exposure — `public` / `internal` / `library-owned`. VP `surface` splits `MCP-public` (123), `component` (39), `hors-MCP` (26); a blank `surface` (0 rows on the day158 audit) is non-conforming (§17.5 corrects an earlier stale citation).
|
|
97
|
+
|
|
98
|
+
**R-9 (mechanical presence).** Every tool declares its `tokens_estime_contexte`. *(Frontier: the catalogue-wide token ceiling — VP `12 068` / VR `17 158` / `29 226` together at connection — is the MCP standard's rule, not this one.)*
|
|
99
|
+
|
|
100
|
+
**R-12 (mechanical).** An `indexed-vector` tool declares the namespace it is confined to and its `topK`. A vector search with no namespace crosses every tenant's embeddings; one with no `topK` is an unbounded read (R-3) in vector clothing.
|
|
101
|
+
|
|
102
|
+
**R-13 (mechanical).** Every table declares `table_purpose` and a disposition from a **closed set** — **the table stays**, **the table goes**, or **undecided**. `undecided` never stands alone: it carries a named owner and a deadline. A disposition that is empty, outside the set, or `undecided` with no owner and no deadline does not conform — indecision declared as such is a state to resolve by a named date, not a conforming answer. A table with no stated purpose is a deletion candidate.
|
|
103
|
+
|
|
104
|
+
**Frontier — duplication (Finding 2).** Sixteen VP tools carry a duplication verdict, twelve marked for deletion, costing `1 166` catalogue tokens on every connection. **The rule — no two published tools perform the same operation on the same data — is the MCP standard's**; the `doublon_alias` column stays here as recorded evidence, the rule that acts on it does not.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## 6. The error contract — a refusal is data, readable at the caller (source two + MCP frontier)
|
|
109
|
+
|
|
110
|
+
The MCP standard requires that a refusal be readable at the caller and names this standard as the root of the guarantee. The rebuild dropped it; this section restores it. It rests on both sources: the audit shows fail-open scope fallbacks, and established practice (OWASP ASVS v4 §7 error handling & logging) requires a structured, non-leaking error.
|
|
111
|
+
|
|
112
|
+
**R-16 (judgement; shape detection mechanical).** Every refusal returns a structured error `{ code, field|scope, message }`:
|
|
113
|
+
- `code` is **stable** — a caller and a test branch on it, so it never changes meaning between versions.
|
|
114
|
+
- `field|scope` names **what** was refused — the offending field or the scope that failed.
|
|
115
|
+
- `message` is the **human-readable cause in the field a caller reads first**, and it names a **remedy the caller can apply from the surface it already holds** — no opaque refusal, no un-followable remedy.
|
|
116
|
+
- **Fail-closed is the reference pattern.** An absent organisation refuses with a stable code (`AUTH_NO_ORG_ID`), never a fall-back to a global or caller-supplied scope (this is the enforcement half of R-6).
|
|
117
|
+
- **End-to-end.** The contract survives every layer above: a conformant MCP layer maps it into the error envelope without flattening `message` away — the guarantee is end-to-end, not backend-only (the MCP-standard frontier).
|
|
118
|
+
- A return value never means both "could not continue" and "nothing left" — a refusal and an empty result are distinct (this is the read-side companion of R-3's exhausted-vs-truncated).
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## 7. What established practice requires beyond our two products (source two)
|
|
123
|
+
|
|
124
|
+
Each rule below rests on **established external practice, cited**, not on the audit — because the audit's two products never exercised the subject, and their silence is not a permission. Each is a numbered rule; subjects that end as an exclusion are in §10.
|
|
125
|
+
|
|
126
|
+
**R-17 — schema migration is expand-contract, and reversible or written-irreversible.** A schema change ships as *expand* (add the new shape, deploy), *migrate* (backfill, dual-write), *contract* (cut over, then drop the old shape) — never a destructive change in a single deploy that a rollback cannot undo. A migration is reversible, or it carries a written statement of why it is not and what guards the one-way step. *(Source: the expand-contract / parallel-change migration pattern, established practice for zero-downtime schema evolution.)*
|
|
127
|
+
|
|
128
|
+
**R-18 — a retriable write is idempotent.** Any write a client, a webhook, or a retrying job may deliver more than once is idempotent: an idempotency key or a natural unique key makes a duplicate delivery a no-op, never a second row. `create` without an idempotency guard is non-conforming on any path that can retry. *(Source: idempotency-key practice for at-least-once delivery; Stripe/IETF idempotency drafts.)*
|
|
129
|
+
|
|
130
|
+
**R-19 — personal data declares a retention period and a subject-deletion path.** A table holding personal data declares how long it is kept and the mechanism that enforces expiry, and exposes a deletion path that removes a data subject's rows on request with a defined cascade, verifiably. *(Source: GDPR Art. 5(1)(e) storage limitation and Art. 17 right to erasure.)*
|
|
131
|
+
|
|
132
|
+
**R-20 — background and scheduled work is idempotent, bounded, retried-with-cap, and observable.** A scheduled or background job is idempotent (R-18), bounds its own work per run, retries a failure a bounded number of times before surfacing it, and logs each run with its outcome. An unbounded or silent job does not ship. *(Source: established job-queue practice — bounded retry with dead-letter, at-least-once semantics.)*
|
|
133
|
+
|
|
134
|
+
**R-21 — an inbound webhook verifies its signature, is idempotent on its delivery id, and fails closed.** A webhook handler verifies the sender's signature before acting, treats the provider's delivery id as the idempotency key (R-18), and refuses an unverified or replayed payload with the error contract (R-16). *(Source: standard webhook-security practice — signature verification + replay protection.)*
|
|
135
|
+
|
|
136
|
+
**R-22 — a secret is read from the environment or a secret store, never committed, never returned, never logged.** No secret is in source, in a response body, or in a log line; a tool that would echo a secret redacts it. *(Source: OWASP ASVS v4 §6 (stored cryptography / secrets) and the 12-factor config principle.)*
|
|
137
|
+
|
|
138
|
+
**R-23 — an operation declares a bound on its own runtime and its rate.** A public write or an expensive read declares a per-caller-identity rate limit and a runtime bound at which it fails closed with the error contract; an operation that cannot complete within the bound is a background job (R-20), not a synchronous call. Unlimited is a written exception. *(Source: rate-limiting and timeout practice; OWASP ASVS v4 §11 business-logic / anti-automation. The platform task refines the runtime bound to the platform's actual limit.)*
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 8. Audit rows the first cut left uncovered (source one, re-read)
|
|
143
|
+
|
|
144
|
+
Re-reading the audit row by row surfaces facts the first cut did not turn into a rule.
|
|
145
|
+
|
|
146
|
+
**R-24 — a filter-selected bulk-write defaults to a dry run and applies only on explicit confirm.** `bulk_complete_tasks` is `bulk-write` (R-2) and its `operation_detail` reads `dry-run défaut` — it already defaults safe, but **no rule required it**. A `bulk-write` whose rows are chosen by a filter returns the affected set without applying by default, and applies only on an explicit confirm flag, because its blast radius is unbounded by construction. *(Evidence: `bulk_complete_tasks`, `operation_detail = complete N tasks par filtre (dry-run défaut)`.)*
|
|
147
|
+
|
|
148
|
+
**R-25 — a minted storage address or a cross-boundary signed bundle is org-bound, expiring, and checked on both mint and redemption.** An `external-effect` tool that mints an upload address into storage or moves a signed bundle across an organisation boundary binds the artifact to the verified organisation, gives it an expiry, and checks the boundary at **both** the mint and the redemption — not only at the mint. *(Evidence: `generate_upload_url` → `mint URL upload Convex storage, orgId lié`; `export_okf_bundle` → `exporte namespace en tarball signé` (`checkNamespaceRead`); `import_okf_bundle` → `importe bundle OKF` (`checkNamespaceWrite`).)*
|
|
149
|
+
|
|
150
|
+
**R-26 — a library-owned (component) table declares its boundary at the component seam; the application never assumes the library enforces tenancy.** The `39` `component` tables are Convex components, mounted by the app and running their own transactions on tables the app cannot `.withIndex` into. The standard governs them at the seam: the application declares the authority tier and the boundary key it hands the component, and treats the component as enforcing isolation only to the extent the component's own contract states it. A component with no stated isolation contract is used `fleet-internal`/`master` only — never exposed org-scoped on trust; a component table with an undeclared seam is non-conforming exactly as an app-owned table with a blank field. *(Sources: the audit's `39` `surface=component` rows; the Convex component model — this rule folds the former platform-layer R-32, which stated the same obligation.)*
|
|
151
|
+
|
|
152
|
+
**R-27 — a removed tool's record names its survivor and the change that removed it.** Deprecation is traceable: `statut_suppression` on a removed tool names the survivor it was merged into and the pull request that removed it (the `14` `SUPPRIMÉ @ PR#1169 … (survivor: …)` rows are the worked form). A tool that vanishes with no survivor and no change reference is an untraceable deletion. *(Evidence: `statut_suppression`, `14` rows naming a survivor + PR.)*
|
|
153
|
+
|
|
154
|
+
**R-28 — the inventory carries a per-tool remediation field.** Every non-conforming tool names the fix it owes, in `rbac_adjustment_needed`; `46` VP rows already do (`35` `CORRIGER: scoper l'écriture sur orgId`, `11` `CORRIGER: lier le namespace à l'orgId vérifié`). A conformance failure with no named remediation is a finding with no owner. This is a structural rule like R-14: it governs the inventory schema, so a fix is attached to its defect rather than tracked apart. *(Evidence: `rbac_adjustment_needed`, `46` remediation rows.)*
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## 9. The security page, routed — outbound fetch and the attack surface
|
|
159
|
+
|
|
160
|
+
The specification's `basic/security_best_practices` page (revision `2025-06-18`, cross-referenced with `2025-11-25`) is a **whole page with eight attack sections**, and a page is read whole. Its subjects — and the wider `2025-06-18` practice sweep of nineteen subjects (source: the shared research record, MCP-side pass merged as the served-surface rules) — are each routed in §9b with **no unrouted row**: a rule here (the data layer), a rule on the served surface (the MCP standard), already covered by a rule above, or a written out-of-scope reason. The assumption that a security subject "looked like an MCP subject" and stopped at the served surface is precisely what left **outbound fetch unwritten on both sides**. It is written here now, against the whole OWASP SSRF cheat sheet and not its first two lines.
|
|
161
|
+
|
|
162
|
+
**R-33 — an outbound fetch to an address the backend did not choose is validated as an SSRF surface.** Any outbound call whose address is or can be influenced by a caller — a webhook callback URL stored in a row, a document or corpus fetched from a customer-supplied link, an image imported from a URL, an authorization-server metadata document dereferenced during discovery, any `external-effect` tool that calls out — is guarded before it dereferences:
|
|
163
|
+
- **scheme** — `https` in production; a loopback/`http` target is permitted only in development, never in the deployed backend.
|
|
164
|
+
- **address range** — private, loopback, link-local, and reserved ranges are refused (RFC 9728 §7.7): `10/8`, `172.16/12`, `192.168/16`, `127/8`, `169.254/16` (and its IPv6 `fe80::/10`), `::1`, unique-local `fc00::/7`, and the cloud metadata address `169.254.169.254` explicitly. A backend that reaches `169.254.169.254` on a caller's instruction is reading our own cloud metadata service on their behalf.
|
|
165
|
+
- **redirects** — every redirect hop's target is re-validated against the same rules; a `30x` to an internal address is the bypass a one-time check misses.
|
|
166
|
+
- **time-of-check vs time-of-use** — the address is re-checked **at connection time**, not only at validation time; the DNS name is pinned (resolve once, connect to the resolved-and-validated address) so a name that validated as public cannot re-resolve to `127.0.0.1` between the check and the connect. This swap is the whole attack.
|
|
167
|
+
- **never hand-roll the address parser** — octal, hex, decimal, and IPv4-mapped-IPv6 encodings defeat a custom string check; validation uses the platform's own address parser or an egress proxy with an allow-list, never a regex. An egress proxy with DNS pinning is the reference pattern (SHOULD).
|
|
168
|
+
|
|
169
|
+
*(Sources: MCP spec `2025-06-18` `basic/security_best_practices` §Server-Side Request Forgery and §OAuth authorization-URL validation; the OWASP SSRF Prevention Cheat Sheet — cited, not restated.)*
|
|
170
|
+
**Detector spec.** *reads:* every outbound call site (the sweep in §9a). *red:* a `fetch`/request whose address derives from an argument, a stored row, or a redirect, with no range check before dereference, or a check that runs only at validation and not at connect, or a hand-rolled string/regex address check. *green:* the address passes a platform/proxy validator (scheme + range + redirect) and is pinned between check and connect. *kind:* mechanical. The call-site inventory and the "address influenced by a caller?" classification are the mechanical pole. A flagged call site whose validator soundness is asserted rather than derived publishes only when it carries a written token (`JUSTIFIÉ:`/`JUSTIFIED:`) in its remediation cell; a flagged site with no token is **non-conforming**. The control decides on the token's **presence**, never on a person's opinion of the justification's soundness (§23, the disposition control — the same mechanism R-7 and R-8 use). Could-not-judge is reserved for input the instrument could not read, never for "a person should look at this". **Frontier — MCP standard.** `S-10` (MCP server standard) declares this SSRF guard on the served surface — the full OWASP-union blocklist, IP validation, redirect re-validation and DNS pinning; R-33 executes it in the data-access code, the outbound fetch itself. S-10 declares the guard, R-33 enforces it in the query; each side names the other.
|
|
171
|
+
|
|
172
|
+
**R-34 — an outbound call carries the backend's own credential, never the caller's, and validates audience before trusting an inbound one.** A backend calling a third party presents a credential minted for and scoped to that callee; it **never forwards a token the caller supplied** (token passthrough). Before trusting any inbound bearer token it validates the token's audience is **itself** and refuses one minted for another party; it requests the **minimal scope** the call needs. *(Sources: spec `2025-06-18` `basic/security_best_practices` §Token Passthrough; `basic/authorization` §Access Token Privilege Restriction + audience validation; scope-minimization §.)*
|
|
173
|
+
**Detector spec.** *reads:* every outbound call with an `Authorization` header, and every inbound-token verification. *red:* an outbound header that forwards a caller-supplied token verbatim, or an inbound-token check with no audience assertion, or a scope request wider than the call. *green:* outbound credential derived from the backend's own secret/service identity; inbound audience asserted; scope narrowed. *kind:* mechanical. Header-forwarding and a missing audience assertion are mechanically greppable. A site whose scope-minimality is asserted rather than derived publishes only when it carries a written token (`JUSTIFIÉ:`/`JUSTIFIED:`) in its remediation cell; a flagged site with no token is **non-conforming**. The control decides on the token's **presence**, never on a person's opinion of the justification's soundness (§23, the disposition control — the same mechanism R-7 and R-8 use). Could-not-judge is reserved for input the instrument could not read, never for "a person should look at this". **Frontier — MCP standard.** `S-8` guards the served facade — no token passthrough, the inbound `aud` validated as a pre-request gate; R-34 applies that audience check inside the data access and presents the backend's own callee-scoped upstream credential. S-8 guards the facade, R-34 enforces it in the query; each side names the other.
|
|
174
|
+
|
|
175
|
+
**R-35 — content the backend returns toward a model is untrusted data, never instructions.** A tool result, a fetched document, a corpus chunk, or a stored row that will reach an LLM is **data**, and the backend never interpolates it into a position that carries or alters instructions; it marks provenance (which content is model-authored vs user/third-party-sourced) so a consumer can keep the trust boundary. This is a subject the MCP spec leaves generic (OWASP LLM01) — **ours to state, to exceed the market.** *(Source: OWASP LLM01 Prompt Injection; MCP spec `2025-06-18` `server/tools` §Security, generic.)*
|
|
176
|
+
**Detector spec.** *reads:* every path that concatenates fetched/stored content into a prompt, a system message, or a tool-instruction field. *red:* third-party or user-sourced content spliced into a system/instruction position with no provenance marker. *green:* such content carried in a data field with a provenance marker, never in an instruction field. *kind:* mechanical. The splice inventory is mechanical: content concatenated into a system/instruction position with no provenance marker is red, the same content in a data field with a marker is green — the red/green above is already fully decidable. A flagged splice claimed to be an accepted exception publishes only when it carries a written token (`JUSTIFIÉ:`/`JUSTIFIED:`) in its remediation cell; a flagged site with no token is **non-conforming**. The control decides on the token's **presence**, never on a person's opinion of the justification's soundness (§23, the disposition control — the same mechanism R-7 and R-8 use). Could-not-judge is reserved for input the instrument could not read, never for "a person should look at this". **Frontier — MCP standard.** `C-16` (MCP trunk) holds the trust boundary on the served surface — the server sanitises its outputs, the consumer validates a result before the LLM; R-35 applies it in the data, never interpolating third-party content into an instruction position and marking provenance on what it returns toward a model. C-16 on the served surface, R-35 in the data; each side names the other.
|
|
177
|
+
|
|
178
|
+
**R-36 — the backend's dependencies are pinned and carry provenance.** Every dependency the backend ships is pinned by a lockfile, and the published artifact carries a provenance attestation; an unpinned or unattested dependency does not ship. This too has **no authoritative MCP source** and is ours to state. *(Source: npm provenance / SLSA build-provenance; OWASP dependency practice.)*
|
|
179
|
+
**Detector spec.** *reads:* the lockfile and the publish config. *red:* a floating version range in a shipped manifest, or a publish without `--provenance`/attestation. *green:* every dependency pinned in the lockfile; publish carries provenance. *kind:* mechanical. **Frontier — MCP standard.** `C-17` carries the same provenance-and-lockfile obligation on the **published MCP package** (server / extension / App); R-36 carries it on the **backend's own artifact**, the data-layer service. One obligation over two distinct artifacts on two layers — not a duplicate: each side names the other so the apparent overlap reads as a boundary, not the drift the restructure exists to prevent.
|
|
180
|
+
|
|
181
|
+
### 9a. Every outbound call site, by command
|
|
182
|
+
|
|
183
|
+
A rule with no inventory of what it governs is prose. The sweep over the data layer and the MCP server:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
grep -rnE '\bfetch\(|axios|https?\.request|new URL\(|generateUploadUrl|storage\.getUrl' convex mcp-server/src \
|
|
187
|
+
| grep -vE '_generated|\.test\.|__tests__|node_modules'
|
|
188
|
+
-> 18 non-test outbound call sites
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Positive control (the pattern can match a known outbound call, so a zero would be meaningful):
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
grep -rnE '\bfetch\(' convex/githubComments.ts -> convex/githubComments.ts:31: await fetch( ✓ matches
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Classification — **ours** (fixed host, address the backend chose) vs **caller-influenced** (address from an argument, a stored row, or a redirect, the R-33 surface):
|
|
198
|
+
|
|
199
|
+
| Site | Address | Class |
|
|
200
|
+
|---|---|---|
|
|
201
|
+
| `githubComments.ts`, `prMonitor.ts`, `issueStats.ts`, `errorMonitorActions/AutoResolver.ts`, `issueClosedSweep.ts` | GitHub API (fixed host) | ours |
|
|
202
|
+
| `gumroadWebhook.ts:534` | `https://api.resend.com/emails` (fixed) | ours |
|
|
203
|
+
| `credentials.ts:205` | `jwksUrl` (Clerk issuer, config-derived) | ours (config, verify pinned) |
|
|
204
|
+
| `serviceAccountAuth.ts:116` | `${domain}/v1/client/sign_ins` (Clerk domain, config) | ours (config) |
|
|
205
|
+
| `okfBundleNode.ts:702` | `fetch(safeUrl.toString())` — **already wrapped** | caller-influenced, guarded |
|
|
206
|
+
| `okfBundleNode.ts:916` | `fetch(url)` — **raw, unguarded** | caller-influenced, **R-33 gap** |
|
|
207
|
+
| `kbShared.ts` / `kbMutations.ts` / `kbIngest.ts` | corpus/document fetch from a supplied link | caller-influenced, verify against R-33 |
|
|
208
|
+
|
|
209
|
+
The inventory is itself the finding: `okfBundleNode.ts` guards one fetch (`safeUrl`, line 702) and leaves its neighbour raw (`fetch(url)`, line 916) — the Axis-2 divergence (§1) inside one file, and the exact reason R-33 is a rule and not a note.
|
|
210
|
+
|
|
211
|
+
### 9b. The routing table — nineteen subjects and eight attacks, no unrouted row
|
|
212
|
+
|
|
213
|
+
Every subject from the whole-page read and the nineteen-subject sweep, routed. **Data-layer half → a rule here; served surface → the MCP standard (merged); already covered → the rule above; out-of-scope-here → §10 with a reason.**
|
|
214
|
+
|
|
215
|
+
| # | Subject | Owner | Routed to |
|
|
216
|
+
|---|---|---|---|
|
|
217
|
+
| 19 / attack | **SSRF / OAuth-URL validation** | data layer | **R-33 (here)** |
|
|
218
|
+
| 5 / attack | **Token passthrough** | data layer + surface | **R-34 (here)**; served-surface half = MCP standard |
|
|
219
|
+
| 18 | Authorization audience / no-passthrough | data layer half | **R-34** (audience assert); OAuth flow = MCP standard |
|
|
220
|
+
| attack | Scope minimization | data layer half | **R-34** (minimal scope); token issuance = MCP standard |
|
|
221
|
+
| 3 | Prompt injection (LLM01) | data layer (ours) | **R-35 (here)** |
|
|
222
|
+
| 15 | Supply chain / provenance | data layer (ours) | **R-36 (here)** |
|
|
223
|
+
| 10 | Logging — no secrets/PII | data layer half | **already covered — R-22** (secrets never logged); capability deprecated on surface |
|
|
224
|
+
| 4 / attack | Confused deputy (proxy consent) | served surface | MCP standard; data-layer touchpoint = R-34 (no forward) |
|
|
225
|
+
| attack | Session hijacking | served surface | MCP standard; data-layer identity = R-5/R-6 (verified token, no arg) |
|
|
226
|
+
| 17 | Structured errors (JSON-RPC codes) | served surface | MCP standard; data-layer error contract = R-16 (frontier named) |
|
|
227
|
+
| 8 | Structured content / outputSchema | served surface | MCP standard; data-layer return shape = R-16 |
|
|
228
|
+
| 9 | Protocol-list pagination (opaque cursor) | served surface | MCP standard; data-layer pagination = R-3 (frontier named) |
|
|
229
|
+
| 1 | Tool annotations (untrusted) | served surface | MCP standard |
|
|
230
|
+
| 2 | Human confirmation of destructive tool | app / host | MCP standard; data-layer analogue = R-24 (dry-run) |
|
|
231
|
+
| 7 | Progress + cancellation | served surface | MCP standard |
|
|
232
|
+
| 13 | Completions (exfil surface) | served surface | MCP standard |
|
|
233
|
+
| 16 | Version negotiation floor | served surface | MCP standard |
|
|
234
|
+
| 6 | Resource subscriptions | served surface | MCP standard (branch on negotiated version) |
|
|
235
|
+
| 14 / attack | Local/stdio server compromise | out-of-scope here | §10 — the data layer has no local filesystem or stdio; the MCP-server process owns it |
|
|
236
|
+
| attack | stdio transport in proxy architecture | out-of-scope here | §10 — transport/deployment, not the data layer |
|
|
237
|
+
| 12 | Roots (filesystem boundary) | out-of-scope here | §10 — deprecated `2026-07-28`; no local filesystem in the data layer |
|
|
238
|
+
| 11 | Sampling human-review | out-of-scope here | §10 — deprecated `2026-07-28`; app-layer |
|
|
239
|
+
|
|
240
|
+
No row is unrouted. Four subjects with a data-layer half become rules here (R-33..R-36); three fold into a rule already written (R-22, R-5/R-6, R-16); four are named out-of-scope with a reason (§10); the remainder are the served surface's, merged, with the frontier named so neither layer doubles the other.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## 10. Out of scope, with a written reason (not a silent gap)
|
|
245
|
+
|
|
246
|
+
A subject a careful reader raises that this standard deliberately does **not** rule on is named here with its reason — so its absence is a decision, not an oversight.
|
|
247
|
+
|
|
248
|
+
- **Backup, restore, and storage durability.** The platform provides these; the standard does not re-specify them. Its obligation is to **name the platform guarantee it relies on** rather than restate it: the standard relies on Convex's managed durability — synchronous replication and continuous backup with point-in-time recovery — and requires only that a backend does nothing that voids it (no out-of-band writes bypassing the transaction log, §11 R-29). *(Source: the platform's published durability and backup guarantees.)* This closes the open item the first cut left.
|
|
249
|
+
- **Network-edge concerns (DDoS, TLS termination, WAF).** Held by the deployment platform and the CDN, above the data layer this standard governs. Named so a reader does not read their absence as a claim that a backend need not consider them.
|
|
250
|
+
- **The catalogue token ceiling and tool duplication.** The MCP standard's, per the §5 frontier.
|
|
251
|
+
- **Local / stdio server compromise, and stdio transport in a proxy architecture** (security-page attacks; sweep #14). The data layer runs on a managed platform with no local filesystem and no stdio transport — there is no `~/.ssh` to read, no shell to spawn. The concern is real but it is the **MCP-server process's** (its deployment), not the data layer's; it is a served-surface/transport rule, merged there. Named so its absence here is a boundary, not a blind spot.
|
|
252
|
+
- **Filesystem roots (sweep #12) and sampling human-review (sweep #11).** Both **deprecated** in the `2026-07-28` revision, and both app/client-layer: roots expose a `file://` boundary the data layer does not have, and sampling is an app-side LLM call. Out of scope here; if a legacy client still uses roots, the server-side path validation it needs is the MCP standard's.
|
|
253
|
+
|
|
254
|
+
## 11. The platform layer — the mechanism each rule depends on (source three)
|
|
255
|
+
|
|
256
|
+
A rule stated generally that only holds because of a platform mechanism is unenforceable prose until the mechanism is named. This section names the mechanism each such rule depends on, settles which bounds are the platform's and which are ours, and states what the standard demands of the tables a platform component owns. The platform's guidance is **cited, never pasted** — the standard states what we require; the platform states what it provides.
|
|
257
|
+
|
|
258
|
+
**Refinements — the platform mechanism an existing rule depends on.** These add no rule; they make an existing rule checkable by naming the exact mechanism, so a reviewer can see the rule pass or fail in code rather than argue about prose.
|
|
259
|
+
|
|
260
|
+
| Rule | The platform mechanism it depends on (cited: Convex guidelines) |
|
|
261
|
+
|---|---|
|
|
262
|
+
| R-2 operation verb | `query` = read, `mutation` = write (transactional), `action` = `external-effect` (no `ctx.db`, may `fetch`); `pure`/`aggregate` are queries. The four function kinds are the verb's effect class. |
|
|
263
|
+
| R-3 pagination | `.paginate(paginationOpts)` returns `{ page, isDone, continueCursor }`; `numItems` is the tool-set `max`, `continueCursor` the cursor, `isDone` distinguishes **exhausted** from **truncated** — R-3's response contract is the platform's return shape. |
|
|
264
|
+
| R-4 retrieval / no unindexed scan | `.withIndex` (indexed-range), `.withSearchIndex` (bm25), the vector component (vector); the platform itself forbids `.filter` in a query and `.collect()` on an unbounded table — an `unindexed-scan` is that anti-pattern. |
|
|
265
|
+
| R-5 / R-6 / R-10 authority from verified identity | `ctx.auth.getUserIdentity()`, keyed on `identity.tokenIdentifier`; the platform's own rule is **"NEVER accept a userId as a function argument for authorization"** — R-6 is the platform's rule restated. `auth.config.ts applicationID` is checked against the JWT `aud`. |
|
|
266
|
+
| R-11 isolation locus | `.withIndex("by_org", q => q.eq("orgId", identity.orgId))`; the platform's own rule is **"Do NOT use `filter` in queries; define an index and use `withIndex`"** — R-11 is that rule applied to the tenant boundary. |
|
|
267
|
+
| R-16 error contract | a refusal is a thrown `ConvexError` carrying `{ code, field/scope, message }`; a `mutation` that throws rolls its transaction back, so **fail-closed is the platform's default**, not something the code must arrange. |
|
|
268
|
+
| R-17 migration | the schema validates every write, so a non-additive change fails closed at write time; expand-contract is: add the field optional, backfill by a batched scheduled mutation, then tighten the validator. |
|
|
269
|
+
| R-20 background / scheduled | `ctx.scheduler.runAfter/runAt` and `crons.interval`/`crons.cron` (never the removed `hourly`/`daily` helpers); a job that self-schedules its next batch is the platform's continuation pattern. |
|
|
270
|
+
| R-21 webhook | an `httpAction` in `convex/http.ts` verifies the signature before any `ctx.runMutation`; the mutation it calls is idempotent on the delivery id. |
|
|
271
|
+
| R-25 minted address / bundle | `ctx.storage.generateUploadUrl()` mints, `ctx.storage.getUrl()` returns a signed URL (null if absent); the signed URL carries **no tenancy**, so the app binds ownership via an app-owned row keyed to the verified org and checks it on read. |
|
|
272
|
+
|
|
273
|
+
**New platform rules.**
|
|
274
|
+
|
|
275
|
+
**R-29 (mechanical).** A write that must be atomic runs in **one mutation** — one transaction, retried automatically on a write conflict — and an `action` never stands in for a transaction. An action orchestrating several mutations to fake atomicity has a race window by construction; a durable invariant across tables is one mutation. *(Source: Convex guidelines — "queries and mutations are transactions, so splitting logic into multiple calls introduces the risk of race conditions"; "never use `ctx.db` inside of an action." This is also what keeps managed durability intact, §10.)*
|
|
276
|
+
|
|
277
|
+
**R-30 (mechanical) — the declared bound is ours; the platform limit is a backstop.** This settles which bound is whose. The platform's structural limits — a `1 MB` document, an array of at most `8192`, an object of at most `1024` entries, and the per-transaction read/write caps — are ceilings no code can exceed. They are **not** the bound a tool declares. R-3's `default` and `max` are **ours**, chosen below the platform limit; a tool whose only bound is the platform's (the `.take(500)` with no truncation signal, the `?? unbounded` inherited from the store) is non-conforming, because it has declared no bound at all and merely runs until the platform stops it. *(Source: Convex type and transaction limits.)*
|
|
278
|
+
|
|
279
|
+
**R-31 (mechanical) — a bulk operation beyond one transaction self-schedules in batches, on BOTH the read and the write budget.** A bulk operation that can exceed a per-transaction limit processes a batch with `.take(n)` (or `.paginate`) and calls `ctx.scheduler.runAfter(0, self, …)` to continue, so each invocation stays within the transaction limit; it never attempts an unbounded read or an unbounded write in one call. **The read budget and the write budget are distinct, and both bind.** The per-transaction cap has two halves — bytes read and bytes written (R-30) — and an operation exceeds either independently: an operation that writes nothing but `.collect()`s an unbounded table still aborts on the **read** cap (`Too many bytes read in a single function execution`), so bounding the write half alone does NOT satisfy this rule. The read side pages exactly as the write side does — `.take(n)`/`.paginate` + reschedule — and reads only the fields the operation uses (R-4, field axis). **Scope — any bulk operation over an unbounded set, not only a published `bulk-write`.** This binds a `bulk-write` tool (R-24) and equally a one-shot migration invoked by hand (`npx convex run`): a hand-run backfill that scans and writes an open-ended row set in one transaction is a bulk operation in every way that matters, and is inside this rule exactly as a published `bulk-write` is — being outside R-24's served-surface verb does not place it outside R-31. *(Source: Convex guidelines — the batched self-scheduling pattern for bulk operations; the per-transaction read AND write caps, R-30.)*
|
|
280
|
+
|
|
281
|
+
**The component seam is R-26, not a second rule.** What the standard demands of the `39` library-owned tables is stated once, in R-26 (§8): a Convex component the app cannot `.withIndex` into, so the app declares the authority tier and boundary key at the mount seam and trusts the component's isolation only as far as the component's own contract states it — absent that contract, `fleet-internal`/`master` only. This platform reading is what completed R-26 with the component mechanism; a first cut wrote it again here as its own rule (a former R-32), which was the one duplication this standard forbids, and it is folded into R-26 — the real-deployment-proof rule, previously the highest-numbered, takes the vacated id so the set stays contiguous.
|
|
282
|
+
|
|
283
|
+
**R-32 (process) — a limit-governed rule's change is proven on a real deployment, cited before the merge, by an independent read; a stated runtime risk is the trigger for that proof.** Any change to a rule governed by a runtime limit — R-3, R-4, R-11, R-23, R-30, R-31 — is proven on a **real deployment**, never only under `convex-test`, which permits what the runtime refuses. **The gate reads two things, at acceptance and before the merge is requested:** (1) a diff that touches a limit-governed rule's surface — an unbounded `.collect()`, a bare `.take(n)`, a scan or write loop over an open-ended set, a change to a declared bound — AND (2) a real-deployment proof cited **in the delivery**, run on a dataset at or above production volume. A surface-touching diff with no such proof cited does not pass. **The proof is an independent read of the artifact, never the producer's own returned count.** A migration's return value is derived from its own input and reports success against work it may never have done — the aborting backfill's deploy reported success against an empty table, and the migration returned a count derived from its input, not from what landed; the proof is a separate query that reads the affected rows back, not the number the producer handed up. **A stated runtime risk is the trigger, never the waiver.** A sentence naming an unbounded read or write as "harmless at the current volume" is precisely what OBLIGES this proof: a reviewer writing "harmless at N" and a merge authority reading it and merging is the exact path that shipped the P0. A named risk about a runtime-bounded resource requires the real-deployment proof — it never excuses its absence. *(Source: Convex testing guidance — `convex-test` does not enforce the runtime's transaction limits; the producer-return-value anti-pattern — an effect is proven by an independent read of the artifact, not the producer's returned count.)*
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## 12. The inventory schema is part of the contract
|
|
288
|
+
|
|
289
|
+
**R-14 (mechanical, column-presence).** The reference is the **§2 contract**, never a neighbouring inventory: an inventory conforms when it carries **every field the contract defines**, so two equally incomplete inventories cannot validate each other. Where the contract splits one concern into two — **reader authority** and **writer authority** are two distinct fields (§2) — an inventory holding a single merged authority column is **non-conforming by construction**, because the reader/writer divergence R-10 and R-7 exist to record cannot be written in it. The check runs **contract → inventory**, one direction only: a field the contract defines that the inventory omits or merges is a conformance failure of the inventory, not a silent gap.
|
|
290
|
+
|
|
291
|
+
**R-15 (process).** A defect class the two audits name differently is a class the standard failed to name; a new audit term is reconciled to the contract vocabulary before the audit is accepted. The four shared names of §3 are its standing product. **This revision extends the rule to its second source:** a subject raised from established practice is likewise reconciled — it ends as a numbered rule (§6–§8) or a written out-of-scope reason (§10), never as silence.
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## 13. Objective traceability — every rule to the objective it serves (no blank cell)
|
|
296
|
+
|
|
297
|
+
The five: **production-ready · enterprise-grade · highly secure · token-efficient · multi-organisation multi-tenant**.
|
|
298
|
+
|
|
299
|
+
| Rule | Objective(s) served |
|
|
300
|
+
|---|---|
|
|
301
|
+
| R-1 exposure declared | enterprise-grade · highly secure |
|
|
302
|
+
| R-2 operation verb, closed set, rpc refused | production-ready · enterprise-grade |
|
|
303
|
+
| R-3 pagination, tool-enforced bound | token-efficient · production-ready |
|
|
304
|
+
| R-4 retrieval mechanism, no unindexed scan | token-efficient · production-ready |
|
|
305
|
+
| R-5 reader authority, resolved from identity | highly secure · multi-organisation multi-tenant |
|
|
306
|
+
| R-6 write boundary from verified identity | highly secure · multi-organisation multi-tenant |
|
|
307
|
+
| R-7 read/write coherence (Axis 2) | highly secure · multi-organisation multi-tenant |
|
|
308
|
+
| R-8 zero-scope tool carries a justification | highly secure |
|
|
309
|
+
| R-9 per-tool token cost declared | token-efficient |
|
|
310
|
+
| R-10 writer authority, distinct resolved tier | highly secure · multi-organisation multi-tenant |
|
|
311
|
+
| R-11 isolation in-query, not post-query filter | highly secure · multi-organisation multi-tenant · token-efficient |
|
|
312
|
+
| R-12 vector search declares namespace + topK | highly secure · multi-organisation multi-tenant · token-efficient |
|
|
313
|
+
| R-13 purpose & disposition declared | enterprise-grade |
|
|
314
|
+
| R-14 inventory-schema parity | enterprise-grade · production-ready |
|
|
315
|
+
| R-15 vocabulary & subject reconciliation | production-ready · enterprise-grade |
|
|
316
|
+
| R-16 structured, fail-closed, end-to-end error contract | production-ready · enterprise-grade · highly secure |
|
|
317
|
+
| R-17 expand-contract, reversible migration | production-ready · enterprise-grade |
|
|
318
|
+
| R-18 retriable writes idempotent | production-ready · enterprise-grade |
|
|
319
|
+
| R-19 personal-data retention + erasure path | highly secure · enterprise-grade |
|
|
320
|
+
| R-20 background/scheduled work bounded & observable | production-ready · enterprise-grade |
|
|
321
|
+
| R-21 webhook signature-verified, idempotent, fail-closed | highly secure · production-ready |
|
|
322
|
+
| R-22 secrets never in source, response, or log | highly secure |
|
|
323
|
+
| R-23 runtime + rate bound declared, fails closed | token-efficient · production-ready |
|
|
324
|
+
| R-24 filter bulk-write defaults to dry run | highly secure · production-ready |
|
|
325
|
+
| R-25 minted address / cross-boundary bundle org-bound & expiring | highly secure · multi-organisation multi-tenant |
|
|
326
|
+
| R-26 library-owned table declares its seam boundary | highly secure · multi-organisation multi-tenant |
|
|
327
|
+
| R-27 removed tool names survivor + change | enterprise-grade |
|
|
328
|
+
| R-28 per-tool remediation field | enterprise-grade · production-ready |
|
|
329
|
+
| R-29 atomic write is one mutation, not an action | production-ready · highly secure · multi-organisation multi-tenant |
|
|
330
|
+
| R-30 declared bound is ours, platform limit is a backstop | token-efficient · production-ready |
|
|
331
|
+
| R-31 bulk write self-schedules within the transaction limit | production-ready · token-efficient |
|
|
332
|
+
| R-32 limit-governed rule proven on a real deployment | production-ready · enterprise-grade |
|
|
333
|
+
| R-33 outbound fetch validated as an SSRF surface | highly secure · multi-organisation multi-tenant |
|
|
334
|
+
| R-34 outbound credential is the backend's own, audience-validated | highly secure · multi-organisation multi-tenant |
|
|
335
|
+
| R-35 model-facing content is untrusted data | highly secure · enterprise-grade |
|
|
336
|
+
| R-36 dependencies pinned + provenance attested | enterprise-grade · production-ready |
|
|
337
|
+
| R-37 filled-neighbour divergence caught per tool (Axis 2) | highly secure · multi-organisation multi-tenant |
|
|
338
|
+
| R-38 every table declares all thirteen contract fields | production-ready · enterprise-grade |
|
|
339
|
+
| R-39 a rule's platform mechanism is named and resolves | production-ready |
|
|
340
|
+
| R-40 two backends fill a shared field alike (Axis 1) | enterprise-grade · multi-organisation multi-tenant |
|
|
341
|
+
| R-41 every frontier pointer resolves in the sibling | enterprise-grade |
|
|
342
|
+
| R-42 every rule sourced; no raised subject left silent | production-ready · enterprise-grade |
|
|
343
|
+
| R-43 an admin-provisionable resource exposes a delete/GC path or a written reason | enterprise-grade · highly secure |
|
|
344
|
+
| R-44 a derived structure ships with an idempotent backfill of existing rows | production-ready · enterprise-grade |
|
|
345
|
+
| R-45 a test reaches the pre-write state via a non-write-path fixture | enterprise-grade · production-ready |
|
|
346
|
+
| R-46 returns validator derived from the schema, never restated | production-ready · enterprise-grade · highly secure |
|
|
347
|
+
| R-47 an incompletable read refuses, never returns a partial set | production-ready · highly secure |
|
|
348
|
+
| R-48 authority total over its rows; a declared tier unused does not exist | highly secure · multi-organisation multi-tenant · enterprise-grade |
|
|
349
|
+
| 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 |
|
|
350
|
+
| 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 |
|
|
351
|
+
| R-52 a write reaching a row with no tenant value through a caller-choosable value checks the target's tenant first | highly secure · multi-organisation multi-tenant |
|
|
352
|
+
| R-53 actor and target identified by ID through the shared identity package; a name never selects or authorises | highly secure · multi-organisation multi-tenant · enterprise-grade |
|
|
353
|
+
|
|
354
|
+
**Deletion candidates — rules serving none of the five: none.**
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## 14. Traceability — every rule to its source
|
|
359
|
+
|
|
360
|
+
| Rule | Source | Evidence / citation |
|
|
361
|
+
|---|---|---|
|
|
362
|
+
| R-1 | audit | `surface`; MCP-public 123 / component 39 / hors-MCP 26, 0 blank (day158) |
|
|
363
|
+
| R-2 | audit — Finding 1 | `crud`: VP `RPC` 21 → 7 kinds; VR `UPSERT` 19 |
|
|
364
|
+
| R-3 | audit — Finding 5 | `search_mecanisme`: 14/14 `pas de curseur`; 11/14 unbounded max |
|
|
365
|
+
| R-4 | audit — Finding 4 | `search_components` `.collect()`+`.includes()`; 19 empty cells |
|
|
366
|
+
| R-5 | audit — Finding 6 | `rbac_qui` 8 values → 5 tiers; 32/52 writes on allow-list |
|
|
367
|
+
| R-6 | audit — Finding 6 | `namespace_ou_org` = `arg=createdBy`; 2/52 fail closed |
|
|
368
|
+
| R-7 | audit — Finding 6 | `rbac_coherence_table` `INCOHERENT` (VP 105, VR 66) |
|
|
369
|
+
| R-8 | audit — Finding 6 | `scope_enforcement` `AUCUN(public_no_scope_check)` (5) |
|
|
370
|
+
| R-9 | audit — Finding 3 | `tokens_estime_contexte` (ceiling is MCP's) |
|
|
371
|
+
| R-10 | audit — Finding 6 | `read=public write=admin` (66 VR rows) |
|
|
372
|
+
| R-11 | audit — Finding 6 | `filterByOrgScope(in-handler, post-query)` (40) |
|
|
373
|
+
| R-12 | audit — Finding 4 | vector searches — namespace + `topK` |
|
|
374
|
+
| R-13 | audit | `table_purpose` + `table_conserver_supprimer` |
|
|
375
|
+
| R-14 | audit | header: VP 18 cols incl. `statut_suppression`; VR 17 |
|
|
376
|
+
| R-15 | audit | structural, like R-14: `RPC`/`TRANSITION`/`UPSERT`; 8 `rbac_qui`; 19 empty retrieval cells |
|
|
377
|
+
| R-16 | practice + MCP frontier | OWASP ASVS v4 §7; the MCP standard's cited root |
|
|
378
|
+
| R-17 | practice | expand-contract / parallel-change migration pattern |
|
|
379
|
+
| R-18 | practice | idempotency-key for at-least-once delivery |
|
|
380
|
+
| R-19 | practice | GDPR Art. 5(1)(e), Art. 17 |
|
|
381
|
+
| R-20 | practice | bounded-retry / dead-letter job-queue practice |
|
|
382
|
+
| R-21 | practice | webhook signature verification + replay protection |
|
|
383
|
+
| R-22 | practice | OWASP ASVS v4 §6; 12-factor config |
|
|
384
|
+
| R-23 | practice | rate-limit/timeout; OWASP ASVS v4 §11 |
|
|
385
|
+
| R-24 | audit re-read | `bulk_complete_tasks` `dry-run défaut` |
|
|
386
|
+
| R-25 | audit re-read | `generate_upload_url`; `export_okf_bundle`; `import_okf_bundle` |
|
|
387
|
+
| R-26 | audit re-read + platform | `surface=component` (39 rows); Convex component model |
|
|
388
|
+
| R-27 | audit re-read | `statut_suppression` (14 survivor rows) |
|
|
389
|
+
| R-28 | audit re-read | `rbac_adjustment_needed` (46 remediation rows) |
|
|
390
|
+
| R-29 | platform | Convex: mutations are transactions; no `ctx.db` in an action |
|
|
391
|
+
| R-30 | platform | Convex type + per-transaction limits (1 MB doc, 8192 array, …) |
|
|
392
|
+
| R-31 | platform | Convex batched self-scheduling (`.take(n)` + `scheduler.runAfter`) |
|
|
393
|
+
| R-32 | platform | Convex `convex-test` does not enforce runtime limits |
|
|
394
|
+
| R-33 | practice — spec + OWASP | MCP `basic/security_best_practices` §SSRF; OWASP SSRF cheat sheet |
|
|
395
|
+
| R-34 | practice — spec | §Token Passthrough; `basic/authorization` audience + scope |
|
|
396
|
+
| R-35 | practice (ours) | OWASP LLM01; MCP `server/tools` §Security (generic) |
|
|
397
|
+
| R-36 | practice (ours) | npm provenance / SLSA build-provenance |
|
|
398
|
+
| R-37 | audit — Finding 6 / Axis 2 | 14 tables with divergent `scope_enforcement`; `memories` 6 mechanisms → 1 coherence label |
|
|
399
|
+
| R-38 | self — the conformance ruling (§2 contract) | the per-table contract as the instrument, gated as a completeness check |
|
|
400
|
+
| R-39 | self — the conformance ruling (§11 platform) | a named mechanism resolves to a current platform guarantee |
|
|
401
|
+
| R-40 | self — the conformance ruling (§1 Axis 1) | between-backend divergence on a shared field |
|
|
402
|
+
| R-41 | self — the conformance ruling (§9 frontier; §3 vocabulary) | the reconciled MCP frontier; sibling identifiers resolve |
|
|
403
|
+
| R-42 | self — the conformance ruling (§14 three sources) | every rule sourced, no subject left silent |
|
|
404
|
+
| R-43 | practice + audit re-read | resource-lifecycle / CRUD-symmetry practice; VP `oauth_scope_profiles` create/upsert with no delete (Day-165) |
|
|
405
|
+
| R-44 | practice + incident | backfill-on-introduction migration practice; PR #1200 `briefingNoteParticipants` junction populated only by the write path, inert for 100% of pre-deploy rows |
|
|
406
|
+
| R-45 | incident + practice | PR #1200 suite green over an inert fix — every fixture built through `create`; test-fixture-independence practice |
|
|
407
|
+
| R-46 | audit re-read + incident | `returnsValidatorSchemaCoverage.test.ts` @ `d2c32ea` (43 validators classified, 4 omissions); 2026-08-19 `get_task` P0, origin `e9a6a7e` |
|
|
408
|
+
| R-47 | audit re-read + platform | 2026-08-19 `tasks.list` `SCAN_CAP_EXCEEDED` refusal; Convex per-transaction read cap (§11, R-30) |
|
|
409
|
+
| R-48 | incident + the conformance ruling | the four 2026-08-19 RBAC refusals (author-attach, coordinator-route, two retire attempts on a `"system"`-created row); the gap R-5/R-10 leave uncounted |
|
|
410
|
+
| R-50 | incident + platform | 2026-08-30 vCRM-Preview operator report — `workspaces:ensureCurrent` + `prefs:sidebar` uncaught `ConvexError` on a subscribed layout; VP `convex/lib/auth.ts` `withOrgScope`, the fail-closed non-throwing resolver |
|
|
411
|
+
| R-51 | incident | 2026-08-30 vCRM operator report — four signed-in writes throwing `ConvexError` NO_ORG through `requireWriteAccess` + `getEffectiveWorkspaceId` |
|
|
412
|
+
| R-52 | incident | vantage-peers @ `45e40c8` `convex/tasks.ts` `complete` auto-link + IRP branch (global `issues`/`fixPatterns` written through `task.project`); PR #1459 RED `expected { …(17) } to deeply equal { …(13) }` |
|
|
413
|
+
| R-53 | incident + operator order | vantage-peers PR #1464 @ `ce75179` — ten `convex/tasks.ts` doors reached org-b's `eta` task by name, three with a `verifiedActor`; org-less `bulk_complete` fell into the name-only master path; operator order 2026-10-05 (identity by ID, resolved by `@vantageos/cloud-identity`) |
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
## 15. Build column — what backend-doctor checks, per rule
|
|
418
|
+
|
|
419
|
+
**mechanical** — a detector derives the rule's red population deterministically from source or the oracle, and decides each flagged site by the presence of a written justification token where the rule allows one (R-7, R-8); the deploy gate refuses on these. **judgement** — deciding whether a site is IN the red population needs a reading of meaning no parser performs (which data is personal, which write can be retried, which artifact crosses an organisation); the reason is stated per rule in §23. Judgement is retired as an *acceptance control* (2026-09-02): such a rule's disposition still reads a written token, never a person's verdict, and each is a named, owned gap in §23 until a detector derives its population — it is not a deploy-gate refusal meanwhile. **process** — an obligation on how the standard is maintained. **The table is the authority for a rule's build kind**; a rule's own `*kind:*` token and parenthetical marker agree with it, and the summary line under the table is derived from it by the command in §16. Each rule below carries both classifications: its **build kind** — *how* it checks — and its **control kind** — *where* the check runs: **static** (a read), **dynamic** (a live call to a running service), or **delivery gate** (a check at acceptance), as §18 defines. The two are orthogonal, and both are stated per rule in the table. The **check** column states, per rule, what the control reads and the criterion that passes it — its negation is what refuses — so each row carries all four: the read, the pass/refuse criterion, the build kind, and the control kind. A rule observable by no kind would say so, naming each of the three considered and why it fails — there is none.
|
|
420
|
+
|
|
421
|
+
| Rule | Build kind | Control kind | The check |
|
|
422
|
+
|---|---|---|---|
|
|
423
|
+
| R-1 | mechanical | static | `surface` non-empty, one of three |
|
|
424
|
+
| R-2 | mechanical | static | verb one of the closed set; no `rpc`; a multi-table write declares **every** table it writes, cross-checked against its write call-sites (red: a declared list that is a strict subset of the tables actually written) |
|
|
425
|
+
| R-3 | mechanical | static | cursor declared; `default`/`max` numeric and tool-set; truncated≠exhausted |
|
|
426
|
+
| R-4 | mechanical | static | mechanism one of the set, every list included; no `.collect()`+filter without `.withIndex()`; reads only the fields used (red: `.collect()` of full documents where the consuming code uses a strict subset of fields) |
|
|
427
|
+
| R-5 | mechanical | static | reader tier non-empty, one of five |
|
|
428
|
+
| R-6 | mechanical | static + dynamic | grep `arg=<caller-field>` on a write; identity-binding enforced; a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
429
|
+
| R-7 | mechanical | static | `INCOHERENT*` flag with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → non-conforming; `COHERENT*`, or `INCOHERENT*` carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
430
|
+
| R-8 | mechanical | static + dynamic | empty/`AUCUN` scope on a data op with no written `STATELESS:`/`JUSTIFIÉ:` token → non-conforming; a genuine scope check, or a no-scope row carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
431
|
+
| R-9 | mechanical | static | `tokens_estime_contexte` is a number — a measured or method-stated estimate — not merely non-empty (red: a placeholder such as `TBD`/`?`/`~`) |
|
|
432
|
+
| R-10 | mechanical | static | writer tier present, distinct, one of five |
|
|
433
|
+
| R-11 | mechanical | static + dynamic | in-handler `.filter(...Scope...)` with no `.withIndex` on the key; unavoidable case justified; a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
434
|
+
| R-12 | mechanical | static | `indexed-vector` declares a real namespace and a numeric `topK` (red: `topK` non-numeric, or namespace ∈ {`n/a`, `TBD`, blank}) |
|
|
435
|
+
| R-13 | mechanical | static | `table_purpose` non-empty; disposition ∈ {stays, goes, undecided}; `undecided` carries an owner + a deadline (red: empty, out-of-set, or a bare `undecided`) |
|
|
436
|
+
| R-14 | mechanical | static | against the §2 contract, not a sibling inventory: every contract field present, reader authority and writer authority as two separate columns (red: any contract field missing, or a merged reader/writer column) |
|
|
437
|
+
| R-15 | process | delivery gate | a new term/subject reconciled before acceptance |
|
|
438
|
+
| R-16 | judgement | static + dynamic | error shape `{code,field/scope,message}` present + fail-closed default; remedy followable; a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
439
|
+
| R-17 | judgement | static | migration is multi-step additive; reversibility sound; a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
440
|
+
| R-18 | judgement | static | retriable write has an idempotency/unique key; key correct; a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
441
|
+
| R-19 | judgement | static | retention is a stated duration and the delete path names an executable mechanism, neither a placeholder; cascade complete; a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
442
|
+
| R-20 | judgement | static | bound and retry-cap are numeric and a log is named, none a placeholder; idempotency sound; a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
443
|
+
| R-21 | mechanical | static | webhook path verifies signature + keys on delivery id |
|
|
444
|
+
| R-22 | mechanical | static | no secret literal in source/response/log; redaction on echo paths |
|
|
445
|
+
| R-23 | mechanical | static | rate and runtime bound are numeric (requests/interval, ms), or an explicit written exception is on file (red: a non-numeric placeholder with no written exception) |
|
|
446
|
+
| R-24 | mechanical | static | `bulk-write` defaults dry-run; apply behind a confirm flag |
|
|
447
|
+
| R-25 | judgement | static | org-binding is a real org id and expiry a real duration/timestamp, neither a placeholder; both-ends check present; a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
448
|
+
| R-26 | judgement | static | `component` table declares seam authority + key; library trust justified; a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
449
|
+
| R-27 | mechanical | static | the named survivor resolves to a tool in the current inventory and the change ref to a real PR/commit, not merely non-empty strings (red: survivor absent from the inventory, or an unresolvable ref) |
|
|
450
|
+
| R-28 | mechanical | static | a non-conforming tool names a concrete remediation action (a `CORRIGER`/`REVOIR`-form fix), not a bare placeholder like `n/a` (red: non-conforming tool with an empty or placeholder remediation) |
|
|
451
|
+
| R-29 | mechanical | static | atomic invariant in one mutation, no action orchestrating writes; atomicity truly needed; a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
452
|
+
| R-30 | mechanical | static | declared `max` present and below the platform limit; no bare `.take(n)`/`?? n` as the only bound |
|
|
453
|
+
| R-31 | mechanical | static | a bulk operation over an unbounded set — a `bulk-write` OR a hand-invoked one-shot migration — batches BOTH its reads and its writes with `.take(n)`/`.paginate` + `scheduler.runAfter(0, self)` (red: an operation that bounds its writes but `.collect()`s an unbounded read, or an unbounded read that writes nothing) |
|
|
454
|
+
| R-32 | process | delivery gate | a diff touching a limit-governed rule's surface carries a real-deployment proof cited in the delivery on production-scale data, verified by an independent read-back of the affected rows (not the producer's returned count); a stated "harmless at N" runtime risk triggers the proof (red: surface touched with no cited proof, or proof resting only on the producer's own count, or a stated volume risk with no proof) |
|
|
455
|
+
| R-33 | mechanical | static | call-site inventory + caller-influenced classification; validator soundness; a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
456
|
+
| R-34 | mechanical | static | forwarded caller-token / missing audience-assert grep; scope-minimality; a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
457
|
+
| R-35 | mechanical | static | third-party content spliced into an instruction position (mechanical grep); trust boundary correct; a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
458
|
+
| R-36 | mechanical | static | lockfile pins every dependency; publish carries a provenance attestation |
|
|
459
|
+
| R-37 | mechanical | static | per-table pairwise filled-cell compare on shared fields flags a divergent neighbour; a divergence with no written per-tool justification token → non-conforming; a divergence carrying the token, or no divergence, → present; could-not-judge only on an unparseable row (§23) |
|
|
460
|
+
| R-38 | mechanical | static | all thirteen §2 fields present per table, each within its vocabulary (static) |
|
|
461
|
+
| R-39 | judgement | static + delivery gate | the platform mechanism a rule depends on is named; it resolves to a current guarantee (delivery gate); a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
462
|
+
| R-40 | mechanical | static | two backends' shared field compared; a between-backend divergence (value or column) with no written justification → non-conforming; agreement on every shared field and column → present; could-not-judge only on an unparseable oracle (§23) |
|
|
463
|
+
| R-41 | mechanical | static | every cited `S-`/`C-` frontier identifier resolves in the sibling document (static, cross-document) |
|
|
464
|
+
| R-42 | process | static + delivery gate | each rule's §14 source cell names a source from the known set, not merely non-empty (static); no raised subject left un-routed (delivery gate) |
|
|
465
|
+
| R-43 | judgement | static | admin create/upsert inventory + "delete/GC path exists?" classification; a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
466
|
+
| R-44 | judgement | static | a diff adds a derived structure (junction/side table/denormalised field/counter/search index) with a same-delivery migration that backfills existing rows (mechanical: structure-add ∧ backfill-present ∧ idempotency guard); the backfill reaches every pre-write row and is genuinely idempotent; a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
467
|
+
| R-45 | judgement | static | at least one test builds the source row via a direct `ctx.db.insert` (not `create`) and asserts before ∧ after the migration (mechanical: a non-write-path fixture exists); that it truly reaches the failing pre-migration state; a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
468
|
+
| R-46 | mechanical | static | the returns validator's field set, derived from `schema.ts`, equals the schema's writable field set, or a shortfall carries a written projection reason (red: a schema field writable by some path, absent from the returns validator, with no projection reason; or a validator authored as a literal list rather than derived) |
|
|
469
|
+
| R-47 | judgement | static + dynamic | a scan truncatable before its filter returns its partial set rather than refusing (mechanical: a non-index-bounded read with a wide range and no refusal path); a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
470
|
+
| R-48 | judgement | static + delivery gate | per governed table, the union of accepting tiers reaches every row including a non-actor-created one, and every declared tier is used by some mutation (mechanical: derive each mutation's accepting tiers + the tier-usage census; red: a table no tier reaches for a `"system"`/webhook/migration/scheduler row, or a declared tier used nowhere); a flagged row with no written `JUSTIFIÉ:`/`JUSTIFIED:` token in its remediation cell → **non-conforming**; carrying the token → present; could-not-judge only on an unparseable row (§23) |
|
|
471
|
+
| R-50 | mechanical | static | a public `query` (or a gated `customQuery` wrapper's `customCtx`) whose no-org path throws, with no fail-closed resolver and no isolation-contract marker → red; a typed empty/unauthorised result, the resolver, or the marker → green; could-not-judge only on an unresolvable wrapper base or an undelimitable block |
|
|
472
|
+
| R-51 | mechanical | static | a public `mutation` (or a gated `customMutation` wrapper's `customCtx`) whose no-org path throws NO_ORG with no typed-unauthorised return and no `write-contract:` marker → red; a typed unauthorised return → green; a marker-borne unreachability claim the detector cannot verify → could-not-judge, never a silent green |
|
|
473
|
+
| R-52 | mechanical | static | a write (patch/replace/delete, an attributed insert, a scheduled external effect) whose target carries no tenant value, selected by an argument-derived value or a caller-chosen stored field, with no guard reading a server-stamped tenant that refuses or skips it → red; such a guard dominates the write, or the selector is not caller-choosable, or the target has a required tenant column → green; could-not-judge only on an unresolvable selector, an undelimitable guard, or an unreadable schema; no justification-token exit |
|
|
474
|
+
| R-53 | mechanical | static | a principal ID/org not taken from `@vantageos/cloud-identity`'s resolver, a write admitted by comparing a name field with a typed or credential-carried name (or with no target predicate), an unresolved principal/org passed through as master, service account or name match, or a local function deciding identity → red; principal from the package, admission compares stored IDs, unresolved branch refuses → green; could-not-judge only on an undelimitable predicate or an unresolvable import; no justification-token exit |
|
|
475
|
+
|
|
476
|
+
**Mechanical: R-1,R-2,R-3,R-4,R-5,R-6,R-7,R-8,R-9,R-10,R-11,R-12,R-13,R-14,R-21,R-22,R-23,R-24,R-27,R-28,R-29,R-30,R-31,R-33,R-34,R-35,R-36,R-37,R-38,R-40,R-41,R-46,R-50,R-51,R-52,R-53 = 36. Judgement: R-16,R-17,R-18,R-19,R-20,R-25,R-26,R-39,R-43,R-44,R-45,R-47,R-48 = 13. Process: R-15,R-32,R-42 = 3.** Total 36 + 13 + 3 = 52. Derived from the table above by the command in §16 — never typed. (Task k179jnhx moved R-7, R-8, R-37, R-40 from judgement to mechanical. #144 marked every row mechanical while this line still listed nineteen as judgement; ElPi-Corp #225 settled each of the nineteen on its own text: R-6, R-11, R-29, R-33, R-34, R-35 are mechanical, the other thirteen judgement, with the reason per rule in §23. R-50 and R-51 were defined in §22 without being entered here; they are added with R-52. R-53 is added mechanical.)
|
|
477
|
+
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
## 16. How the rule count is derived, and why it is fifty-two
|
|
481
|
+
|
|
482
|
+
The count is read from the material, and the material is now **three sources** — the two audits, established practice, and the platform. The count rises with the material — a new finding, a new column, a new source adds a rule — and it is derived here by command, never typed.
|
|
483
|
+
|
|
484
|
+
**From source one, the audit — twenty rules.** The first cut derived fifteen: eleven behavioural contract fields, the two structural rules (R-14 parity, R-15 reconciliation), plus the finding-driven pagination, vector, and authority-pair rules. Re-reading the rows adds five (R-24 dry-run bulk, R-25 minted/bundle artifacts, R-26 library-owned seam, R-27 deprecation lifecycle, R-28 remediation field) — each a fact already in the audit the first cut passed over.
|
|
485
|
+
|
|
486
|
+
**From source two, established practice — eight rules.** The error contract the rebuild dropped and the MCP standard cites (R-16), and seven subjects the two products never exercised: migration (R-17), idempotency (R-18), personal-data lifecycle (R-19), background work (R-20), webhooks (R-21), secrets (R-22), rate/runtime bounds (R-23). Each cites an external source (§14), not an audit column, because the audit could not have produced it.
|
|
487
|
+
|
|
488
|
+
**From source three, the platform — four rules.** Reading the platform's guidance against the standard, ten existing rules gain a **refinement** (§11) that names the mechanism they depend on and adds no count, and the component seam **completed an existing rule** (R-26) rather than adding one — a first cut had written it twice, and the duplicate is folded out this pass. Four obligations are genuinely new and platform-shaped: atomicity as one mutation (R-29), the declared-bound-versus-platform-limit distinction that settles which bound is whose (R-30), batched self-scheduling for a bulk write beyond the transaction limit (R-31), and real-deployment proof for a limit-governed rule (R-32, renumbered down into the id the folded rule vacated). Each cites the platform's guidance rather than pastes it.
|
|
489
|
+
|
|
490
|
+
That is **thirty-two**. One rule was retired this pass: the platform-layer component-seam rule (a former R-32) stated the same obligation as R-26 and is folded into it, and the real-deployment-proof rule (previously the highest id) takes the vacated slot, so the set stayed contiguous R-1..R-32 at that point. Three subjects a reader raises end not as a rule but as a written exclusion (§10) — one of which, backup and durability, this revision closes by naming the platform guarantee it relies on. The count is a function of the material, the practice the objectives require, and the platform the fleet runs on; it rose from fifteen to twenty-eight to thirty-three as each source was added, and settled at thirty-two once a single obligation duplicated across two sources was folded into one rule — a standard that did the thing it forbids is not complete.
|
|
491
|
+
|
|
492
|
+
**The security page — four rules.** The specification's whole security page and the nineteen-subject practice sweep were routed (§9b) with no unrouted row: four subjects with a data-layer half became rules — outbound-fetch/SSRF (R-33), the outbound credential and no-token-passthrough (R-34), model-facing content as untrusted data (R-35), and dependency provenance (R-36) — three folded into rules already written (R-22, R-5/R-6, R-16), four were named out-of-scope with a reason (§10), and the remainder are the served surface's, merged, with the frontier named. That took the count from thirty-two to **thirty-six**. The count is still a function of the material: a whole page read whole, every row routed, the rules the data-layer half produced added and counted by command.
|
|
493
|
+
|
|
494
|
+
**The reverse test — one rule.** Proving the standard against the audit it was derived from (§17), row by row from the CSV side, found that every row-level defect the audit records — the `RPC` verbs, the missing cursors, the `AUCUN` scope checks, the post-query filters, the arg-derived boundaries — was already caught, but the audit's **largest** finding was not: two filled-in neighbours on one table diverging in their tenancy or retrieval mechanism. The per-table contract as merged surfaced a *blank* field, not a *filled-neighbour divergence* — the §2 obligation was asserted in prose and enforced by no check. R-37 closes it, and it is the only rule the reverse test added; it took the count to **thirty-seven**. That the exam run from the audit's side found exactly one gap, and that it was the dominant one, is why the reverse direction is not a formality.
|
|
495
|
+
|
|
496
|
+
**The conformance ruling — five rules.** The operator's ruling that binds the MCP standard binds this one: nothing is prescribed without a control, and a control may be a static read, a dynamic call to a running service, or a check at the delivery gate — all three count (§18). Five instruments the document asserted and gated nowhere become rules: the per-table contract as a completeness gate (R-38), the platform mechanism named and resolving (R-39), Axis-1 between-backend divergence (R-40), the frontier pointer that resolves in its sibling (R-41), and the three-source discipline (R-42). No new obligation is invented — each was already prescribed in prose; what was missing was the control, now named. That took the count from thirty-seven to **forty-two**. A standard that requires what no control can see is the defect this pass closes at the level of the standard itself. A later revision adds one rule from the resource-lifecycle practice source — **R-43**, admin-resource lifecycle symmetry (a provisionable resource carries its own delete/GC path, or a written out-of-scope reason), surfaced by a Day-165 friction where `oauth_scope_profiles` was create/upsert-provisioned with no delete beside it — taking the count to **forty-three**. A further revision adds two rules from the derived-structure source, surfaced by the PR #1200 briefing-note-participants P0: a junction table populated only by the write path was inert for 100% of pre-deploy rows, and the suite stayed green because every fixture was built through `create`. Two distinct classes become **R-44** (a derived structure ships with an idempotent backfill of its existing rows, deferring the backfill's batching to R-31 and its real-deployment proof to R-32) and **R-45** (at least one test constructs the source row without the write path and asserts before and after the migration), taking the count to **forty-five**. A later **read-budget-closure** pass — surfaced by the participants-backfill P0 that aborted on `Too many bytes read in a single function execution` at a `.collect()`+write loop that violated R-31 and R-32 yet passed review — added **no new rule** and instead refined three existing ones: **R-4** now reads only the fields the operation uses (the field axis of over-read), **R-31** now binds BOTH the read budget and the write budget and widens its scope to a hand-invoked one-shot migration (not only a published `bulk-write`), and **R-32** now names its gate (a surface-touching diff must cite a real-deployment proof verified by an independent read-back, never the producer's own returned count, and a stated "harmless at N" risk is the trigger for that proof). Each is a refinement of an existing rule — the read/write distinction stated where R-31 already owns the transaction budget — so the count holds at **forty-five**.
|
|
497
|
+
|
|
498
|
+
**The read-shape source — two rules, one on each face of the incompletable result (§21).** Two 2026-08-19 production incidents, one per face of R-16's *a return value never means both 'could not continue' and 'nothing left'*: `get_task` threw a `ReturnsValidationError` on a row carrying a schema field its returns validator did not declare — the field written four days earlier, unread on every blocked row until a caller fetched one; and `tasks.list` refused a scan its read cap would truncate before the filter ran, naming the arguments that would narrow it. Each becomes a rule because the correct behaviour was a crash on one path and luck on the other, and neither was written: **R-46** (a returns validator is derived from the schema, never a hand-written restatement — the field-completeness twin of R-4's over-read, distinct from R-38's inventory contract and R-7's tenant coherence) and **R-47** (an incompletable read refuses rather than return a set indistinguishable from a complete one — a new rule between R-3's detectable-truncation signal and R-30's declared bound, neither of which can make an unmeetable scan honest). That took the count from forty-five to **forty-seven**. The derived check `returnsValidatorSchemaCoverage.test.ts` is R-46's instrument, already classifying every document validator against the schema at runtime; the count still rose only by the rules the material produced, one mechanical and one judgement.
|
|
499
|
+
|
|
500
|
+
**The authority-totality source — one rule (§22).** Four 2026-08-19 RBAC refusals, each with its command, exposed a gap R-5 and R-10 leave: they make each mutation ask *may THIS caller write this row* caller-by-caller, but say nothing about whether, for a given row, ANY tier can answer yes — a model correct on every caller that still leaves rows nobody governs, failing silently because each refusal is correct. A `"system"`-created review row that neither its assignee, nor the coordinator, nor a PR author could act on is the case, and three sat live in the queue. **R-48** closes it with two clauses under one number — an authority model is total over the rows it governs (every table has a tier reaching every row, including non-actor-created ones), and a tier declared in R-5's vocabulary but wired into zero mutations does not exist (ours is `org-admin`/`fleet-internal` today). Its control is mechanical (the tier-derivation and the usage census are derived; an unreachable-row table or an unused tier publishes only when it carries a written `JUSTIFIÉ:`/`JUSTIFIED:` token, and a flagged row without one is non-conforming — the reachability disposition is no longer a human verdict), distinct from R-5/R-10 (per-mutation) and R-11 (predicate locus). That took the count from forty-seven to **forty-eight**. The count still rose only by the material — one incident, one rule the caller-by-caller rules could not have produced.
|
|
501
|
+
|
|
502
|
+
**The subscribed-read and pre-org-write source — two rules (§22).** The 2026-08-30 vCRM-Preview incident crashed a subscribed app shell twice over: its reads threw an uncaught `ConvexError` on the no-org path, and once those were typed-empty, every write the shell issued threw NO_ORG. **R-50** (a reactively-subscribed read refuses with a typed value, never a throw) and **R-51** (a public write a pre-org shell can reach refuses with a typed value, or records its unreachability) took the count from forty-eight to **fifty**. The number **R-49** is held by task `k177kewn589jpcp98f3njfgae58cwk1b` (§23) and is not defined in this document, so it is not counted.
|
|
503
|
+
|
|
504
|
+
**The cross-tenant-write source — one rule (§4), and one refinement.** At vantage-peers @ `45e40c8`, `tasks.complete` wrote the global `issues` and `fixPatterns` tables, and a GitHub comment, through a lookup keyed on `task.project` — a field the caller chose on its own row — and an org-a member marked org-b's issue fixed (PR #1459, RED). R-6 could not name it: the target had no boundary, and the selector was not an argument. **R-52** closes the class, mechanically, taking the count from fifty to **fifty-one**. The same evidence refined **R-6** without adding a rule: three sites it flagged (`missions.ts:232`, `recurringTasks.ts:216`, `tasks.ts:2453`) take their scope from an identity resolver that honours an argument only from a verified principal, and PR #1458 proved them unsteerable; R-6 now states that such a resolver's verified output is conformant, and that a field derived from the target or the resolved caller is not an argument boundary, while its refusal of argument-derived boundaries, renames included, is unchanged.
|
|
505
|
+
|
|
506
|
+
**The identity-by-name source — one rule (§4).** On vantage-peers PR #1464's branch (@ `ce75179`), an org-a agent named `eta` reached org-b's `eta` task at ten `convex/tasks.ts` doors by name, three of them even with a `verifiedActor`, and an org-less bearer fell into the name-only master path at `bulk_complete`. No rule named it: R-6 credited the resolver without asking how it resolved, R-52 governs targets that carry no tenant value, and the target here carried one that was never compared. **R-53** (identity by ID through the shared identity package; a name never selects or authorises; refusal by default; no local identity layer) took the count from fifty-one to **fifty-two**. backend-doctor's CR-1 still proposes R-52 as its upstream number (`src/caller-resolution.ts:58` on its `main`); R-52 is held by the cross-tenant-write rule, so CR-1 takes the next free number when it is upstreamed.
|
|
507
|
+
|
|
508
|
+
Derivation, by command against this document:
|
|
509
|
+
|
|
510
|
+
```
|
|
511
|
+
grep -cE '^\*\*R-[0-9]+ ' backend-standard.md # rule definitions -> 52
|
|
512
|
+
grep -oE 'R-[0-9]+' backend-standard.md | sort -uV | tail -1 # highest id -> R-53
|
|
513
|
+
grep -oE 'R-[0-9]+' backend-standard.md | sort -uV | wc -l # distinct ids -> 53 (52 rules + R-49, named in §23, not defined)
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
The §15 summary line is derived from the §15 table, by build kind, by this command — the table is the authority, and a summary that disagrees with the command's output is the defect, not the table:
|
|
517
|
+
|
|
518
|
+
```
|
|
519
|
+
awk '/^## 15\./{f=1} /^## 16\./{f=0} f && /^\| R-[0-9]+ \| (mechanical|judgement|process) \|/{split($0,c," *[|] *"); n[c[3]]++; l[c[3]]=l[c[3]] (l[c[3]]?",":"") c[2]} END{for(k in n) print k, n[k], l[k]}' backend-standard.md | sort
|
|
520
|
+
-> judgement 13 R-16,R-17,R-18,R-19,R-20,R-25,R-26,R-39,R-43,R-44,R-45,R-47,R-48
|
|
521
|
+
-> mechanical 36 R-1,R-2,R-3,R-4,R-5,R-6,R-7,R-8,R-9,R-10,R-11,R-12,R-13,R-14,R-21,R-22,R-23,R-24,R-27,R-28,R-29,R-30,R-31,R-33,R-34,R-35,R-36,R-37,R-38,R-40,R-41,R-46,R-50,R-51,R-52,R-53
|
|
522
|
+
-> process 3 R-15,R-32,R-42
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
## 17. Proven against the source audit — every defect names the rule that would have stopped it
|
|
526
|
+
|
|
527
|
+
The standard was written **from** the audit. This section asks the reverse: had it existed **before** VantagePeers was built, would it have prevented every divergence, hole and gap the audit records? The test is applied per defect class, and **a rule merely existing is not coverage** — all three must hold or the defect is uncovered:
|
|
528
|
+
|
|
529
|
+
1. **a rule whose text forbids it** — named;
|
|
530
|
+
2. **its §15 check would have *seen* this row** — cited, with what the check reads on the row; a rule whose check reads a field the row leaves blank does not catch a row wrong in a way that field cannot express;
|
|
531
|
+
3. **it fires at publication**, not in a review checklist.
|
|
532
|
+
|
|
533
|
+
The material is the audit on `main`: `vantagepeers/vp-restructuring/vp-by-tool-day158.csv` — **188 tool rows** (the `TOTAL` row excluded), 18 columns. Every count below is derived from it by command (§17.6); a zero is an event, never a rest.
|
|
534
|
+
|
|
535
|
+
### 17.1 Defect inventory — every defect-bearing column, by command
|
|
536
|
+
|
|
537
|
+
| Defect (as the CSV expresses it) | Rows | Positive control (the pattern can match) |
|
|
538
|
+
|---|---|---|
|
|
539
|
+
| `crud == RPC` | 21 | `crud` has 9 values incl. `n/a` (65); `RPC` is present and countable |
|
|
540
|
+
| `search_mecanisme` carries `pas de curseur` | 14 | 14 of 14 non-blank mechanism strings match |
|
|
541
|
+
| `search_mecanisme` carries `non-borné` max | 11 | subset of the 14 |
|
|
542
|
+
| `scope_enforcement == AUCUN(public_no_scope_check)` | 5 | named rows incl. `mandates/check_mandate_spending`, `n/a/whoami` |
|
|
543
|
+
| `scope_enforcement == filterByOrgScope(in-handler, post-query)` | 40 | named rows incl. `tasks/list_tasks`, `memories/get_memory` |
|
|
544
|
+
| `namespace_ou_org` carries `arg=` (authority from a caller argument) | 46 | `arg=callerOrchestrator` 15, `arg=createdBy` 11, `arg=namespace` 10, + 10 more |
|
|
545
|
+
| `rbac_coherence_table` starts `INCOHERENT` | 105 | 7 `INCOHERENT …` variants; 12 distinct values incl. `n/a` (65), `COHERENT(*)` (18) |
|
|
546
|
+
| `rbac_adjustment_needed` starts `CORRIGER`/`REVOIR` | 55 | 46 `CORRIGER` + 9 `REVOIR` |
|
|
547
|
+
| `statut_suppression` non-blank | 14 | all read `SUPPRIMÉ @ PR#1169 303cd48 … (survivor: …)` |
|
|
548
|
+
| `doublon_alias` non-blank | 16 | 8 `GARDER` / 8 `SUPPRIMER` verdicts |
|
|
549
|
+
| `surface == component` (library-owned) | 39 | `T2_verdict` COMPONENT-OWNED 39; surface splits 123/39/26 |
|
|
550
|
+
| **two filled neighbours on one table carry different tenancy/retrieval values** | **14 tables** | see §17.4 — the audit's dominant defect, Axis 2 |
|
|
551
|
+
|
|
552
|
+
Descriptive columns (`table`, `outil`, `T2_composant_cible`, `operation_detail`, `table_purpose`, `table_conserver_supprimer`) carry no independent defect pattern; they are the evidence other rules read. No defect-bearing column is left unenumerated.
|
|
553
|
+
|
|
554
|
+
### 17.2 Coverage — one row per defect class
|
|
555
|
+
|
|
556
|
+
| Defect | Rows | Rule | §15 check reads | Verdict |
|
|
557
|
+
|---|---|---|---|---|
|
|
558
|
+
| `RPC` verb | 21 | R-2 | `crud` ∉ closed set | **CAUGHT** |
|
|
559
|
+
| no cursor | 14 | R-3 | cursor absent | **CAUGHT** |
|
|
560
|
+
| unbounded max | 11 | R-3 | `max` non-numeric | **CAUGHT** |
|
|
561
|
+
| blank/unindexed retrieval on a list | (join) | R-4 | mechanism ∉ set, on a `READ-LIST`/`SEARCH` row | **CAUGHT** |
|
|
562
|
+
| zero scope check | 5 | R-8 | `AUCUN` + a data op | **CAUGHT** |
|
|
563
|
+
| post-query isolation filter | 40 | R-11 | `filterByOrgScope(...post-query)` with no `.withIndex` | **CAUGHT** |
|
|
564
|
+
| authority from a caller arg | 46 | R-6 | `arg=<field>` on a write | **CAUGHT** |
|
|
565
|
+
| read/write incoherence (table aggregate) | 105 | R-7 | `INCOHERENT*` flag | **CAUGHT (aggregate)** |
|
|
566
|
+
| remediation owed | 55 | R-28 | field non-empty on a non-conforming tool | **CAUGHT (evidentiary)** |
|
|
567
|
+
| deprecation trace | 14 | R-27 | survivor + change ref present | **CAUGHT** |
|
|
568
|
+
| library-owned seam | 39 | R-26 | seam authority + key declared | **CAUGHT** |
|
|
569
|
+
| exposure blank | 0 | R-1 | `surface` empty | **CAUGHT** (0 rows on day158 — R-1's cited count was stale; corrected, §17.5) |
|
|
570
|
+
| token cost | 0 blank | R-9 | field empty | **CAUGHT (vacuous — no negative row to test)** |
|
|
571
|
+
| duplication (`doublon_alias`) | 16 | — | frontier | **OUT OF SCOPE — the MCP standard's (§5)** |
|
|
572
|
+
| **filled-neighbour divergence (Axis 2)** | **14 tables** | R-7 only, partially | `INCOHERENT*` table-union flag | **NOT CAUGHT as a class — §17.4, closed by R-37** |
|
|
573
|
+
|
|
574
|
+
### 17.3 `doublon_alias` — the written reason it is not a rule here
|
|
575
|
+
|
|
576
|
+
Confirmed on all 16 rows: `memories/recall` (GARDER, usage 310) and `memories/search_memories_by_semantic` (SUPPRIMER, usage 0) perform the same operation on the same data — a **served-surface duplication**, not a data-layer defect. The pair shares `crud` (`SEARCH`) and `scope_enforcement`; nothing in the tenancy, verb, or authority fields diverges. §5's frontier — *duplication is the MCP standard's alone* — is the correct home. Checked all 16: none carries a data-layer divergence from its sibling. Out of scope here, by decision, not oversight.
|
|
577
|
+
|
|
578
|
+
### 17.4 The Axis-2 answer — on named rows
|
|
579
|
+
|
|
580
|
+
Pi's decisive question: does the per-table contract surface a divergence between **two filled-in neighbours**, or only a **blank field**? A contract that catches omission but not filled-neighbour divergence would miss the audit's largest finding. Proven on named rows:
|
|
581
|
+
|
|
582
|
+
- **`memories` — 18 rows carry SIX distinct `scope_enforcement` mechanisms** (`checkNamespaceRead` 10, `filterByOrgScope`-post-query 2, `checkNamespaceWrite` 1, `resolveOrgContext` 2, `isMasterScope` 1, `checkFromAllowed` 2) **and ONE identical `rbac_coherence_table` label** (`INCOHERENT read=ns+org write=identite+master+ns+org-derive`) on every row. R-11 catches the 2 post-query rows on their own absolute merit. But `checkNamespaceRead` versus `resolveOrgContext` — two *individually valid* read mechanisms on one table — are collapsed into the single `read=ns+org` term; **no field, no rule, distinguishes them.**
|
|
583
|
+
- **`missions` — 3 mechanisms → 1 label** (`INCOHERENT read=org write=identite+master`): `update_mission` (identity-gated) beside `update_mission_status` (fleet-master). Both values are valid; R-8 and R-11 do not fire; the divergence lives only in the compound `write=identite+master` union — which names *that* the table diverges, never *which two tools*.
|
|
584
|
+
- **`mandates` — 3 mechanisms → 1 label**: here the `AUCUN` rows are caught independently by R-8, so this pair is doubly covered — a coincidence of an illegal value, not the contract surfacing the divergence.
|
|
585
|
+
|
|
586
|
+
**The answer, proven:** the per-table contract as merged surfaces a **blank field** (omission) — `backend-doctor reads each field per table and refuses a table that omits one` (§2). It does **not** surface a **filled-neighbour divergence** as a class: R-7's `INCOHERENT*` label is a lossy table-level union that flags that a table's writes differ, never which tools, and on the read side compresses distinct valid mechanisms into one term. The §2 prose *asserts* the obligation — *"a table that fills a field differently from its neighbour is a divergence to be justified or removed"* — but no check delivered it. This is the audit's dominant defect, and as-shipped it was **NOT CAUGHT**.
|
|
587
|
+
|
|
588
|
+
### 17.5 The one gap, closed — R-37; and a stale citation corrected
|
|
589
|
+
|
|
590
|
+
**R-37 (mechanical, both halves).** For each table, `backend-doctor` groups its tool rows and, for every shared contract field (§2: exposure, verb effect-class, reader/writer authority, retrieval mechanism, isolation locus, boundary key), flags any two tools whose **filled** values differ. A flagged filled-neighbour divergence publishes only with a per-tool written justification recorded in the contract; an unjustified divergence between two filled neighbours does not ship. This is the enforcement §2 asserts and no check delivered: R-7 stays the read/write tenant-model slice as a table aggregate, and **R-37 generalises the neighbour comparison to every shared field and names *which two tools* diverge** — the attribution R-7's union label cannot carry, and the read-versus-read divergence (`memories`) it cannot see at all. *(Source: audit Finding 6 / Axis 2 — the dominant defect; proven on `memories` 6→1, `missions` 3→1, `mandates` 3→1 in §17.4. This is why the reverse test is not a formality: the standard derived from the audit did not, as merged, catch the audit's largest finding.)*
|
|
591
|
+
**Detector spec.** *reads:* the per-table group of tool rows, each shared contract field. *red:* two tools on one table with different filled values for a shared field and no per-tool written justification token. *green:* the filled values agree, or the divergence carries a per-tool justification token. *could-not-judge:* only an unparseable row. *kind:* mechanical, both halves — the group-by-table pairwise filled-cell compare AND the disposition (the token's presence, §23); no human verdict is deferred.
|
|
592
|
+
|
|
593
|
+
**A stale citation, corrected (not a rule change).** R-1's §5 text and §14 trace **cited** `MCP-public (106)` and `a blank surface (11 rows)`. Against `vp-by-tool-day158.csv` the split is **`MCP-public` 123, `component` 39, `hors-MCP` 26, blank 0** — and §1 already cited `123`. The standard had contradicted itself and the audit; the §5/§14 numbers **were corrected** to the day158 material, and this paragraph records that correction rather than the old figures — grep the body for `106` and it appears only here, describing what was fixed. R-1's *check* was always sound; only its cited count had been stale.
|
|
594
|
+
|
|
595
|
+
### 17.6 Derivation, by command
|
|
596
|
+
|
|
597
|
+
```
|
|
598
|
+
python3 -c "import csv; d=[r for r in csv.DictReader(open('vantagepeers/vp-restructuring/vp-by-tool-day158.csv')) if r['table']!='TOTAL']; print(len(d))" -> 188
|
|
599
|
+
crud==RPC -> 21 | 'pas de curseur' -> 14 | 'non-borné' max -> 11 | AUCUN -> 5
|
|
600
|
+
filterByOrgScope(post-query) -> 40 | namespace_ou_org 'arg=' -> 46 | INCOHERENT -> 105
|
|
601
|
+
statut_suppression non-blank -> 14 | doublon_alias non-blank -> 16 | surface: 123/39/26, blank 0
|
|
602
|
+
tables with >1 distinct non-n/a scope_enforcement -> 14 (briefingNotes, businessUnits, components, diary,
|
|
603
|
+
fixPatterns, githubRepoMapping, mandates, memories, messages, missionTemplates, missions, profiles,
|
|
604
|
+
recurringTasks, tasks)
|
|
605
|
+
memories: 6 distinct scope_enforcement -> 1 rbac_coherence_table label (positive control for §17.4)
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
### 17.7 Cleared — checked and genuinely caught, not only the gaps
|
|
609
|
+
|
|
610
|
+
R-2 (21/21 `RPC`), R-3 (14/14 no-cursor + 11/11 unbounded), R-6 (46/46 `arg=`), R-8 (5/5 `AUCUN`), R-11 (40/40 post-query — fires unconditionally, which is why it independently plugs the illegal slice of Axis 2), R-26 (39/39 component), R-27 (14/14 with survivor + PR ref), R-28 (55/55 remediation populated where R-6/R-7/R-11 fired). `doublon_alias` (16/16) correctly out of scope. Each verdict rests on the field the check reads, not on the rule's existence.
|
|
611
|
+
|
|
612
|
+
### 17.8 The prevented count, derived
|
|
613
|
+
|
|
614
|
+
Fifteen defect classes enumerated (§17.1–§17.2). One is correctly the MCP standard's (`doublon_alias`), leaving **fourteen in-scope**. As merged at 36 rules, thirteen were caught mechanically at publication; the fourteenth — **the filled-neighbour divergence, the audit's dominant defect** — was not, its obligation asserted in §2 prose but enforced by no check. **R-37 closes it: fourteen of fourteen in-scope defect classes now catch at publication.** The reverse test found exactly one gap, and it was the largest one — which is the point of running the exam from the audit's side rather than the standard's.
|
|
615
|
+
|
|
616
|
+
## 18. No obligation without a control — the standard's own instruments, as rules
|
|
617
|
+
|
|
618
|
+
A rule prescribes nothing unless a control can see it. A **control** is one of three kinds, and all three are legitimate:
|
|
619
|
+
|
|
620
|
+
- **static** — a read of a file, an inventory, a manifest, a document;
|
|
621
|
+
- **dynamic** — a call to a running service and a measurement of what it actually answers;
|
|
622
|
+
- **delivery gate** — a check run at the moment a delivery is accepted.
|
|
623
|
+
|
|
624
|
+
A rule whose obligation no control of any kind can observe is prose, not a rule — and scoping every future rule down to what a file read can see is exactly how the five instruments below ended up asserted across §1–§14 and gated by nothing. Each is now a rule, with its control named. No new obligation is invented here: each was already prescribed in prose; what was missing was the control.
|
|
625
|
+
|
|
626
|
+
**R-38 — a table is contract-conformant only when it declares all thirteen §2 fields, each from its closed vocabulary where the field has one, none left implicit.** The per-field rules the §2 contract names — one per field — are its clauses; R-38 is the completeness gate over them — a table publishes only when every clause is present and valid, so a table that silently omits a field, or fills it outside its vocabulary, is non-conforming as a whole and not merely on the one field. The per-table contract is the document's own instrument; until this rule it was asserted in §2 prose and gated nowhere — the same class of defect R-37 closed for the contract's *divergence* half, an obligation §2 states and no check delivered. *(Source: §2 the per-table contract; the conformance ruling — no obligation without a control.)*
|
|
627
|
+
**Detector spec.** *control kind:* static. *reads:* each table's row against the thirteen §2 fields. *red:* any field absent, or a value outside the field's closed vocabulary. *green:* the thirteen fields present, each within its vocabulary. *kind:* mechanical.
|
|
628
|
+
|
|
629
|
+
**R-39 — a rule whose enforceability rests on a platform mechanism names that mechanism, and the named mechanism resolves to a real, current platform guarantee.** The §11 refinements name, per rule, the Convex mechanism a rule depends on; R-39 makes that naming an obligation and its resolution a gate — a named mechanism that no current platform guarantee backs (a renamed API, a withdrawn guarantee) fails at the delivery gate, because a rule resting on a mechanism that no longer exists is unenforceable prose again. *(Source: §11 the platform layer; the conformance ruling.)*
|
|
630
|
+
**Detector spec.** *control kind:* static (the naming) + delivery gate (the resolution). *reads:* each §11-dependent rule and the platform guarantee it names. *red:* a rule whose enforceability depends on a platform mechanism it does not name, or a named mechanism that resolves to no current guarantee. *green:* the mechanism is named and resolves to a current, published guarantee. *kind:* judgement — which rules depend on a platform mechanism is a reading of the rule text (§15, §23); the detection below is mechanical where stated, and the disposition is the token's presence, never a person's verdict. The naming is mechanical (a §11-dependent rule either names its mechanism or does not), and the resolution is checked against the pinned platform-guarantee list. A named mechanism that the pinned list cannot resolve publishes only when it carries a written token (`JUSTIFIÉ:`/`JUSTIFIED:`) in its remediation cell; a flagged site with no token is **non-conforming**. The control decides on the token's **presence**, never on a person's opinion of the justification's soundness (§23, the disposition control — the same mechanism R-7 and R-8 use). Could-not-judge is reserved for input the instrument could not read, never for "a person should look at this".
|
|
631
|
+
|
|
632
|
+
**R-40 — two backends governed by this standard fill the same contract field from the same closed vocabulary; a divergence between two backends on a shared field publishes only with a written justification.** Axis 2 — divergence within one backend — is R-37; Axis 1 — divergence between two backends — had no control. R-40 is that control, comparative: it fires the moment a second backend's inventory exists and compares the two on every shared field, so the between-backend divergence §1 names as the first axis is caught, not assumed away. *(Source: §1 the two axes of divergence, Axis 1; the conformance ruling.)*
|
|
633
|
+
**Detector spec.** *control kind:* static, comparative. *reads:* two backends' inventories on each shared §2 field. *red:* two backends filling one shared field from different closed-vocabulary values (or a column present on one backend and absent on the other) with no written justification. *green:* the values agree on every shared field and the two share every column, or the divergence carries a written justification. *could-not-judge:* only an unparseable oracle. *kind:* mechanical, both halves — the compare AND the disposition (the token's presence, §23); no human verdict is deferred.
|
|
634
|
+
|
|
635
|
+
**R-41 — every frontier statement names its MCP sibling by identifier, and that identifier resolves in the sibling document.** The §9 security frontier names the served-surface siblings `S-8`, `S-10`, `C-16`, `C-17` by identifier, and the §3 four-name vocabulary is the other half shared with the MCP standard; R-41 makes the pointer's resolution an obligation — a frontier that names a sibling which does not exist in the merged MCP standard is a dead pointer, and a dead pointer sends a reader looking for a counterpart that is not there, or deleting a half it wrongly reads as a duplicate. A dead pointer is a red, not a note. *(Source: §9 the security frontier and §3 the shared vocabulary; the reconciled MCP frontier; the conformance ruling.)*
|
|
636
|
+
**Detector spec.** *control kind:* static, cross-document resolution. *reads:* every `S-`/`C-` identifier cited as a frontier sibling, against the merged MCP standard documents on `main`. *red:* a cited sibling identifier that does not resolve in the sibling document. *green:* every cited identifier resolves. *kind:* mechanical.
|
|
637
|
+
|
|
638
|
+
**R-42 — every rule cites its source, and a subject raised from any of the three sources ends as a numbered rule or a written out-of-scope reason, never as silence.** The three sources are the two audits, established practice, and the platform (§14); R-15 reconciles a new term, and R-42 is the standing discipline over the whole: the §14 source table names a source per rule from the known set — the two audits, established practice, the platform, or the conformance ruling, not a placeholder (static) — and no raised subject is left un-routed — a subject neither ruled nor excluded blocks acceptance at the gate. *(Source: §14 traceability and the three-source discipline; R-15; the conformance ruling.)*
|
|
639
|
+
**Detector spec.** *control kind:* static (the citation) + delivery gate (the never-silence half). *reads:* the §14 source cell per rule, and every subject raised in review. *red:* a rule with no source cited, or a raised subject that ends as neither a rule nor a written out-of-scope reason. *green:* every rule sourced; every raised subject routed to a rule or a written exclusion. *kind:* process.
|
|
640
|
+
|
|
641
|
+
No instrument the document names as normative is left without a control: the per-table contract (R-38), the platform mechanisms (R-39), both axes of divergence (Axis 2 = R-37, Axis 1 = R-40), the MCP frontier (R-41), and the three-source discipline (R-42). A sixth was looked for and not found — the five are the whole of what §1–§14 asserted and gated nowhere.
|
|
642
|
+
|
|
643
|
+
---
|
|
644
|
+
|
|
645
|
+
## 19. Resource lifecycle — an admin surface carries its own de-provisioning path
|
|
646
|
+
|
|
647
|
+
**R-43 — an admin-provisionable resource exposes a delete/GC path, or carries a written out-of-scope reason.** A resource an admin can create or upsert — a reusable config template, an OAuth client, a scope profile, a tenant, a runtime filter rule — declares a delete/GC path that removes it, **master-gated** and **refusing to orphan referenced dependents** (it either cascades or refuses while a dependent still references it); a resource that can be created but never removed accretes forever and orphans whatever pointed at it. Where a resource is deliberately immutable — seeded once, garbage-collected only by redeploy — that is a **written out-of-scope reason** on the resource, not a silent asymmetry. This is R-19 for admin/config resources rather than personal data: R-19 governs a data subject's erasure, R-13 a table's disposition, R-27 a tool's deprecation — none governs the lifecycle of a live admin resource you provision by hand. The check is the neighbour split the audit's Axis-2 names: `oauth_clients` is created (`createClient`) **and** removable (`deleteClient`), while `oauth_scope_profiles` is created/upserted (`seedDefaultProfiles`/`upsertScopeProfile`) with no `deleteScopeProfile` beside it — a create/delete asymmetry inside one file. *(Source: established resource-lifecycle / CRUD-symmetry practice — a provisionable resource carries its own de-provisioning path; the enterprise-grade and highly-secure objectives it serves forbid an unbounded, un-GC-able admin surface. Evidence: VP `oauth_scope_profiles` create/upsert with no delete path, Day-165 friction.)*
|
|
648
|
+
**Detector spec.** *reads:* every `ctx.db.insert("<table>")` into an admin-provisionable table (a config/profile/client/tenant/template/mapping/filter-rule resource), and every delete/GC path in the tree (a delete-named or single-subject mutation that `ctx.db.delete`s or tombstones the table). *red:* an admin resource with a create/upsert and no delete/GC path and no written out-of-scope reason. *green:* the resource has a delete/GC path, or a written out-of-scope reason. *kind:* judgement — which tables are admin-provisionable resources is a reading of meaning (§15, §23); the detection below is mechanical where stated, and the disposition is the token's presence, never a person's verdict. The create/upsert inventory and the "has a delete path?" classification are the mechanical pole. A no-delete site claimed to be a deliberate out-of-scope choice publishes only when it carries a written token (`JUSTIFIÉ:`/`JUSTIFIED:`) in its remediation cell; a flagged site with no token is **non-conforming**. The control decides on the token's **presence**, never on a person's opinion of the justification's soundness (§23, the disposition control — the same mechanism R-7 and R-8 use). Could-not-judge is reserved for input the instrument could not read, never for "a person should look at this".
|
|
649
|
+
|
|
650
|
+
---
|
|
651
|
+
|
|
652
|
+
## 20. Derived structures — a computed structure ships with its backfill, and a test that reaches the pre-write state
|
|
653
|
+
|
|
654
|
+
A structure whose contents are **computed from other rows** — a junction table, an index-bearing side table, a denormalised field, a counter, a search index — is populated only by the write path that maintains it. Rows that predate that write path are never visited, so a fix that lives in the structure is inert for every row created before it shipped, and a suite whose every fixture is built through the write path can never fail on that pre-write state. Both halves became a P0: PR #1200 shipped a `briefingNoteParticipants` junction populated only on create/update, inert for 100% of pre-deploy notes, while the whole suite stayed green because every fixture builds its subject through `create`.
|
|
655
|
+
|
|
656
|
+
**R-44 — a diff that introduces a derived structure carries, in the same delivery, an idempotent migration that populates it from the existing source rows.** A derived structure — a junction table, an index-bearing side table, a denormalised field, a counter, a search index, any structure whose contents are computed from other rows — is maintained by the write path; rows written before that path existed are never visited, so the structure is empty for all pre-existing data until a migration backfills it. A delivery that adds a derived structure ships, **in the same delivery**, a migration that populates the structure from the existing source rows, and that migration is **idempotent** — a second run is a no-op, never a duplicate row (this is R-18 applied to the backfill). A derived structure added without such a migration is non-conforming: it is correct only for data created after it, which is the inert-fix defect this rule exists to stop. **Frontier — the migration's *shape*, not its *existence*, is deferred.** R-44 requires the migration to **exist**, to **populate existing rows**, and to be **idempotent**; it does **not** restate how the migration stays within the platform's limits — a backfill beyond one transaction self-schedules in batches per **R-31**, and any change to it is proven on a real deployment per **R-32**; those two own the batching and the proof, R-44 owns only that the backfill is present and idempotent. It is likewise distinct from **R-17**, which governs the expand-contract *schema-shape* transition: R-44 fires on any derived structure even when no schema validator changes, and from **R-11**, which governs how the boundary is read *in-query* from an index-bearing structure — R-11 says the read is an index-range predicate, R-44 says the index-bearing structure that predicate reads is populated for the rows that predate the write path. *(Source: established backfill-on-introduction migration practice, and the PR #1200 briefing-note-participants incident — a junction populated only by the write path, inert for 100% of pre-deploy rows.)*
|
|
657
|
+
**Detector spec.** *reads:* every diff that introduces a derived structure (a new junction/side table in `schema.ts`, a new denormalised field, a counter, a search index), and the migrations shipped in the same delivery. *red:* a derived structure added with no migration that backfills it from existing source rows, or a backfill with no idempotency/unique guard against a second run. *green:* the delivery carries a migration that populates the structure from the existing source rows and is idempotent. *kind:* judgement — which fields or tables are derived from other rows is a reading of meaning (§15, §23); the detection below is mechanical where stated, and the disposition is the token's presence, never a person's verdict. Matching a structure-add to a same-delivery backfill is mechanical; an unbackfilled structure-add stays red. A present-but-unverified backfill publishes only when it carries a written token (`JUSTIFIÉ:`/`JUSTIFIED:`) in its remediation cell; a flagged site with no token is **non-conforming**. The control decides on the token's **presence**, never on a person's opinion of the justification's soundness (§23, the disposition control — the same mechanism R-7 and R-8 use). Could-not-judge is reserved for input the instrument could not read, never for "a person should look at this".
|
|
658
|
+
|
|
659
|
+
**R-45 — where a delivery introduces a derived structure, at least one test constructs the source row WITHOUT the write path and asserts the behaviour before AND after the migration.** Every fixture built through the write path (`create`) populates the derived structure as a side effect, so a suite made only of such fixtures is **structurally incapable** of failing on the pre-migration state — a green from a fixture that cannot reach the failing state is indistinguishable from a passing test, which is exactly how PR #1200's suite stayed green over an inert fix. A delivery that introduces a derived structure (R-44) therefore carries at least one test whose source row is constructed **without** the write path — a direct `ctx.db.insert` of the source row, not a `create` call — that asserts the behaviour **before** the migration (the pre-write state is observably broken) **and after** it (the backfill fixed it). **Frontier — the test reaches the pre-write state; the migration that state proves is R-44's, and its real-deployment proof is R-32's.** R-45 owns only that a fixture bypasses the write path and brackets the migration; whether the migration itself is idempotent and populates existing rows is R-44, and that a limit-governed backfill is proven on a real deployment rather than only under `convex-test` is R-32. *(Source: the PR #1200 incident — every fixture built its subject through `create`, so the suite could not fail on the pre-migration state; test-fixture-independence practice.)*
|
|
660
|
+
**Detector spec.** *reads:* the test suite accompanying a delivery that introduces a derived structure, and how each fixture constructs its source row. *red:* a derived structure introduced with every test building its subject through the write path (`create`), and no test that inserts the source row directly and asserts before-and-after the migration. *green:* at least one test constructs the source row via a direct `ctx.db.insert` (not `create`) and asserts the behaviour both before and after the migration. *kind:* judgement — its population is R-44's, so it inherits R-44's reading (§15, §23); the detection below is mechanical where stated, and the disposition is the token's presence, never a person's verdict. Whether a fixture calls the write path or a direct insert is mechanical. A fixture claimed to reach the failing pre-migration state without deriving it publishes only when it carries a written token (`JUSTIFIÉ:`/`JUSTIFIED:`) in its remediation cell; a flagged site with no token is **non-conforming**. The control decides on the token's **presence**, never on a person's opinion of the justification's soundness (§23, the disposition control — the same mechanism R-7 and R-8 use). Could-not-judge is reserved for input the instrument could not read, never for "a person should look at this".
|
|
661
|
+
|
|
662
|
+
---
|
|
663
|
+
|
|
664
|
+
## 21. The returns validator, and completeness-or-refusal — two rules the 2026-08-19 incidents paid for
|
|
665
|
+
|
|
666
|
+
Two incidents on one day, one on each face of a single disease: a result a caller cannot tell apart from a correct one. The first *returned* — it threw on a row it should have read, an under-declared shape crashing the read. The second *refused* — it declined a scan it could not complete rather than answer with a fraction. The system failed the wrong way, then failed the right way, and neither behaviour was written down: the crash was a defect that ran four days invisible, and the refusal was the luck of one query having been built to refuse. Both become rules because the shared principle — R-16's *a return value never means both 'could not continue' and 'nothing left'* — was asserted for the error envelope and enforced nowhere for the read shape or the incompletable scan.
|
|
667
|
+
|
|
668
|
+
R-46's instrument already exists as `convex/returnsValidatorSchemaCoverage.test.ts` (§14, `d2c32ea`), deriving the field set from `schema.ts` at runtime; the detector task that lifts that check out of one repository into `backend-doctor`, so every backend inherits R-46 rather than only the one that paid for it, is `k175q642ec4eq67jyskdv77wa98cs2jt`, and R-47's refusal check rides the same lift.
|
|
669
|
+
|
|
670
|
+
**R-46 — a read path's returns validator is DERIVED from the table's schema, never a hand-written restatement.** A read path's `returns` validator declares exactly the field set the schema declares for the table it returns — derived from the schema, never a literal list that restates it and drifts from it. A field the schema declares and any write path can write, but a read validator does not declare, is a production defect on every row that carries it: the platform's `ReturnsValidationError` rejects the whole document, so the read does not quietly return a partial row — it throws, and the row becomes unreadable through that path while still writable and still readable through others (a defect one tool can see and its neighbour cannot). This is the 2026-08-19 P0: `get_task` threw twice (request ids `cbd093fa714f6bc6`, `a2db9b965398797d`) because the row carried `blockedOnNobodyReason` and the single-document validator union did not declare it; the field had been written since `e9a6a7e` (2026-08-14) and went unread for four days, on every blocked row, invisible until a caller fetched one. A deliberate projection — a read that returns a subset of the schema's fields on purpose — is written down with its reason and verified against its handler; a shortfall with no written reason is the omission re-expressed as an exemption, the move R-13's bare `undecided` and R-8's unjustified zero-scope are refused for. **Frontier.** R-38 gates the §2 per-table *contract* — that a table declares all thirteen *inventory* fields; R-46 gates a table's *returns validator* against its *schema* — a different object (the `v.object({…})` a query returns, not the inventory row), so neither subsumes the other. R-7 governs read/write *coherence in the tenant model*; R-46 governs one read's *field completeness against the schema* — R-7 never reads a validator against the schema it must mirror. And R-46 is the field-completeness twin of **R-4**'s over-read: R-4 refuses a read that loads *more* fields than it uses; R-46 refuses a read that *declares fewer* fields than the schema can carry — over-reading and under-declaring are the two ways a read shape and a schema disagree, and this is the second. *(Source: audit re-read — `convex/returnsValidatorSchemaCoverage.test.ts` @ `d2c32ea` derives the field set from `schema.ts` at runtime and classified 43 document validators as COVERS / allowlisted-PROJECTION / FIXED, finding **4 live field-level omissions across 5 read paths** nobody had named — `diary.listByDateRange` on `createdBy`; `memories.getMemory` **and** `listMemories` both on `instanceId` (two paths, one table-field omission); `memoriesScoped.listMemoriesScoped` on `instanceId`; `messages.listByChannel` on `tenantId` — and the 2026-08-19 `get_task` production incident, origin `e9a6a7e`. The count is a runtime output of that check; it is re-derived at the delivered commit, not carried from this text.)*
|
|
671
|
+
**Detector spec.** *control kind:* static. *reads:* each read path's `returns` validator and the schema of the table it returns. *red:* a schema field, writable by some path, absent from the returns validator with no written projection reason; or a validator authored as a literal field list rather than derived from the schema's field set. *green:* the returns validator carries the schema's full writable field set, or a subset with a written, handler-verified projection reason. *kind:* mechanical (the field set is derived from `schema.ts` at runtime and each document validator classified COVERS / PROJECTION / FIXED; whether a stated projection is sound is the human edge, as with R-4).
|
|
672
|
+
|
|
673
|
+
**R-47 — a read that cannot guarantee its result is complete REFUSES, naming what would narrow it, rather than return a set indistinguishable from a complete one.** A query whose scan the platform's read cap can truncate **before its filter runs** cannot know whether the rows it never read would have matched — its result is not a page of a known whole, it is a fraction of an unknown one, indistinguishable from a full match. Such a read refuses, with the error contract (R-16), naming the arguments that would narrow its scan below the cap; it does not return the fraction. This is the 2026-08-19 companion incident, and the one where the system behaved correctly: `tasks.list` refused a widened scan with `SCAN_CAP_EXCEEDED — the result would be incomplete and indistinguishable from a full match`, naming the narrowing arguments. It is written as a rule so the correct behaviour is required, not left to the luck of one query having been built that way. **This is a NEW rule, not an amendment — and the frontier states why.** **R-3** governs the read that CAN bound itself: it declares a cursor and a `max`, and its response distinguishes *exhausted* from *truncated* — R-3 presumes truncation is detectable and signals it. R-47 is the read where truncation is NOT honestly signallable, because the cap bites before the filter selects and there is no reliable *truncated* flag to set — the only honest answer is a refusal. **R-30** settles that a tool's declared bound is its own, below the platform limit; R-47 is the case no declared bound can rescue — the filter is too selective over too large a table, so every bound still returns a set indistinguishable from the whole. R-47 sits between them: where R-3's signal is possible it is used; where R-30's bound suffices it is declared; where neither can make the result honest, R-47 refuses. The root is R-16's last clause — *a return value never means both 'could not continue' and 'nothing left'*: an incomplete result that reads as a complete one is worse than an error, and R-46 and R-47 are its two faces — a crash that hid an under-declared shape, a refusal that named an unmeetable scan. *(Source: audit re-read — the 2026-08-19 `tasks.list` `SCAN_CAP_EXCEEDED` refusal distinguishing an incomplete result from a full match; and the platform's per-transaction read cap (§11, R-30), the mechanism that bites before the filter.)*
|
|
674
|
+
**Detector spec.** *control kind:* static + dynamic. *reads:* every list/search whose scan is not fully index-bounded — a `.filter(...)` or post-read predicate over an index range wide enough to reach the read cap. *red:* such a read returning its truncated set (or a bare `.take(n)` of it) with no honest *truncated*-vs-*exhausted* signal it could set, rather than refusing. *green:* the read is index-bounded so the cap cannot bite before the filter (then R-3/R-30 govern), OR it refuses with the error contract (R-16) naming the arguments that would narrow it. *kind:* judgement — whether an index range can reach the read cap depends on data volume, not source (§15, §23); the detection below is mechanical where stated, and the disposition is the token's presence, never a person's verdict. That a scan can be truncated before its filter is mechanically detectable. A truncatable scan whose chosen disposition — refusal, tighter index, or declared bound — is asserted publishes only when it carries a written token (`JUSTIFIÉ:`/`JUSTIFIED:`) in its remediation cell; a flagged site with no token is **non-conforming**. The control decides on the token's **presence**, never on a person's opinion of the justification's soundness (§23, the disposition control — the same mechanism R-7 and R-8 use). Could-not-judge is reserved for input the instrument could not read, never for "a person should look at this".
|
|
675
|
+
|
|
676
|
+
---
|
|
677
|
+
|
|
678
|
+
## 22. The authority model is total over the rows it governs — the rule the 2026-08-19 refusals paid for
|
|
679
|
+
|
|
680
|
+
R-5 and R-10 make each mutation ask the right question caller-by-caller — *may THIS caller write this row*, a tier resolved from a verified identity, not *is this a known caller*. They say nothing about whether, for a given row, ANY caller can answer yes. A model can be correct on every caller and still leave rows nobody governs, and it fails **silently**, because every individual refusal is itself correct. Four refusals on one day proved it in our own product: the author of a PR could not attach it to the review row waiting for it; the coordinator could not route that row (`update_task … priority=urgent` → `RBAC_DENIED, "pi is not creator or assignee"`); the assignee could not retire a duplicate in its own queue (`update_task … status=cancelled` → `RBAC_DENIED`, creator `"system"`); and the same call from the coordinator, refused identically. For those rows — created by `"system"`, which is not an actor — the answer to *who may write this* is **nobody**, and three sat live in the reviewer's queue with no one able to clear them. That is not a permission held by the wrong party (R-6/R-10 already catch that); it is a permission held by NO party, a hole the caller-by-caller rules cannot see.
|
|
681
|
+
|
|
682
|
+
**R-48 — an authority model is TOTAL over the rows it governs, and a declared tier that no mutation uses does not exist.** Two clauses, one number.
|
|
683
|
+
|
|
684
|
+
1. **Totality.** For every table a mutation governs, at least one caller tier (R-5's set) can act on **every row** of it — including rows created by a non-actor: the `"system"` role, a webhook, a migration, a scheduled job. A row whose creator is not an actor is the case that breaks a relationship-based model, and it breaks silently — nothing reports it, because each refusal is correct in isolation. An authority model that leaves any governed row unreachable by every tier is non-conforming: it has locked a row against the whole fleet, and the defect surfaces only when someone needs to act on that row and finds no one can.
|
|
685
|
+
2. **A declared tier that no mutation uses does not exist.** A tier present in R-5's vocabulary (`public` / `org-member` / `org-admin` / `fleet-internal` / `master`) but wired into zero mutations is a promise the product does not keep — the coordinator's place is `fleet-internal`, and `org-admin` governs an organisation's rows, yet ours are declared and used nowhere, so the coordinator falls back to a relationship test and hits clause 1. A vocabulary term with no enforcement site is not a capability; it is documentation of one that does not exist.
|
|
686
|
+
|
|
687
|
+
**Frontier.** R-5 and R-10 govern the *per-mutation* question (which tier, resolved how); R-48 governs the *per-table totality* the set of mutations must together satisfy — a table can have every mutation individually conformant to R-10 and still fail R-48 if no tier among them reaches a non-actor-created row. R-11 governs *where* the predicate runs (in-query, not a post-filter); R-48 governs *whether the predicate any tier offers can ever match* a given row — a distinct axis. And R-48 is the reason a tier-wiring delivery is measured against a table's rows, not only against each mutation's check. *(Source: the four 2026-08-19 RBAC refusals, each with its command — the author-attach, the coordinator-route, and two assignee/coordinator retire attempts on a `"system"`-created row for which every tier answers no; the gap R-5/R-10 leave, named not supposed.)*
|
|
688
|
+
**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 — whether a governed row is reachable by any tier needs a model of relationship tests the derivation does not yet build (§15, §23); the detection below is mechanical where stated, and the disposition is the token's presence, never a person's verdict. Deriving each mutation's accepting tiers and the tier-usage census is mechanical. A unreachable-row table or unused tier claimed to be an accepted deliberate lock publishes only when it carries a written token (`JUSTIFIÉ:`/`JUSTIFIED:`) in its remediation cell; a flagged site with no token is **non-conforming**. The control decides on the token's **presence**, never on a person's opinion of the justification's soundness (§23, the disposition control — the same mechanism R-7 and R-8 use). Could-not-judge is reserved for input the instrument could not read, never for "a person should look at this". **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.
|
|
689
|
+
|
|
690
|
+
**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.)*
|
|
691
|
+
**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, AND every subscribed query built through a `customQuery` wrapper (the `authedQuery = customQuery(query, customCtx(gate))` idiom), reading the wrapper's own `customCtx` body. *red:* (a) 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; OR (b) a **gated `customQuery` wrapper** whose shared `customCtx` throws NO_ORG with no typed-empty/unauthorised return and no isolation-contract marker — the throw fires into every subscribed query built from it (the detector names the wrapper and counts its dependents). *green:* a public query (or a gated wrapper's `customCtx`) that carries the fail-closed resolver, returns a typed empty/unauthorised context on the no-org path, or documents a 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 org-isolation throw, the fail-closed resolver / contract marker, and a wrapper's `customCtx` disposition are all read from the delivered source; `could-not-judge` is reserved for a `customQuery` wrapper whose base cannot be resolved and for a query block whose braces cannot be delimited, never for "a person should read this" and never for a gated wrapper (its `customCtx` is readable). **Coverage fails on itself:** a tree with no public query returns could-not-judge naming the empty scan, never a clean bill.
|
|
692
|
+
|
|
693
|
+
**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**. A **gated `customMutation` wrapper** (`writeMutation = customMutation(mutation, customCtx(gate))`): its `customCtx` body **is** in `convex/` and readable, so the detector **judges the wrapper class** — never CNJ on it — **red** when that shared `customCtx` throws NO_ORG with no typed-unauthorised return (the throw fires into every write built from it), **green** when it returns a typed unauthorised result. A **bare** public mutation is **red on the conjunction of two readable absences** — it throws NO_ORG, returns **no** typed-unauthorised result, **and** carries **no** `write-contract:` marker: the accusation stands until the author either returns a shape (green) or **records** the unreachability claim. The reachability itself is undecidable, and the marker is exactly how an author records it — so a write that **carries** a `write-contract:` marker whose claim the detector cannot verify is **could-not-judge** (a claim the reviewer checks), never a silent green. Deleting the bare red would delete the only pressure to write that marker. *(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; the wrapper-judgement refinement is the 2026-08-30 follow-up capitalising the CRM `writeMutation` could-not-judge into a red/green verdict — a wrapper's `customCtx` is readable source; the bare red on the two-absence conjunction is retained per Eta's review, so the marker stays the recorded exit from red.)*
|
|
694
|
+
**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) a **gated `customMutation` wrapper** whose shared `customCtx` throws NO_ORG (an org-keyed `ConvexError` or `requireWriteAccess`/`requireWorkspaceId`/`getEffectiveWorkspaceId`) with **no** typed-unauthorised return and **no** `write-contract` marker — the throw fires into every write built from it (the detector names the wrapper and counts its dependent writes); AND (b) a **bare** public mutation whose no-org path throws with **no** typed-unauthorised return **and no** `write-contract` marker — the two absences are both readable in `convex/`, and the accusation stands until the author returns a shape or records the unreachability claim. *green:* a public mutation (or a gated wrapper's `customCtx`) that returns a typed unauthorised result on the no-org path. *could-not-judge (never a silent PASS):* a write (bare or wrapped) that **carries** a `write-contract` marker whose unreachability claim the detector cannot verify (a claim the reviewer checks — the marker is how the author records what `convex/` cannot decide); an unresolvable-base wrapper; and an undelimitable block. *out of scope:* a `query` (that is R-50), and a `customMutation(internalMutation, …)` wrapper. *kind:* mechanical — the two readable absences and a wrapper's `customCtx` are read from the delivered source; CNJ is reserved for a marker-borne claim the detector cannot verify, an unresolvable base, and an undelimitable block. **Coverage fails on itself:** a tree with no public mutation returns could-not-judge naming the empty scan, never a clean bill.
|
|
695
|
+
|
|
696
|
+
---
|
|
697
|
+
|
|
698
|
+
## 23. The disposition control — "a person decides" is not a control (task k179jnhx)
|
|
699
|
+
|
|
700
|
+
A rule's ACCEPTANCE names a **machine control** — a script/grep predicate, a Convex/MCP query, or a doctor detector (§18's three kinds). **"A human weighs whether the divergence/justification is sound" is not one of the three, and is never the accepted control.** A prior revision left several judgement rules with a mechanical *detection* half and a *disposition* deferred to "a human verdict is owed" — the doctor rendered that as `could-not-judge`, and a whole-catalogue tally read as a wall of human abstentions rather than decisions. This section closes that.
|
|
701
|
+
|
|
702
|
+
**The disposition control, uniform across every divergence/anomaly rule.** A rule that flags a site (an incoherence, a zero-scope data op, a filled-neighbour divergence, a between-backend divergence) decides its disposition **mechanically**:
|
|
703
|
+
|
|
704
|
+
1. **A flagged site with no written justification token is `non-conforming`.** The site owes either a fix or a **written justification token** recorded in the inventory/contract (a `JUSTIFIÉ:`/`JUSTIFIED:`/`STATELESS:`/`ACCEPTÉ:` marker in the remediation cell the rule reads). Asserting neither is the defect — the same move R-13's bare `undecided` and R-8's unjustified zero-scope are refused for.
|
|
705
|
+
2. **A flagged site carrying the token, or a site with nothing flagged, is `present`.** The control reads whether the token is **present** — never whether a person judges its prose sound. Soundness beyond the token's presence is **out of the control's scope by decision**, not deferred to a human: a wrong-but-token-bearing justification is a content defect a later reviewer may reopen, not a state the instrument abstains into.
|
|
706
|
+
3. **`could-not-judge` is reserved for UNREADABLE INPUT only** — a malformed/unparseable inventory row, an oracle the parser could not shape, a source file the tree could not read. It is **never** emitted for "a person should read this rule." A detector that would abstain on a readable site is non-conforming *as a detector*.
|
|
707
|
+
|
|
708
|
+
**Migrated this pass (detector shipped + text names the control): R-7, R-8, R-37, R-40.** **Settled by ElPi-Corp #225 as mechanical, on their own text:** R-6 (the boundary's provenance is a taint trace from source), R-11 (an in-handler filter with no `.withIndex` on the key is syntactic), R-29 (an action running two or more mutations is syntactic), R-33 (the address taint and the validator call are read from source), R-34 (a forwarded caller token and an absent audience assertion are greppable), R-35 (a splice into an instruction position is syntactic). Each decides a flagged site by the token's presence. Each moved from judgement to mechanical (§15); each ships a decisive detector with a bipolar test (a flagged-unjustified site → RED, a justified/clean fixture → GREEN, an unreadable row → could-not-judge) in `elpiarthera/backend-doctor`.
|
|
709
|
+
|
|
710
|
+
**Named gaps — rules whose disposition control is defined here but whose detector still emits a human-disposition `could-not-judge`, each with an owning task (NOT left as "human decides"):**
|
|
711
|
+
|
|
712
|
+
| Rule | Disposition still to mechanize | Owner |
|
|
713
|
+
|---|---|---|
|
|
714
|
+
| R-48 | judgement — whether a governed row is reachable by any tier needs a model of relationship tests the derivation does not yet build | task `k17chpr4kzp399j3s7r7edpm4s8cscbz` |
|
|
715
|
+
| R-49 | (as scoped by its task) | task `k177kewn589jpcp98f3njfgae58cwk1b` |
|
|
716
|
+
| R-16, R-25, R-26, R-43, R-45 | judgement — the population needs a reading: R-16 whether a refusal's message names a remedy the caller can apply is a reading of prose; R-25 which artifacts cross an organisation boundary, and where each is redeemed, is not derivable from source; R-26 what a third-party component's own isolation contract states is a reading of its documentation; R-43 which tables are admin-provisionable resources is a reading of meaning; R-45 its population is R-44's, so it inherits R-44's reading | this task's follow-up `k179jnhx20ptgapk25y50bkhdd8de0hf` |
|
|
717
|
+
| R-17, R-18, R-19, R-20, R-44, R-47 | judgement — the population needs a reading: R-17 whether a data migration can be undone is not readable from the schema diff; R-18 which writes a client may deliver twice is not derivable from source; R-19 which tables hold personal data is a reading of meaning; R-20 whether a job's work is idempotent is R-18's open population again; R-44 which fields or tables are derived from other rows is a reading of meaning; R-47 whether an index range can reach the read cap depends on data volume, not source | this task's follow-up `k179jnhx20ptgapk25y50bkhdd8de0hf` |
|
|
718
|
+
| R-15, R-32, R-39, R-42 | process (R-15, R-32, R-42) and judgement (R-39: which rules depend on a platform mechanism is a reading of the rule text); delivery-gate (D5) rules — their `could-not-judge` is the D5-off state, not a human-reads-the-rule abstention | D5 activation (out of scope this pass; D5 stays off) |
|
|
719
|
+
|
|
720
|
+
Until each row above is flipped, its §15 `(human)` clause is a **named, owned gap** — not an accepted control. The invariant this section establishes is binding now: no rule's acceptance is satisfied by a person's verdict; a rule not yet carrying a shipped token-detector carries an owning task id here instead.
|
|
721
|
+
|
|
722
|
+
A rule in this table keeps its judgement/process build-kind until its detector is flipped; its ACCEPTANCE text points here, at a machine control, not at a human — closing "human-only" as a control kind while being honest about which detectors already enforce it.
|