From 601b93895e7b759bc6aadaeed4f984fd53f2ac15 Mon Sep 17 00:00:00 2001 From: os-steve Date: Mon, 31 Aug 2026 09:47:52 +0000 Subject: [PATCH 1/3] wip: ledger-sourced authz population (family/domain) + producer authz field --- .../dogfood/test/authz-conformance.matrix.ts | 103 ++++- .../qa/dogfood/test/authz-conformance.test.ts | 427 +++++++++++++++++- .../test/authz-ledger-population.baseline.ts | 126 ++++++ .../test/authz-probe-blind-spot.census.ts | 136 +++++- .../test/authz-probe-blind-spot.test.ts | 18 +- packages/rest/src/rest-route-ledger.ts | 55 ++- packages/runtime/src/route-ledger.ts | 64 ++- 7 files changed, 886 insertions(+), 43 deletions(-) create mode 100644 packages/qa/dogfood/test/authz-ledger-population.baseline.ts diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index fc92841ce3..cd694fb00b 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -8,26 +8,56 @@ // enforced rows additionally reference an end-to-end dogfood proof. The // companion test (`authz-conformance.test.ts`) asserts every referenced proof // file exists and that the row ↔ proof pairing is MUTUAL (#7976 below), AND -// ratchets completeness over a CURATED table of HTTP/transport entry points -// (`discover()`: 16 probes over 11 named source files) — a new ungated route -// there is UNCLASSIFIED, a deleted guard is STALE, and either breaks CI. -// The count said 15 from 2026-08-16, when it was true, until a 16th probe -// landed on 2026-08-18 without the sentence moving; a reach census pinned the -// drift rather than repairing it, and the sentence is now held EQUAL to the -// table by `authz-probe-blind-spot.test.ts` instead of restated by hand. -// Each of those 16 probes now DECLARES its instrument kind +// ratchets completeness at ROUTE-FAMILY AND DISPATCHER-DOMAIN granularity over +// the two route ledgers, plus a curated table of named gates and transport +// tripwires (`discover()`: 18 probes over 13 named source files) — a new REST +// route FAMILY or dispatcher DOMAIN is UNCLASSIFIED, a deleted named guard is +// STALE, and either breaks CI. +// +// ⭐ READ THAT GRANULARITY LITERALLY; it is the whole claim and there is no +// caveat hiding behind it. A route added inside an EXISTING family or domain +// mints no key and this gate stays green — measured, not assumed: injecting an +// ungated route into `registerUiEndpoints` leaves every assertion here passing, +// because `ui` is already a family. What is ratcheted is that no route family +// and no dispatcher domain can arrive unclassified, which is a strictly +// stronger claim than the one this sentence made while it was sourced from a +// hand-curated regex table reaching 1 of 17 REST registrars and 4 of 17 +// dispatcher domain files. +// +// The population comes from `packages/rest/src/rest-route-ledger.ts` (94 rows +// / 19 families) and `packages/runtime/src/route-ledger.ts` (80 rows / 21 +// domains) because those two are enumerated from a RUNNING server and guarded +// in both directions by their own conformance tests — so a new family or +// domain cannot be silently absent from them, and therefore cannot be silently +// absent from here. Widening a regex instead was refused: it rots on the next +// added file, which is the mechanism this replaces. Deriving "gated" from +// source syntax was refused too, on a measurement — 22 of 30 apparently +// ungated `register(` sites in `rest-server.ts` are false, a 73% false-ungated +// rate — because that trades a visible gap for a written-down false assurance. +// The ledgers supply the POPULATION; the classification stays a reviewed row +// here. +// +// Of the 40 ledger keys, 6 are classified by rows below that already pinned +// the same surface through a probe; the other 34 are enumerated one by one, +// dated, and pinned SHRINK-ONLY in `authz-ledger-population.baseline.ts`. That +// list can only get shorter: growth, staleness, a classified entry left behind +// or a duplicate each fail CI. Before 2026-08-31 those 34 surfaces minted no +// key at all, so nothing about them was visible in either direction. +// +// Each of the 18 probes DECLARES its instrument kind // (ROUTE_ENUMERATION / GATE_PIN / TRIPWIRE — see the companion test), and a // non-tripwire probe that mints ZERO keys fails as a DEAD PROBE. That closes a // blind-spot mechanism neither UNCLASSIFIED nor STALE can reach: both are // keyed on a KEY, so a probe whose population is deleted before any row // covered it goes quiet without either firing. // -// ⚠️ What this table's route population is DERIVED FROM remains an open -// question, deliberately not answered here: the probes reach 1 of the 17 route -// registrars in `packages/rest/src/rest-server.ts`, a measured gap the reach -// census sizes in full. Widening the patterns is NOT the repair — it rots -// again on the next added file — and the alternatives carry a contract -// decision (see the census's population-source record). +// A ledger row may also DECLARE the authorization posture it has been reviewed +// to have, by naming the row here that classifies it (`authz:`, phased exactly +// like `responseSchema` in those same files: optional, filled only where +// conformance coverage exists, never mass-produced). The companion test +// refuses a name that is not a row id and refuses a row that is not +// `enforced`, so the declaration is a checked fact at the producer rather than +// a second place to write an unverified claim. // [#9083] Classification itself is state-gated for the transport tripwires: a // discovered TRANSPORT-WIRED key may be covered ONLY by an `enforced` row, so // silencing that particular red costs an enforcement site rather than a @@ -36,12 +66,12 @@ // [#8711] That completeness is over ROUTES, not over primitives: a primitive // enforced by a predicate inside an existing resolver adds no entry point, so // it can be neither UNCLASSIFIED nor STALE. Measured against the rows below: -// 44 of 51 carry no `covers` key at all (7 rows, 9 keys, every one an +// 44 of 51 carry no `covers` key at all (7 rows, 15 keys, every one an // HTTP/transport pin), and 38 of the file's 44 `enforced` rows are exactly // that in-resolver shape — the ADR-0049/#8613 `active` rows and the ADR-0091 // grant-validity-window row among them (see their own blocks further down) // are the normal case, not an exception. Of the -// 9 `covers` keys that DO exist, 5 are GATE pins tied to the enforcement call +// 15 `covers` keys that DO exist, 5 are GATE pins tied to the enforcement call // itself, not merely a function name — delete `shouldDenyAnonymous` from // `/actions`, `/automation` or `/packages`, or drop the MCP context-threading // / stdio principal binding, and the pinned key vanishes from source, its row @@ -151,7 +181,18 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ { id: 'anonymous-deny-meta', summary: 'anonymous-deny on the metadata endpoints (#2567 surface 1)', state: 'enforced', enforcement: 'rest/rest-server.ts registerMetadataEndpoints guarded registrar (enforceAuth → shouldDenyAnonymous) — every /meta route inherits the gate; runtime/http-dispatcher.ts handleMetadata mirrors it for the dispatcher metadata catch-all', proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', - covers: ['meta:rest-server.ts:registerMetadataEndpoints', 'meta:http-dispatcher.ts:handleMetadata'], + // [2026-08-31] The two ledger-sourced keys are NOT a new claim: they name + // the same two surfaces the two probe keys beside them already pin, at the + // granularity the ledger population uses. The enforcement text above is + // family-wide by construction on both — a wrapping guarded registrar for + // every REST /meta route, and one dispatcher handler body for the domain — + // which is exactly the property a family/domain key needs and the reason + // these two were classified while the other 34 keys were enumerated into + // the shrink-only baseline instead. + covers: [ + 'meta:rest-server.ts:registerMetadataEndpoints', 'meta:http-dispatcher.ts:handleMetadata', + 'rest-family:rest-route-ledger.ts:metadata', 'dispatcher-domain:route-ledger.ts:/meta', + ], note: '#11373 — for most of this row\'s life the cited proof drove ONE anonymous `GET /meta`, so the row read as covering a surface while only its read face was exercised. The mutating doors (`_migrate-stored`, the single save, the reset, publish, rollback — six when measured, five since #12176 D3 retired the compound save) are now driven there as real HTTP: measured 2026-08-23 on the booted showcase, every mounted door answers 401 UNAUTHENTICATED in the rest-flat envelope, nothing persists, and the same URL/method/body with a session answers 403 (member) or runs the door (admin) — so the 401 is the floor and not a broken probe; the retired compound spelling has its own case there pinning 404-for-everyone, since an auth floor only speaks for a door that exists. The write half was previously pinned only in `rest/src/meta-write-door-capability-enumeration.test.ts`, which invokes `route.handler` over a `vi.fn()` transport and therefore could not show that the composed app routes a real request into the guarded registrar at all.' }, // #5519 — the two DISPATCHER-mounted execution surfaces. `@objectstack/rest` // gated `/data` and `/meta`; these routes are mounted by a SECOND @@ -163,12 +204,20 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ { id: 'anonymous-deny-actions', summary: 'anonymous-deny on the business-action dispatch surface (#2567 surface 2 / #5519)', state: 'enforced', enforcement: 'runtime/domains/actions.ts handleActionsRequest — shouldDenyAnonymous as the handler\'s FIRST statement, ahead of the ADR-0066 D4 requiredPermissions gate and the ADR-0104 param contract; those keep their semantics and simply run after the auth baseline, so an anonymous caller never reaches action dispatch and never learns the route\'s shape', proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', - covers: ['actions:domains/actions.ts:anonymous-gate'], + // [2026-08-31] Same surface as the gate pin beside it, at ledger + // granularity: the enforcement above is DOMAIN-WIDE by construction (the + // gate is the single handler body's first statement), so the domain key + // adds no claim the pinned gate did not already carry. + covers: ['actions:domains/actions.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/actions'], note: 'A `type: \'script\'` action body runs `isSystem: true` (elevated), so an ungated POST was an anonymous privilege-escalating WRITE, not merely an information leak — #5519 measured `POST /actions/showcase_task/showcase_mark_done/:id` answering 200 with the update applied. Internal dispatch is unaffected: this handler is a pure HTTP seam (the MCP `run_action` bridge enters through action-execution.invokeBusinessAction, declarative endpoints through the transport fallback seam with their own `authRequired` gate), so `authRequired: false` public endpoints stay public.' }, { id: 'anonymous-deny-automation', summary: 'anonymous-deny on the automation/flow surface (#2567 surface 3 / #5519)', state: 'enforced', enforcement: 'runtime/domains/automation.ts handleAutomationRequest — shouldDenyAnonymous DOMAIN-WIDE at the top, and deliberately BEFORE the isServiceServeable probe so the 401/501 difference cannot be used to fingerprint whether a deployment mounts automation; per-route capability predicates run after this floor — `manage_metadata` for the four gated flow writes (create `POST /` / update `PUT /:name` / deregister `DELETE /:name`, #10145, plus enablement `POST /:name/toggle` since the #10243 ruling of 2026-08-23, which measured that the enabled bit is not a ROW and so reaches every organization on the deployment), all selected by the ONE `isFlowAuthoringWrite` predicate, fail-closed by construction (an absent executionContext, an absent `systemPermissions` or an empty one all refuse) and answering 403 `PERMISSION_DENIED`, with only engine `isSystem` bypassing; the run-state reads (#7900) and `resume` (#3801 / #5561) carry their own separate per-route predicates, and the execution doors (trigger / execute) sit outside all of them — including `POST /trigger/:name` for a flow literally NAMED `toggle`, which the toggle arm deliberately excludes so a name cannot cost a member its run door', proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', - covers: ['automation:domains/automation.ts:anonymous-gate'], + // [2026-08-31] Ledger granularity for the same DOMAIN-WIDE gate named + // above — the property the note already relies on ("gating the DOMAIN + // rather than each route is what keeps a newly added automation route from + // arriving ungated"). + covers: ['automation:domains/automation.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/automation'], note: 'Ungated, an anonymous caller could start real flow runs (`POST /:name/trigger`), read the full flow inventory (`GET /automation`), and DEREGISTER a registered flow (`DELETE /:name` → `{deleted:true}`) — the destructive one, which #5519 did not originally record. Gating the DOMAIN rather than each route is what keeps a newly added automation route from arriving ungated. Engine-internal triggers (record-change, schedule) never speak HTTP and are untouched.' }, // #7033 / #7023 — the SIXTH dispatcher domain to join the baseline. `/packages` // was the last routed domain with ZERO authorization predicates: a survey drove @@ -182,7 +231,12 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ { id: 'anonymous-deny-packages', summary: 'anonymous-deny on the package-management surface (#7033 / #7023)', state: 'enforced', enforcement: 'runtime/domains/packages.ts handlePackagesRequest — shouldDenyAnonymous DOMAIN-WIDE as the handler\'s FIRST statement, ahead of the ObjectQL registry probe so the 401-vs-503 difference cannot fingerprint whether the package service is mounted; per-route capability predicates run after this floor — `manage_metadata` for every state-changing route (install / enable / disable / publish / publish-drafts / discard-drafts / commit-revert / rollback / revert / adopt-orphans / duplicate / manifest-PATCH / DELETE), and the ADR-0106 D4 read set (`studio.access` / `setup.access`) for every read (list / detail / commits / export)', proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', - covers: ['packages:domains/packages.ts:anonymous-gate'], + // [2026-08-31] The DISPATCHER domain only. ⛔ NOT the REST `packages` + // family, which is a different registrar on a different server and stays + // in the shrink-only baseline: every /packages TRANSPORT converging on one + // handler body is a statement about the dispatcher domain, not about the + // four routes @objectstack/rest mounts itself. + covers: ['packages:domains/packages.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/packages'], note: 'Ungated, a guest-principal caller reached the whole domain: `GET /packages` (the id ENUMERATION face — first step of the chain), `GET /packages/:id/export` (27 metadata types read whole), and — destructively — `POST /packages/:id/discard-drafts` (drop every pending draft) and `POST /packages/:id/publish-drafts` (promote every draft to active + load seed rows + flip ADR-0045 visibility). Gating the DOMAIN rather than each route keeps a newly added package route from arriving ungated. Engine-internal / SDK internal calls never enter this HTTP handler. The per-route capability gates are unit-pinned in runtime/domains/packages-capability-gate.test.ts.' }, // ── #2992 / ADR-0096 D4 — latent execution surfaces (pre-wiring identity @@ -208,7 +262,14 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ // is wired; the real gap is the opt-in stdio transport.) { id: 'mcp-http-identity', summary: 'MCP HTTP surface (/api/v1/mcp) admits the caller identity — anonymous denied, OAuth scope-gated, caller ExecutionContext threaded to every tool\'s data op', state: 'enforced', enforcement: 'runtime/http-dispatcher.ts handleMcp — requires ec.userId||ec.isSystem (401 else, RFC 9728 WWW-Authenticate advertised when the OAuth track is live); OAuth-token provenance narrows the exposed tool families to the granted MCP scopes (403 on none, #2698); buildMcpBridge(context) threads the caller ExecutionContext into every bridge op (callData(..., ec)), and mcp-server-runtime.ts handleHttpRequest builds a fresh per-request McpServer from that principal-bound bridge (registerObjectTools/registerActionTools) — so RLS / FLS / tenant apply exactly as on REST /data', - covers: ['mcp:http-dispatcher.ts:handleMcp', 'mcp:domains/mcp.ts:buildMcpBridge(context-threaded)'], + // [2026-08-31] The dispatcher `/mcp` domain, at ledger granularity — the + // same handler the two keys beside it already pin. ⛔ NOT `/mcp/skill`: + // that is a second handler body (handleMcpSkillRequest) this row's + // enforcement text does not name, so it stays in the shrink-only baseline. + covers: [ + 'mcp:http-dispatcher.ts:handleMcp', 'mcp:domains/mcp.ts:buildMcpBridge(context-threaded)', + 'dispatcher-domain:route-ledger.ts:/mcp', + ], proof: 'showcase-mcp-http-identity.dogfood.test.ts', note: 'The per-request principal-bound tool server is isolated from the long-lived UNSCOPED stdio server (see mcp-stdio-authority). HIGH-RISK, proven end-to-end (#3167 PR-B): the proof boots the real showcase + security + MCP plugin and drives POST /api/v1/mcp — an anonymous tools/call is 401 before any tool runs, and a member\'s query_records over the owner-private showcase_private_note returns ONLY their own rows (if the tool ran unscoped/system — the stdio posture — the other owner\'s rows would leak). Dropping the buildMcpBridge(context) threading (or building an unscoped/system bridge for HTTP) makes the context-threaded key STALE → red CI; a new sibling MCP data handler appears as an UNCLASSIFIED surface until a row covers it. Dispatcher-level unit coverage: http-dispatcher.mcp.test.ts (401, EC-to-bridge) + http-dispatcher.mcp-oauth.test.ts (scope 403).' }, { id: 'mcp-stdio-authority', summary: 'MCP stdio transport admits an env-supplied API-key principal — RLS/FLS/tenant applied to record reads, fail-closed on a missing/invalid key, no `system` bypass (opt-in: autoStart / OS_MCP_STDIO_ENABLED=true + OS_MCP_STDIO_API_KEY)', state: 'enforced', diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 15fdb0a6a7..5328788d91 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -31,6 +31,10 @@ import { dirname, join } from 'node:path'; import { readdirSync, readFileSync } from 'node:fs'; import { checkLedger } from '@objectstack/verify'; import { AUTHZ_CONFORMANCE, type AuthzPrimitive } from './authz-conformance.matrix.js'; +import { + LEDGER_POPULATION_BASELINE, + LEDGER_POPULATION_BASELINE_MAX, +} from './authz-ledger-population.baseline.js'; const HERE = dirname(fileURLToPath(import.meta.url)); // packages/qa/dogfood/test → repo root. @@ -109,10 +113,75 @@ interface Probe { kind: ProbeKind; file: string; re: RegExp; + /** + * Narrow the scan to ONE exported array literal, named here (`export const + * ` up to the closing `\n];`). Only the two ledger probes use it, + * and they need it: their patterns (`family: '...'` / `domain: '...'`) are + * the ledger's OWN row vocabulary, so a doc-comment or a future type + * declaration spelling the same tokens outside the table would mint a + * phantom key. Scoping the read to the table is what makes the key set a + * reading OF the table rather than of the file that carries it. + */ + within?: string; key: (m: RegExpExecArray) => string; } const PROBES: readonly Probe[] = [ + // ── THE POPULATION SOURCE: the two route ledgers, at FAMILY / DOMAIN + // granularity (2026-08-31 ruling) ───────────────────────────────────── + // + // Everything below these two entries is a hand-curated pattern aimed at a + // hand-chosen file, and that is the mechanism this pair replaces as the + // COMPLETENESS instrument. A probe table naming 11 files cannot notice a + // 12th; it reaches 1 of the 17 registrars in `rest-server.ts` and 4 of the + // 17 files in `runtime/src/domains/`. A file nobody named emitted no key, + // no UNCLASSIFIED and no STALE — its absence was structurally unobservable. + // + // The ledgers do not have that property, and that is the whole reason they + // are the source. Both are enumerated from a RUNNING server and guarded in + // both directions by their own conformance tests: every mounted REST route + // must have a ledger row (`RestServer.getRoutes()`), and every registered + // dispatcher domain must have a ledger row while every ledger domain must be + // a live registry prefix (`domainRegistry.list()`). A new family or a new + // domain therefore cannot be silently absent from them, so it cannot be + // silently absent from HERE. + // + // ⭐ WHAT THIS PAIR PROMISES, stated at exactly the granularity delivered: + // ANY NEW ROUTE FAMILY or DISPATCHER DOMAIN mints a key. That key is + // UNCLASSIFIED — and red — until a matrix row covers it or it is written + // into the dated, shrink-only `authz-ledger-population.baseline.ts`. + // + // ⛔ WHAT IT DOES NOT PROMISE, measured and accepted rather than papered + // over: a route added INSIDE an existing family or domain mints NOTHING. An + // ungated route injected into `registerUiEndpoints` leaves this gate GREEN, + // exactly as it did before this change — `ui` is already a family, so no key + // appears. That bound is stated in the matrix header too, with no caveat + // attached to a wider claim, because the claim itself is the narrow one. + // + // ⛔ AND IT IS NOT A GATE READING. A ledger row grades SDK expressibility, + // never authorization: `sdk` / `server-only` / `public` / `gap` / `mismatch` + // say nothing about whether a caller is authenticated. Deriving "gated" from + // source syntax instead was measured and rejected — scanning all 80 + // `this.routeManager.register(` sites in `rest-server.ts` for `enforceAuth` + // reads 50/30 and 22 of the 30 ungated are FALSE (a wrapping + // `guardedRouteManager` for 19 of them, a shared handler const for 3), a 73% + // false-ungated rate on the largest registrar. What these two probes supply + // is the POPULATION; the classification stays a reviewed row. + { + kind: 'ROUTE_ENUMERATION', + file: 'packages/rest/src/rest-route-ledger.ts', + within: 'REST_ROUTE_LEDGER', + re: /family: '([^']+)'/g, + key: (m) => `rest-family:rest-route-ledger.ts:${m[1]}`, + }, + { + kind: 'ROUTE_ENUMERATION', + file: 'packages/runtime/src/route-ledger.ts', + within: 'ROUTE_LEDGER', + re: /domain: '([^']+)'/g, + key: (m) => `dispatcher-domain:route-ledger.ts:${m[1]}`, + }, + // REST /meta umbrella registrar — one guarded registrar covers all ~17 routes. { kind: 'ROUTE_ENUMERATION', @@ -367,6 +436,25 @@ const PROBES: readonly Probe[] = [ }, ]; +/** + * The text ONE probe reads: the whole file, or — when the probe declares + * `within` — only the body of that exported array literal. + * + * ⛔ A `within` that matches nothing returns the EMPTY string, not the whole + * file. Falling back to the file would turn a renamed export into a silently + * WIDER scan that still mints keys, which is the failure shape this project + * keeps paying for; an empty scope mints zero keys and the DEAD PROBE check + * says so out loud. + */ +function probeSource(probe: Probe): string { + const src = readFileSync(join(REPO_ROOT, probe.file), 'utf8'); + if (!probe.within) return src; + const from = src.indexOf(`export const ${probe.within}`); + if (from < 0) return ''; + const to = src.indexOf('\n];', from); + return to < 0 ? '' : src.slice(from, to); +} + /** * Keys ONE probe mints against today's source. * @@ -377,7 +465,7 @@ const PROBES: readonly Probe[] = [ * and is indistinguishable, there, from a probe that never existed. */ function keysMintedBy(probe: Probe): Set { - const src = readFileSync(join(REPO_ROOT, probe.file), 'utf8'); + const src = probeSource(probe); // Fresh lastIndex per read (the RegExp is shared, `g`-flagged). probe.re.lastIndex = 0; const found = new Set(); @@ -386,13 +474,104 @@ function keysMintedBy(probe: Probe): Set { return found; } -/** Statically enumerate the anonymous-deny HTTP entry points from source. */ -function discoverAnonymousDenySurfaces(): Set { +/** + * Every key the table mints today — the RAW population, baseline included. + * + * Kept separate from what the ratchet classifies (below) because the baseline + * rules are checked against THIS set: an entry that has stopped being minted + * is stale and must go, and that question cannot be asked of a set the entry + * was already filtered out of. + */ +function mintAllSurfaceKeys(): Set { const found = new Set(); for (const probe of PROBES) for (const k of keysMintedBy(probe)) found.add(k); return found; } +/** + * The population this ratchet CLASSIFIES: every minted key except the ones + * enumerated in `authz-ledger-population.baseline.ts`. + * + * ⚠️ The subtraction is not a tolerance and it is not a pattern. Each excluded + * key is written out by hand in a dated file, the list is pinned shrink-only, + * and `checkLedgerPopulationBaseline` reds if it grows, if an entry stops + * being minted, if an entry becomes classified, or if one is duplicated. The + * keys it excludes were UNMINTABLE before 2026-08-31 — no key, no + * UNCLASSIFIED, no STALE — so the net effect on what this gate can see is + * strictly positive: a family or domain that is NOT on that list is red the + * day it lands. + */ +function discoverAnonymousDenySurfaces(): Set { + const baseline = new Set(LEDGER_POPULATION_BASELINE); + return new Set([...mintAllSurfaceKeys()].filter((k) => !baseline.has(k))); +} + +// ── the baseline is shrink-only, and every rule of it is checked ────────── +// +// The four rules the baseline file states, made mechanical. Written as its own +// checker rather than folded into `checkAuthzLedger` for the same reason +// `checkProbeInstrumentIntegrity` is: it reads the REAL ledgers, and every +// controlled-input case in this file hands `checkAuthzLedger` a synthetic +// `discover`. +function checkLedgerPopulationBaseline( + rows: readonly AuthzPrimitive[], + minted: ReadonlySet, +): string[] { + return checkLedgerPopulationBaselineWith(LEDGER_POPULATION_BASELINE, rows, minted); +} + +/** + * The same four rules over an ARBITRARY list, so each can be driven to red + * without editing the real baseline file. `checkLedgerPopulationBaseline` is + * this function bound to the real list — which is what CI runs — so no case + * can pass against a weaker rule than the shipped one. + */ +function checkLedgerPopulationBaselineWith( + baseline: readonly string[], + rows: readonly AuthzPrimitive[], + minted: ReadonlySet, +): string[] { + const problems: string[] = []; + const covered = new Set(rows.flatMap((r) => r.covers ?? [])); + + // Rule 1 — growth is red. The literal is the reviewed decision. + if (baseline.length > LEDGER_POPULATION_BASELINE_MAX) { + problems.push( + `BASELINE GREW — ${baseline.length} entries against a pinned ceiling of ` + + `${LEDGER_POPULATION_BASELINE_MAX}. This list is shrink-only. A new route family or dispatcher ` + + 'domain is UNCLASSIFIED until a matrix row covers it; adding it here instead needs the ceiling ' + + 'moved deliberately, which is a reviewed edit and not a side effect of adding a route.', + ); + } + // Rule 4 — a duplicate makes the count lie. + const seen = new Set(); + for (const k of baseline) { + if (seen.has(k)) problems.push(`DUPLICATE BASELINE ENTRY — ${k}. The pinned count has to mean what it says.`); + seen.add(k); + } + for (const k of baseline) { + // Rule 2 — a stale entry is red. A retired or renamed family takes its + // line with it, so the list cannot describe a table that moved on. + if (!minted.has(k)) { + problems.push( + `STALE BASELINE ENTRY — ${k} is no longer minted by any probe. Delete the line: a baseline that ` + + 'outlives its surface is a record of nothing, and it hides the fact that the list is shorter ' + + 'than it claims.', + ); + } + // Rule 3 — once classified, the entry is a duplicate of the row and must + // go in the same change. This is the burn-down that makes it shrink. + if (covered.has(k)) { + problems.push( + `BASELINE SHADOWS A CLASSIFICATION — ${k} is now covered by a matrix row. Delete it from the ` + + 'baseline in the same change and lower LEDGER_POPULATION_BASELINE_MAX. Leaving it here would ' + + 'let the list keep taking credit for a gap that is closed.', + ); + } + } + return problems; +} + // ── a probe whose POPULATION was deleted must not fail SILENTLY ─────────── // // The second, independent blind-spot mechanism — and the one the surface @@ -441,6 +620,64 @@ function checkProbeInstrumentIntegrity(probes: readonly Probe[]): string[] { return problems; } +// ── the producer-side `authz` declaration resolves, or it is not written ── +// +// The 2026-08-31 ruling's DIRECTION half: a route ledger row may declare the +// authorization posture it has been REVIEWED to have, by naming the +// `authz-conformance.matrix.ts` row that classifies it. The field is phased +// exactly like `responseSchema` in those same two files — optional, filled +// only where conformance coverage already exists, and burnt down incrementally +// rather than mass-produced. +// +// This is the guard that makes the declaration a CHECKED fact rather than a +// second place to write an unverified claim. It is the direct transposition of +// `responseSchema`'s rule ("a name written ahead of the test it points at +// would BE the declared-but-unverified surface the programme exists to +// remove") and of its resolution strategy: the ledgers stay import-free data, +// and the resolution lives in the guard that can import the vocabulary. +// +// Two refusals, and they are the whole rule: +// • a name that is not a row id — a typo, or a row that was renamed or +// retired out from under the declaration; +// • a row that is not `enforced` — an `experimental` or `removed` row +// records an ABSENCE, and pointing a route at one would declare "reviewed" +// while the reviewed answer is "there is nothing here". That is the same +// silent exit `checkTransportWiredAdmission` closes for tripwire keys. +// +// ⛔ It deliberately does NOT check that the named row's enforcement site +// covers this exact route. That is not mechanically decidable and pretending +// otherwise would be the syntactic gate-reading this design already measured +// and rejected. What is mechanical is that the declaration points at a real, +// enforced, reviewed row — and that a route landing WITHOUT one is visible at +// the ledger review point, which is where the question belongs. +function checkAuthzDeclaration(id: string, rows: readonly AuthzPrimitive[], where = ''): string[] { + const row = rows.find((r) => r.id === id); + if (!row) { + return [ + `${where}authz: '${id}' names no row in authz-conformance.matrix.ts. Fill this field only with ` + + 'the id of a row that classifies this route, and move it when the row is renamed.', + ]; + } + if (row.state !== 'enforced') { + return [ + `${where}authz: '${id}' names a '${row.state}' row. Only an \`enforced\` row — which owes an ` + + 'enforcement site — can back a declared authorization posture; a non-enforced row records an ' + + 'absence, so pointing a route at it declares "reviewed" over "there is nothing here".', + ]; + } + return []; +} + +function checkLedgerAuthzDeclarations(rows: readonly AuthzPrimitive[]): string[] { + const problems: string[] = []; + for (const probe of PROBES.filter((pr) => pr.within)) { + for (const m of probeSource(probe).matchAll(/authz: '([^']*)'/g)) { + problems.push(...checkAuthzDeclaration(m[1], rows, `${probe.file}: `)); + } + } + return problems; +} + // ── #9083 — a wired transport is admitted ONLY by an `enforced` row ─────── // // The defect this closes, measured on `origin/main` before the fix: the @@ -553,6 +790,190 @@ describe('ADR-0056 D10 — authorization conformance matrix', () => { const problems = checkProbeInstrumentIntegrity(PROBES); expect(problems, problems.join('\n')).toEqual([]); }); + + it('the ledger-sourced population baseline is shrink-only, current and non-shadowing', () => { + // All four baseline rules on the REAL ledgers and the REAL matrix. Same + // reason as the case above for living outside `checkAuthzLedger`: it reads + // source, and every controlled-input case in this file supplies a + // synthetic `discover`. + const problems = checkLedgerPopulationBaseline(AUTHZ_CONFORMANCE, mintAllSurfaceKeys()); + expect(problems, problems.join('\n')).toEqual([]); + }); + + it('every producer-side `authz` declaration resolves to an `enforced` matrix row', () => { + const problems = checkLedgerAuthzDeclarations(AUTHZ_CONFORMANCE); + expect(problems, problems.join('\n')).toEqual([]); + }); +}); + +// ── the ledger population, and the two new rules over it, BITE ──────────── +// +// Same discipline as every other block here: a check never shown to fail is +// not evidence, and a zero with no positive control beside it is not a +// reading. The cases below drive the real ledgers (so a moved or emptied file +// is caught) and synthetic baselines / rows (so they are deterministic and +// need no source edits). +describe('the ledger-sourced population and its baseline bite', () => { + const clone = (): AuthzPrimitive[] => JSON.parse(JSON.stringify(AUTHZ_CONFORMANCE)); + const REST_LEDGER = PROBES.find((p) => p.file === 'packages/rest/src/rest-route-ledger.ts')!; + const RUN_LEDGER = PROBES.find((p) => p.file === 'packages/runtime/src/route-ledger.ts')!; + + it('CONTROL — both ledgers are readable and mint a real key set (the zeros below are readings)', () => { + // Without this, every assertion in this block would be satisfied just as + // well by two probes pointed at files that no longer exist. + const restKeys = keysMintedBy(REST_LEDGER); + const runKeys = keysMintedBy(RUN_LEDGER); + expect(restKeys.size).toBeGreaterThan(10); + expect(runKeys.size).toBeGreaterThan(10); + // Named anchors, not just counts: a key set of the right SIZE from the + // wrong table would pass a count check. + expect(restKeys).toContain('rest-family:rest-route-ledger.ts:metadata'); + expect(runKeys).toContain('dispatcher-domain:route-ledger.ts:/meta'); + // …and the two key spaces do not collide, so a family can never be + // silently classified by a domain's row or the reverse. + expect([...restKeys].filter((k) => runKeys.has(k))).toEqual([]); + }); + + it('the `within` scope is load-bearing: it reads the TABLE, not the file', () => { + // The probe patterns are the ledger's own row vocabulary. Scoped to the + // exported array they read the table; unscoped they would also read any + // doc-comment or type declaration that spells the same tokens. This case + // is what keeps the scope from being dropped as redundant. + const scoped = keysMintedBy(REST_LEDGER); + const unscoped = keysMintedBy({ ...REST_LEDGER, within: undefined }); + expect(scoped.size).toBeGreaterThan(0); + for (const k of scoped) expect(unscoped).toContain(k); + // A `within` naming an export that is not there mints NOTHING — it does + // not silently fall back to the whole file. + expect(keysMintedBy({ ...REST_LEDGER, within: 'NO_SUCH_EXPORT' }).size).toBe(0); + }); + + it('a NEW route family in the ledger is UNCLASSIFIED — the rot the probe table had is closed', () => { + // The point of the whole change, executable: nobody has to remember to add + // a probe when a family lands. + const fresh = 'rest-family:rest-route-ledger.ts:webhooks'; + expect(mintAllSurfaceKeys().has(fresh)).toBe(false); + const problems = checkAuthzLedger(AUTHZ_CONFORMANCE, { + proofRoot: HERE, + highRisk: HIGH_RISK, + discover: () => new Set([...discoverAnonymousDenySurfaces(), fresh]), + attribution: ATTRIBUTION, + }); + expect(problems.some((p) => p.includes('UNCLASSIFIED surface') && p.includes(fresh))).toBe(true); + }); + + it('a NEW dispatcher domain is UNCLASSIFIED too — the #13376 file-selection layer', () => { + // A domain file no probe names used to emit NOTHING: no key, no STALE, no + // UNCLASSIFIED. Its absence was structurally unobservable. It is not now. + const fresh = 'dispatcher-domain:route-ledger.ts:/webhooks'; + expect(mintAllSurfaceKeys().has(fresh)).toBe(false); + const problems = checkAuthzLedger(AUTHZ_CONFORMANCE, { + proofRoot: HERE, + highRisk: HIGH_RISK, + discover: () => new Set([...discoverAnonymousDenySurfaces(), fresh]), + attribution: ATTRIBUTION, + }); + expect(problems.some((p) => p.includes('UNCLASSIFIED surface') && p.includes(fresh))).toBe(true); + }); + + it('rule 1 — a baseline over its pinned ceiling is RED', () => { + const minted = mintAllSurfaceKeys(); + const over = [...LEDGER_POPULATION_BASELINE, ...[...minted].filter((k) => !LEDGER_POPULATION_BASELINE.includes(k))]; + expect(over.length).toBeGreaterThan(LEDGER_POPULATION_BASELINE_MAX); + // Driven through the same rule the real check uses, with the length as the + // only thing changed. + const problems = checkLedgerPopulationBaselineWith(over, AUTHZ_CONFORMANCE, minted); + expect(problems.some((p) => /BASELINE GREW/.test(p))).toBe(true); + }); + + it('rule 2 — a baseline entry no probe mints any more is RED', () => { + const problems = checkLedgerPopulationBaselineWith( + [...LEDGER_POPULATION_BASELINE, 'rest-family:rest-route-ledger.ts:retired-family'], + AUTHZ_CONFORMANCE, + mintAllSurfaceKeys(), + ); + expect(problems.some((p) => /STALE BASELINE ENTRY/.test(p) && p.includes('retired-family'))).toBe(true); + }); + + it('rule 3 — classifying a baselined key without deleting the line is RED', () => { + // The burn-down rule. Without it the list would keep taking credit for a + // gap somebody closed, and would never shrink. + const key = LEDGER_POPULATION_BASELINE[0]; + const m = clone(); + m.find((r) => r.id === 'anonymous-deny-meta')!.covers!.push(key); + const problems = checkLedgerPopulationBaseline(m, mintAllSurfaceKeys()); + expect(problems.some((p) => /BASELINE SHADOWS A CLASSIFICATION/.test(p) && p.includes(key))).toBe(true); + }); + + it('rule 4 — a duplicated baseline entry is RED', () => { + const problems = checkLedgerPopulationBaselineWith( + [...LEDGER_POPULATION_BASELINE, LEDGER_POPULATION_BASELINE[0]], + AUTHZ_CONFORMANCE, + mintAllSurfaceKeys(), + ); + expect(problems.some((p) => /DUPLICATE BASELINE ENTRY/.test(p))).toBe(true); + }); + + it('every baselined key IS minted, and none of them is classified (non-vacuity)', () => { + // The two rules above are only meaningful if the real list is currently in + // the state they describe. This is what says so out loud. + const minted = mintAllSurfaceKeys(); + const covered = new Set(AUTHZ_CONFORMANCE.flatMap((r) => r.covers ?? [])); + for (const k of LEDGER_POPULATION_BASELINE) { + expect(minted.has(k), `${k} is baselined but not minted`).toBe(true); + expect(covered.has(k), `${k} is baselined AND classified`).toBe(false); + } + expect(LEDGER_POPULATION_BASELINE.length).toBe(LEDGER_POPULATION_BASELINE_MAX); + }); + + it('the classified ledger keys really are classified — the baseline is not the whole population', () => { + // A baseline that swallowed everything would make this gate say nothing + // about the ledgers at all, and would still pass every rule above. + const covered = new Set(AUTHZ_CONFORMANCE.flatMap((r) => r.covers ?? [])); + const ledgerKeys = [...keysMintedBy(REST_LEDGER), ...keysMintedBy(RUN_LEDGER)]; + const classified = ledgerKeys.filter((k) => covered.has(k)); + expect(classified.length).toBeGreaterThan(0); + expect(classified.sort()).toEqual([ + 'dispatcher-domain:route-ledger.ts:/actions', + 'dispatcher-domain:route-ledger.ts:/automation', + 'dispatcher-domain:route-ledger.ts:/mcp', + 'dispatcher-domain:route-ledger.ts:/meta', + 'dispatcher-domain:route-ledger.ts:/packages', + 'rest-family:rest-route-ledger.ts:metadata', + ]); + // …and the two halves partition the population exactly: nothing minted by + // a ledger probe is both, and nothing is neither. + expect(classified.length + LEDGER_POPULATION_BASELINE.length).toBe(new Set(ledgerKeys).size); + }); + + it('a producer `authz` naming no row is RED, and a non-enforced row is RED too', () => { + const m = clone(); + // Drive the checker's two refusals through synthetic rows rather than by + // editing the ledgers: the real declarations are asserted clean above, and + // a check that has only ever seen the clean case is not evidence. + expect(checkAuthzDeclaration('no-such-row', m).some((p) => /names no row/.test(p))).toBe(true); + const experimental = m.find((r) => r.state === 'experimental')!; + expect( + checkAuthzDeclaration(experimental.id, m).some((p) => /names a 'experimental' row/.test(p)), + ).toBe(true); + // …and the positive control: a real enforced row is accepted. + const enforced = m.find((r) => r.state === 'enforced')!; + expect(checkAuthzDeclaration(enforced.id, m)).toEqual([]); + }); + + it('the `authz` field is actually IN USE — the guard is not passing for lack of subjects', () => { + // A resolver over zero declarations is green forever. This is the case + // that fails when the seed rows are deleted or the field is never filled. + const declared = [...PROBES.filter((p) => p.within)].flatMap((p) => + [...probeSource(p).matchAll(/authz: '([^']+)'/g)].map((mm) => mm[1]), + ); + expect(declared.length).toBeGreaterThan(0); + for (const id of declared) { + const row = AUTHZ_CONFORMANCE.find((r) => r.id === id); + expect(row, `authz: '${id}' must name a real row`).toBeDefined(); + expect(row!.state).toBe('enforced'); + } + }); }); // ── the dead-probe check BITES, and is not vacuous ──────────────────────── diff --git a/packages/qa/dogfood/test/authz-ledger-population.baseline.ts b/packages/qa/dogfood/test/authz-ledger-population.baseline.ts new file mode 100644 index 0000000000..8bc75e6933 --- /dev/null +++ b/packages/qa/dogfood/test/authz-ledger-population.baseline.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// THE UNCLASSIFIED BASELINE for the route-ledger population — enumerated, +// dated, shrink-only. +// +// ── WHY IT EXISTS ───────────────────────────────────────────────────────── +// +// `authz-conformance.test.ts` sources its route population from the two route +// ledgers at FAMILY / DOMAIN granularity (2026-08-31). That mints 40 keys +// where the probe table alone minted 9, and 34 of the 40 name a surface no +// authorization row in `authz-conformance.matrix.ts` classifies today. +// +// Those 34 are a REAL, MEASURED gap and they are written down here one by one. +// The alternative shapes were both refused: +// +// ⛔ CLASSIFYING THEM IN BULK. A `covers` append is a claim that a reviewed +// enforcement site accounts for the surface. Writing 34 of them in one +// change would manufacture 34 unreviewed claims — the exact "declared but +// unverified" surface every ratchet in this repo exists to remove, and the +// triage that opened this work forbade it explicitly when the number was +// 16. +// ⛔ SILENCING THEM. A count, a pattern, a prefix skip or a "known gaps" +// tolerance would make the gate see LESS than it does with the keys +// enumerated. None of those can tell a NEW unclassified family from an old +// one, which is the only thing this ratchet is for. +// +// ⭐ WHAT THIS SHAPE BUYS, and why it is not a weakening. Before this file the +// 34 surfaces below were not merely unclassified — they were UNMINTABLE: no +// key, no UNCLASSIFIED, no STALE, nothing to be silent about. Enumerating them +// converts an invisible absence into a written, dated, checked list that can +// only get shorter. A 41st family or domain is not on this list and fails CI +// as UNCLASSIFIED on the day it lands. +// +// ── THE FOUR RULES, ENFORCED BY `authz-conformance.test.ts` ─────────────── +// +// 1. GROWTH IS RED. `LEDGER_POPULATION_BASELINE_MAX` pins the length. Adding +// an entry needs that literal moved, which is a reviewed edit and not a +// side effect of adding a route. +// 2. A STALE ENTRY IS RED. Every entry must still be a key the ledgers mint +// today. A family that is renamed or retired takes its entry with it. +// 3. A CLASSIFIED ENTRY IS RED. The moment a matrix row `covers` one of these +// keys, the entry here is a duplicate and must be deleted in the same +// change. The list cannot silently outlive the gap it records. +// 4. A DUPLICATE ENTRY IS RED. The count has to mean what it says. +// +// ⛔ NOTHING HERE ASSERTS THAT ANY ROUTE IS UNGUARDED, UNAUTHENTICATED OR +// EXPLOITABLE. Route-level enforcement is mostly in-handler (`enforceAuth` / +// `shouldDenyAnonymous` / per-object permission checks) and is not measured +// here at all. What is recorded is what the RATCHET can SEE. That is a defect +// in a GUARANTEE, not a breach. +// +// ── HOW AN ENTRY LEAVES THIS LIST ───────────────────────────────────────── +// +// Write (or cite) the enforcement site, add a matrix row whose `covers` names +// the key, and delete the line below. Rule 3 makes that deletion mandatory +// rather than optional, so the list burns down instead of accreting. + +/** + * Ledger-sourced population keys with no classifying matrix row. + * + * MEASURED 2026-08-31 against `rest-route-ledger.ts` (94 rows / 19 families) + * and `route-ledger.ts` (80 rows / 21 domains): 40 keys minted, 6 classified + * by rows that already pin the same surface through the probe table, 34 here. + * + * ⛔ SHRINK-ONLY. See rules 1–4 above; the test enforces all four. + */ +export const LEDGER_POPULATION_BASELINE: readonly string[] = [ + // ── REST families (`packages/rest/src/rest-route-ledger.ts`) ──────────── + // `metadata` is absent because it IS classified — `anonymous-deny-meta` + // covers it through the guarded registrar. The other 18 families are here. + 'rest-family:rest-route-ledger.ts:analytics', + 'rest-family:rest-route-ledger.ts:approvals', + 'rest-family:rest-route-ledger.ts:batch', + 'rest-family:rest-route-ledger.ts:crud', + 'rest-family:rest-route-ledger.ts:data-actions', + 'rest-family:rest-route-ledger.ts:discovery', + 'rest-family:rest-route-ledger.ts:email', + 'rest-family:rest-route-ledger.ts:external-datasource', + 'rest-family:rest-route-ledger.ts:forms', + 'rest-family:rest-route-ledger.ts:openapi', + // ⚠️ NOT the same surface as `dispatcher-domain:route-ledger.ts:/packages`, + // which IS classified: that key names the dispatcher domain whose single + // handler body carries the domain-wide gate. This one names the four routes + // `@objectstack/rest` mounts itself, through a different registrar. + 'rest-family:rest-route-ledger.ts:packages', + 'rest-family:rest-route-ledger.ts:record-shares', + 'rest-family:rest-route-ledger.ts:reports', + 'rest-family:rest-route-ledger.ts:search', + 'rest-family:rest-route-ledger.ts:security', + 'rest-family:rest-route-ledger.ts:security-explain', + 'rest-family:rest-route-ledger.ts:sharing-rules', + 'rest-family:rest-route-ledger.ts:ui', + + // ── dispatcher domains (`packages/runtime/src/route-ledger.ts`) ───────── + // Absent because classified: `/meta`, `/actions`, `/automation`, + // `/packages`, `/mcp`. The other 16 domains are here. + 'dispatcher-domain:route-ledger.ts:/.well-known/objectstack', + 'dispatcher-domain:route-ledger.ts:/ai', + 'dispatcher-domain:route-ledger.ts:/analytics', + 'dispatcher-domain:route-ledger.ts:/apps', + 'dispatcher-domain:route-ledger.ts:/auth', + 'dispatcher-domain:route-ledger.ts:/data', + 'dispatcher-domain:route-ledger.ts:/discovery', + 'dispatcher-domain:route-ledger.ts:/health', + 'dispatcher-domain:route-ledger.ts:/i18n', + 'dispatcher-domain:route-ledger.ts:/keys', + // ⚠️ Separate from `/mcp`, deliberately. The `/mcp` key is classified by + // `mcp-http-identity`, whose enforcement site is `handleMcp`; `/mcp/skill` + // is a different handler body (`handleMcpSkillRequest`) that the probe + // table was already measured not to reach. + 'dispatcher-domain:route-ledger.ts:/mcp/skill', + 'dispatcher-domain:route-ledger.ts:/notifications', + 'dispatcher-domain:route-ledger.ts:/ready', + 'dispatcher-domain:route-ledger.ts:/security', + 'dispatcher-domain:route-ledger.ts:/share-links', + 'dispatcher-domain:route-ledger.ts:/ui', +]; + +/** + * The pinned ceiling. ⛔ SHRINK-ONLY — lower it as entries are classified + * away; raising it is a reviewed decision, never a side effect of adding a + * route family. + * + * 34 at 2026-08-31, the day the ledger population was adopted. + */ +export const LEDGER_POPULATION_BASELINE_MAX = 34; diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index 44f571232f..3e59f2bd85 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -7,9 +7,16 @@ // "ratchets completeness over a CURATED table of HTTP/transport entry points" // and that "a new ungated route there is UNCLASSIFIED ... and breaks CI". That // promise is true only for the entry points a probe can actually mint a key -// for. This module measures, for EVERY one of the 11 files the `PROBES` table +// for. This module measures, for EVERY one of the 13 files the `PROBES` table // names, how far that reach extends — and records the result so it cannot rot. // +// ⭐ Since 2026-08-31 two of those files are the ROUTE LEDGERS, and they are +// the reason the matrix header now claims family/domain completeness rather +// than route completeness. Their rows read `blindSpot: 0` for a reason no +// other row here has — every ledger row is reachable by a mintable key — and +// that must NOT be read as "the route blind spot is closed". It is not: +// `BLIND_SPOT_TOTAL_STATIC` / `_RUNTIME` below are UNCHANGED at 75 / 80. +// // ⛔ WHAT IS **NOT** CLAIMED HERE. Nothing in this file asserts that any route // is unguarded, unauthenticated or exploitable. Route-level enforcement is // mostly in-handler (`enforceAuth` / `shouldDenyAnonymous` / per-object @@ -81,7 +88,7 @@ // protocol). The static reading is the one pinned because it is the one this // package can re-derive without depending on `@objectstack/rest`. // -// ── THE POPULATION SOURCE: measured, and still an OPEN CONTRACT DECISION ── +// ── THE POPULATION SOURCE: measured, then DECIDED (2026-08-31) ──────────── // // The obvious repair is to source this ratchet's route population from the // route ledgers instead of from a regex table, and that was measured before @@ -145,12 +152,35 @@ // from the one this matrix header states, and adopting it is a contract // decision rather than a repair. // -// ⇒ ⛔ NOT DECIDED HERE, and deliberately not worked around: widening the -// regex is the rot this instrument already has, and inventing a syntactic -// "gated" reading would convert a visible gap into a written-down false -// assurance — strictly worse than an honest UNCLASSIFIED. What this file does -// instead is close the mechanism that was SILENT (the dead probe), leave the -// population untouched, and hand the decision on with the reading attached. +// ⇒ ⭐ DECIDED 2026-08-31, and the three blockers above are why the answer is +// what it is rather than the obvious one. The ledgers now supply the +// POPULATION at FAMILY / DOMAIN granularity — 19 REST families + 21 dispatcher +// domains = 40 keys — and nothing else. They do NOT supply the +// CLASSIFICATION: blocker 1 stands, so "is it gated" remains a reviewed matrix +// row, never a ledger disposition. Blocker 2 stands as the reason no syntactic +// reading was attempted anywhere. Blocker 3 is not fixed and is not hidden: a +// route added inside an existing family mints nothing and this gate stays +// green, which is stated at that exact granularity in the matrix header with +// no caveat attached to a wider claim. +// +// What the change buys is the FILE-SELECTION layer rather than the route +// layer: a family or domain can no longer be silently absent, because both +// ledgers are enumerated from a running server and guarded in both directions. +// 6 of the 40 keys are classified by rows that already pinned the same +// surface; the other 34 are enumerated, dated and pinned shrink-only in +// `authz-ledger-population.baseline.ts`. Before that date those 34 surfaces +// minted no key at all — no UNCLASSIFIED, no STALE, nothing. +// +// The DIRECTION half went to the producer: a ledger row may declare the authz +// posture it has been reviewed to have (`authz:`, phased exactly like +// `responseSchema` — optional, no coverage no fill, never mass-produced), and +// the companion test resolves every declaration against an `enforced` matrix +// row. That is what eventually makes blocker 3 answerable at the ledger review +// point, where a new route is already being read. +// +// ⛔ Two readings stay REJECTED and are recorded here so they are not +// re-proposed: deriving "gated" from source syntax (73% false-ungated), and +// taking a ledger disposition as an authorization fact (blocker 1). import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -200,7 +230,7 @@ export interface ProbeTableReading { keys: number; } -export const PROBE_TABLE: ProbeTableReading = { entries: 16, files: 11, keys: 9 }; +export const PROBE_TABLE: ProbeTableReading = { entries: 18, files: 13, keys: 15 }; /** * The probe count `authz-conformance.matrix.ts`'s header states. @@ -219,9 +249,61 @@ export const PROBE_TABLE: ProbeTableReading = { entries: 16, files: 11, keys: 9 * in a third file. ⛔ Do not re-point this at a hand-written number — it is * read out of the matrix header's own text. */ -export const MATRIX_HEADER_PROBE_CLAIM = 16; +export const MATRIX_HEADER_PROBE_CLAIM = 18; export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ + // ── the two LEDGER files: the population source since 2026-08-31 ─────── + // + // ⭐ These two rows read differently from every other row here, and the + // difference is the point of the change that added them: `blindSpot` is 0 + // NOT because the file mounts nothing (the reading four rows below give) but + // because EVERY row in the table is reachable by a mintable key. Each ledger + // row carries a `family` / `domain`, and each distinct value mints a key, so + // there is no row the ratchet cannot name. + // + // ⚠️ `population` here counts LEDGER ROWS, not registrar call sites, and the + // two are not interchangeable. A `blindSpot` of 0 says every ledger row is + // covered by some key at FAMILY / DOMAIN granularity; it does NOT say every + // route mints its own key, and it does not touch the 75/80 route-level blind + // spot recorded below — that number is unchanged by this change and Leg A of + // the reverse verification is what proves it unchanged. + { + file: 'packages/rest/src/rest-route-ledger.ts', + kinds: ['ROUTE_ENUMERATION'], + probes: 1, + keys: 19, + population: 94, + reachable: 94, + blindSpot: 0, + populationRule: 'ledger rows inside REST_ROUTE_LEDGER; reachable = rows carrying a `family` (each distinct value mints a key)', + controls: { "route: '": 94, "family: '": 94, RestRouteLedgerEntry: 2 }, + note: + 'The audited disposition of every route @objectstack/rest mounts, enumerated through ' + + 'RestServer.getRoutes() on a booted server and guarded per route by rest-route-ledger.conformance.test.ts. ' + + 'That guard is why this file can be a population source and a regex table cannot: a mounted route with no ' + + 'row here is already RED in another package, so a new family cannot be silently absent from this file, ' + + 'and therefore cannot be silently absent from the authz ratchet either. 19 families; 1 classified by a ' + + 'matrix row (metadata), 18 enumerated in the shrink-only baseline.', + }, + { + file: 'packages/runtime/src/route-ledger.ts', + kinds: ['ROUTE_ENUMERATION'], + probes: 1, + keys: 21, + population: 80, + reachable: 80, + blindSpot: 0, + populationRule: 'ledger rows inside ROUTE_LEDGER; reachable = rows carrying a `domain` (each distinct value mints a key)', + controls: { "route: '": 80, "domain: '": 80, RouteLedgerEntry: 2 }, + note: + 'The dispatcher half. Its machine contract is DOMAIN-level by live registry introspection ' + + '(domainRegistry.list()), guarded in BOTH directions by route-ledger.conformance.test.ts: every ' + + 'registered domain needs a row, and every ledger domain must be a live prefix or a pinned legacy / ' + + 'non-dispatch branch. That two-way guard is what settles the #13376 file-selection layer by ' + + 'construction — all 16 DomainRoute prefixes declared across the 15 domain files that declare one are ' + + 'ledger domains today, including the 11 files no probe has ever named. 21 domains; 5 classified ' + + '(/meta, /actions, /automation, /packages, /mcp), 16 in the shrink-only baseline.', + }, { file: 'packages/rest/src/rest-server.ts', kinds: ['ROUTE_ENUMERATION', 'TRIPWIRE'], @@ -432,6 +514,40 @@ export function deriveProbeFileCensus(): { } { const files = new Map }>(); + // ── the two ledger files (the population source) ──────────────────────── + // + // Scoped to the exported array literal, exactly as the probes are: the + // patterns are the ledger's own row vocabulary, so a doc-comment or a type + // declaration spelling the same tokens outside the table would inflate the + // reading. `controls` stay WHOLE-FILE counts, like every other row here — + // they answer "is this still the file I think it is", which is a question + // about the file and not about the table. + for (const [rel, marker, keyField] of [ + ['packages/rest/src/rest-route-ledger.ts', 'REST_ROUTE_LEDGER', 'family'], + ['packages/runtime/src/route-ledger.ts', 'ROUTE_LEDGER', 'domain'], + ] as ReadonlyArray) { + const src = read(rel); + const from = src.indexOf(`export const ${marker}`); + const to = from < 0 ? -1 : src.indexOf('\n];', from); + // A marker that has moved reads as an EMPTY table, never as the whole file: + // a silently wider scope would still produce plausible numbers. + const table = from < 0 || to < 0 ? '' : src.slice(from, to); + const rowRe = /route: '/g; + const keyRe = new RegExp(`${keyField}: '`, 'g'); + const entryName = keyField === 'family' ? 'RestRouteLedgerEntry' : 'RouteLedgerEntry'; + files.set(rel, { + population: occurrences(table, rowRe), + // Every row carrying the key field is reachable: each distinct value + // mints a key. A row that ever loses it shows up as a blind spot here. + reachable: occurrences(table, keyRe), + controls: { + "route: '": occurrences(src, /route: '/g), + [`${keyField}: '`]: occurrences(src, new RegExp(`${keyField}: '`, 'g')), + [entryName]: occurrences(src, new RegExp(entryName, 'g')), + }, + }); + } + // ── rest-server.ts ────────────────────────────────────────────────────── { const src = read('packages/rest/src/rest-server.ts'); diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.test.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.test.ts index c45af3cfd5..a8996b8d4b 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.test.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.test.ts @@ -43,12 +43,18 @@ describe('authz probe blind-spot census (#13260)', () => { expect(derived.table).toEqual(PROBE_TABLE); }); - it('every mintable key is classified by exactly the rows that exist — 9 keys, no more', () => { - // The matrix's `covers` keys and the probes' minted keys are the same set of - // 9 today. If a probe starts minting a key no row covers, the ratchet itself - // goes red as UNCLASSIFIED — that is its job, and this pin does not duplicate - // it. What this asserts is only that the census's key count is current. - expect(PROBE_TABLE.keys).toBe(9); + it('every classified key is accounted for — 15 `covers` keys, no more', () => { + // The matrix's `covers` keys number 15 today: the 9 probe-minted keys this + // census was first measured against, plus the 6 ledger family/domain keys + // classified when the population moved (2026-08-31). If a probe starts + // minting a key no row covers, the ratchet itself goes red as UNCLASSIFIED + // — that is its job, and this pin does not duplicate it. What this asserts + // is only that the census's key count is current. + // + // ⚠️ This is NOT the size of the population. The ledgers mint 40 keys; 6 + // are classified here and 34 are enumerated in the shrink-only baseline, + // which `authz-conformance.test.ts` holds to its own four rules. + expect(PROBE_TABLE.keys).toBe(15); }); it.each(PROBE_FILE_CENSUS.map((r) => [r.file, r] as const))( diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index bc119aa352..6abd2d0ce8 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -101,6 +101,47 @@ export interface RestRouteLedgerEntry { * resolution belongs in the guard that can import the spec, not in the data. */ responseSchema?: string; + /** + * The AUTHORIZATION posture this route has been REVIEWED to have, named by + * the `authz-conformance.matrix.ts` row that classifies it (ADR-0056 D10). + * + * WHY A DECLARED FACT AND NOT A DERIVED ONE. Every other field here grades + * SDK expressibility; none of them says whether a caller must be + * authenticated, and `public` states INTENT for a handful of browser-facing + * routes rather than measuring a gate. Deriving the answer from source + * syntax instead was measured and rejected: scanning all 80 + * `this.routeManager.register(` sites in `rest-server.ts` for `enforceAuth` + * reads 50 gated / 30 ungated, and 22 of those 30 are FALSE — a wrapping + * `guardedRouteManager` gates 19 of them with no `enforceAuth` at the call + * site, and one registrar shares a handler const across its 3 mounts. A 73% + * false-ungated rate on the largest registrar is a written-down false + * assurance, which is strictly worse than an honest blank. So the posture is + * DECLARED at the producer, where a new route is already reviewed, instead of + * guessed at the consumer. + * + * ABSENT MEANS "UNDECLARED", and that is the state of nearly the whole + * surface. This field is filled INCREMENTALLY, exactly like `responseSchema` + * above and for the same ruled reason: mass-producing declarations nobody + * validated is how "declared but unverified" surfaces come to exist. A blank + * one changes no behaviour and is not a defect. + * + * ⛔ DO NOT FILL A ROW THAT HAS NO CONFORMANCE COVERAGE. The rule the seeded + * rows were chosen by, and the one to keep applying: the matrix row named + * here must be `enforced`, its cited dogfood proof must DRIVE this route at a + * literal wire path (no parameter binding inferred from a sibling), and its + * enforcement text must name the site that serves it. A name written ahead of + * the test it points at would BE the surface this programme exists to remove. + * + * A NAME rather than a live reference, deliberately — this module stays + * import-free, and the resolution belongs in the guard that can import the + * vocabulary. `packages/qa/dogfood/test/authz-conformance.test.ts` resolves + * every name written here against the live matrix and refuses two things: a + * name that is not a row id (a typo, or a row renamed out from under it), and + * a row that is not `enforced` (an `experimental` or `removed` row records an + * ABSENCE, so pointing a route at one would declare "reviewed" over "there is + * nothing here"). + */ + authz?: string; /** One-line rationale. Required for every non-`sdk` disposition. */ note?: string; } @@ -128,7 +169,16 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ note: 'interactive Scalar HTML page' }, // ── metadata ────────────────────────────────────────────────────────────── - { route: 'GET /api/v1/meta', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getTypes' }, + // [2026-08-31] SEEDED under the field's fill rule: `anonymous-deny-meta` is + // `enforced`, its enforcement text names both the REST guarded registrar and + // the dispatcher mirror, and its cited proof + // (`showcase-anonymous-deny-surfaces.dogfood.test.ts`) drives THIS wire path + // literally on a booted showcase — anonymous 401, authenticated not-401 as + // the positive control. ⛔ The sibling rows in this family are deliberately + // left blank: the family-wide gate is a real property, but writing it onto + // 19 rows in one change is the mass production the field's rule forbids. + { route: 'GET /api/v1/meta', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getTypes', + authz: 'anonymous-deny-meta' }, // [#7526] The same `listMetaTypes` closure as the row above, at the spelling // the dispatcher branch and `route-ledger.ts` have always used. It is // `server-only` for the reason that ledger's row gives — Studio tooling @@ -149,7 +199,10 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'GET /api/v1/meta/_drafts', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.listDrafts', responseSchema: 'ListDraftsResponseSchema', note: '[#12038] on THIS surface the payload is answered BARE (`res.json(result)`); the dispatcher twin (runtime ledger row) answers the same payload through the `{ success, data }` envelope — the named schema is the PAYLOAD, true on both surfaces. Describe-only transcription of `listDrafts`\'s declared return; conformance: spec `api/protocol.test.ts`' }, + // [2026-08-31] SEEDED — same rule as `GET /api/v1/meta` above; this door is + // one of the five mutating /meta seams that proof drives by literal path. { route: 'POST /api/v1/meta/_migrate-stored', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.migrateStored', + authz: 'anonymous-deny-meta', note: 'ADR-0087 stored-row canonicalization (#4327); gated on `manage_metadata`, preview unless { apply: true }. DELIBERATELY UNBOUND (#12038 ruling 2C) — this row would name the schema, but the report\'s only named type, `StoredMigrationReport`, lives in `@objectstack/metadata-protocol` (unreachable from the spec/api namespace this field resolves against); a second declaration in spec would drift against the CLI rendering the same report. Answered BARE on this surface, enveloped on the dispatcher twin' }, { route: 'GET /api/v1/meta/:type', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItems' }, { route: 'GET /api/v1/meta/:type/:name/references', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getReferences', diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 97f399c957..56b7f93fd0 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -149,6 +149,47 @@ export interface RouteLedgerEntry { * resolution belongs in the guard that can import the spec, not in the data. */ responseSchema?: string; + /** + * The AUTHORIZATION posture this route has been REVIEWED to have, named by + * the `authz-conformance.matrix.ts` row that classifies it (ADR-0056 D10). + * + * WHY A DECLARED FACT AND NOT A DERIVED ONE. Every other field here grades + * SDK expressibility; none of them says whether a caller must be + * authenticated, and `public` states INTENT for a handful of browser-facing + * routes rather than measuring a gate. Deriving the answer from source + * syntax instead was measured and rejected: scanning all 80 + * `this.routeManager.register(` sites in `rest-server.ts` for `enforceAuth` + * reads 50 gated / 30 ungated, and 22 of those 30 are FALSE — a wrapping + * `guardedRouteManager` gates 19 of them with no `enforceAuth` at the call + * site, and one registrar shares a handler const across its 3 mounts. A 73% + * false-ungated rate on the largest registrar is a written-down false + * assurance, which is strictly worse than an honest blank. So the posture is + * DECLARED at the producer, where a new route is already reviewed, instead of + * guessed at the consumer. + * + * ABSENT MEANS "UNDECLARED", and that is the state of nearly the whole + * surface. This field is filled INCREMENTALLY, exactly like `responseSchema` + * above and for the same ruled reason: mass-producing declarations nobody + * validated is how "declared but unverified" surfaces come to exist. A blank + * one changes no behaviour and is not a defect. + * + * ⛔ DO NOT FILL A ROW THAT HAS NO CONFORMANCE COVERAGE. The rule the seeded + * rows were chosen by, and the one to keep applying: the matrix row named + * here must be `enforced`, its cited dogfood proof must DRIVE this route at a + * literal wire path (no parameter binding inferred from a sibling), and its + * enforcement text must name the site that serves it. A name written ahead of + * the test it points at would BE the surface this programme exists to remove. + * + * A NAME rather than a live reference, deliberately — this module stays + * import-free, and the resolution belongs in the guard that can import the + * vocabulary. `packages/qa/dogfood/test/authz-conformance.test.ts` resolves + * every name written here against the live matrix and refuses two things: a + * name that is not a row id (a typo, or a row renamed out from under it), and + * a row that is not `enforced` (an `experimental` or `removed` row records an + * ABSENCE, so pointing a route at one would declare "reviewed" over "there is + * nothing here"). + */ + authz?: string; /** One-line rationale. Required for every non-`sdk` disposition. */ note?: string; } @@ -360,7 +401,16 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ note: 'routes come from service-ai buildAIRoutes() at plugin start — service-ai is a Cloud/EE package in the `cloud` repo, so this repo cannot enumerate them and the dispatcher only proxies (or 404s "AI service is not configured"). Enumerated on the other side of that boundary since #3718: cloud packages/service-ai/src/ai-route-ledger.ts, whose conformance test drives client.ai.* against the table buildAIRoutes() really returns. The client now expresses that table — ai.chat / ai.chatStream / ai.complete / ai.models / ai.conversations.* — but do NOT read a `sdk` disposition into this row: it stays `dynamic` because THIS repo still cannot see the routes. An earlier note here claimed the client "expresses nlq/suggest/insights against the REST AI routes"; that was never verified and was FALSE — nothing has ever mounted those three paths, and both they and the methods calling them are gone (#3718)' }, // ── meta (legacy chain) ─────────────────────────────────────────────────── - { route: 'GET /meta', domain: '/meta', disposition: 'sdk', client: 'meta.getTypes' }, + // [2026-08-31] SEEDED under the field's fill rule: `anonymous-deny-meta` is + // `enforced`, its enforcement text names both the REST guarded registrar and + // the dispatcher mirror, and its cited proof + // (`showcase-anonymous-deny-surfaces.dogfood.test.ts`) drives THIS wire path + // literally on a booted showcase — anonymous 401, authenticated not-401 as + // the positive control. ⛔ The sibling rows in this family are deliberately + // left blank: the family-wide gate is a real property, but writing it onto + // 19 rows in one change is the mass production the field's rule forbids. + { route: 'GET /meta', domain: '/meta', disposition: 'sdk', client: 'meta.getTypes', + authz: 'anonymous-deny-meta' }, { route: 'GET /meta/types', domain: '/meta', disposition: 'server-only', note: 'richer types listing consumed by Studio tooling directly; client uses GET /meta' }, { route: 'GET /meta/:type', domain: '/meta', disposition: 'sdk', client: 'meta.getItems' }, { route: 'GET /meta/:type/:name', domain: '/meta', disposition: 'sdk', client: 'meta.getItem' }, @@ -372,7 +422,9 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'GET /meta/_drafts', domain: '/meta', disposition: 'sdk', client: 'meta.listDrafts', responseSchema: 'ListDraftsResponseSchema', note: '[#12038] enveloped on THIS surface — the named schema is the `data`; the REST twin answers the same payload BARE. Describe-only transcription of `listDrafts`\'s declared return; conformance: spec `api/protocol.test.ts`' }, + // [2026-08-31] SEEDED — same rule as `GET /meta` above. { route: 'POST /meta/_migrate-stored', domain: '/meta', disposition: 'sdk', client: 'meta.migrateStored', + authz: 'anonymous-deny-meta', note: 'ADR-0087 stored-row canonicalization (#4327); gated on `manage_metadata`, preview unless { apply: true }. DELIBERATELY UNBOUND (#12038 ruling 2C) — this row would name the schema, but the report\'s only named type, `StoredMigrationReport`, lives in `@objectstack/metadata-protocol` (unreachable from the spec/api namespace this field resolves against); a second declaration in spec would drift against the CLI rendering the same report. Enveloped on this surface, BARE on the REST twin' }, { route: 'GET /meta/object/:name/state/:field', domain: '/meta', disposition: 'sdk', client: 'meta.getLegalNextStates', note: '#9180 step 2 moved the SDK to the singular spelling and retired the plural REST registration; this row follows the client. DELIBERATE ASYMMETRY, not residue nobody has got to yet: the legacy if-chain branch in `domains/meta.ts` still matches BOTH literals (`objects` and `object`), so `/meta/objects/:name/state/:field` is REFUSED by a REST-fronted deployment (transport 404 — no registration left to match it) and ANSWERED wherever `dispatch()` is the front door (the `createHonoApp` catch-all, the documented embed shape). It stays by the maintainer re-weigh of the #9180 ruling, 2026-08-17 item 3: the tolerance is kept for external callers, no new refusals beyond what step 1 shipped, the external break deferred with no scheduled window — narrowing this arm is a NEW refusal on a SECOND surface and is the maintainer call, not a step of the ruling. ⛔ It is NOT the `META_URL_TO_SINGULAR` fold whose retirement was deferred: that is a map consulted for `/meta/:type`, this is a literal `||` that no request reaches through the fold — separate mechanisms under separate decisions, and conflating them is the specific error to avoid. So this row lists the canonical spelling of a branch that answers two, and `domains/meta-state-plural-tolerance.test.ts` pins BOTH halves so this note cannot quietly stop being true (#10179)' }, @@ -387,7 +439,15 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ // ── mcp ─────────────────────────────────────────────────────────────────── { route: 'GET /mcp/skill', domain: '/mcp/skill', disposition: 'server-only', note: 'public SKILL.md for agents; not JS-SDK surface' }, - { route: '* /mcp/**', domain: '/mcp', disposition: 'server-only', note: 'MCP Streamable HTTP transport — consumed by MCP clients, not this SDK' }, + // [2026-08-31] SEEDED, and the cleanest case in either ledger: `/mcp` is a + // one-row domain, so the family-granular classification is route-granular + // here with nothing inferred from a sibling. `mcp-http-identity` is + // `enforced`, names `handleMcp` as its enforcement site, and its cited proof + // drives POST /api/v1/mcp end to end (anonymous 401 before any tool runs; a + // member's tool call RLS-scoped as the positive control). + { route: '* /mcp/**', domain: '/mcp', disposition: 'server-only', + authz: 'mcp-http-identity', + note: 'MCP Streamable HTTP transport — consumed by MCP clients, not this SDK' }, // ── actions ─────────────────────────────────────────────────────────────── { route: 'POST /actions/:object/:action', domain: '/actions', disposition: 'sdk', client: 'actions.invoke' }, From 9cf6880e01dbdf23934a667af313d938bd20e339 Mon Sep 17 00:00:00 2001 From: os-steve Date: Mon, 31 Aug 2026 10:41:21 +0000 Subject: [PATCH 2/3] fix(qa): strip the tracker id from the census note string --- packages/qa/dogfood/test/authz-conformance.test.ts | 2 +- packages/qa/dogfood/test/authz-probe-blind-spot.census.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 5328788d91..6bdea819fc 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -862,7 +862,7 @@ describe('the ledger-sourced population and its baseline bite', () => { expect(problems.some((p) => p.includes('UNCLASSIFIED surface') && p.includes(fresh))).toBe(true); }); - it('a NEW dispatcher domain is UNCLASSIFIED too — the #13376 file-selection layer', () => { + it('a NEW dispatcher domain is UNCLASSIFIED too — the file-selection layer', () => { // A domain file no probe names used to emit NOTHING: no key, no STALE, no // UNCLASSIFIED. Its absence was structurally unobservable. It is not now. const fresh = 'dispatcher-domain:route-ledger.ts:/webhooks'; diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index 3e59f2bd85..d2e30617c9 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -299,7 +299,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'The dispatcher half. Its machine contract is DOMAIN-level by live registry introspection ' + '(domainRegistry.list()), guarded in BOTH directions by route-ledger.conformance.test.ts: every ' + 'registered domain needs a row, and every ledger domain must be a live prefix or a pinned legacy / ' + - 'non-dispatch branch. That two-way guard is what settles the #13376 file-selection layer by ' + + 'non-dispatch branch. That two-way guard is what settles the FILE-SELECTION layer by ' + 'construction — all 16 DomainRoute prefixes declared across the 15 domain files that declare one are ' + 'ledger domains today, including the 11 files no probe has ever named. 21 domains; 5 classified ' + '(/meta, /actions, /automation, /packages, /mcp), 16 in the shrink-only baseline.', From 1c24f47fdb04e57c2e61e5bf3c91109e729572e5 Mon Sep 17 00:00:00 2001 From: os-steve Date: Mon, 31 Aug 2026 10:43:55 +0000 Subject: [PATCH 3/3] chore: changeset for the route-ledger authz disposition field --- .../route-ledger-authz-disposition-field.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/route-ledger-authz-disposition-field.md diff --git a/.changeset/route-ledger-authz-disposition-field.md b/.changeset/route-ledger-authz-disposition-field.md new file mode 100644 index 0000000000..0db41b0e68 --- /dev/null +++ b/.changeset/route-ledger-authz-disposition-field.md @@ -0,0 +1,17 @@ +--- +'@objectstack/rest': patch +'@objectstack/runtime': patch +--- + +Route ledger rows can declare their reviewed authorization posture + +`RestRouteLedgerEntry` and `RouteLedgerEntry` gain an optional `authz` field +naming the authorization-conformance row that classifies the route. It is +phased exactly like `responseSchema` in the same two files: optional, filled +only where conformance coverage already exists, never mass-produced. Five rows +are seeded; the rest stay undeclared. + +Both modules are package-internal (neither type reaches either package's +published `.d.ts`), and nothing reads the field at runtime — the authorization +conformance ratchet resolves every declaration against the live matrix and +refuses a name that is not an `enforced` row.