From 246495a7aa23ece1bf57e4141b64668181617f47 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 11:18:30 +0000 Subject: [PATCH] test(qa): declare each authz probe's instrument kind and fail a probe that lost its population MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authz-conformance ratchet had a second blind-spot mechanism that neither of its two signals can reach. UNCLASSIFIED fires on a key that APPEARS; STALE fires on a `covers` key that DISAPPEARS. Both are keyed on a key, so a probe that stops minting a key no row ever covered produces neither signal and reads exactly like a healthy one. Each `PROBES` entry now declares its instrument kind (ROUTE_ENUMERATION / GATE_PIN / TRIPWIRE), and a non-tripwire probe minting zero keys fails as a DEAD PROBE. The raw-hono `/data` probe — whose population commit e5a4d26901 deleted on 2026-07-31, 3 matching mounts to 0 — is re-declared TRIPWIRE with the dated evidence rather than deleted, so the gate keeps watching that file. The matrix header's probe count is corrected 15 to 16 and its census pin is INVERTED from "these must differ" (drift recorded) to "these must be equal" (drift closed). The three-kind taxonomy moves out of census prose into the probe table as data, is read back by the census derivation, and is asserted. The route population is deliberately UNTOUCHED: no probe added, removed or widened. The measured reading on where it should come from is recorded in the census header instead — the route ledgers cover the routes but carry no notion of "gated", a syntactic gate reading is 73% false-ungated on rest-server.ts, and a ledger lags source by one guarded step. That is a contract decision, not a repair. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k --- .../dogfood/test/authz-conformance.matrix.ts | 19 +- .../qa/dogfood/test/authz-conformance.test.ts | 227 +++++++++++++++++- .../test/authz-probe-blind-spot.census.ts | 169 ++++++++++--- .../test/authz-probe-blind-spot.test.ts | 38 ++- 4 files changed, 410 insertions(+), 43 deletions(-) diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 5d7eff5355..fc92841ce3 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -9,8 +9,25 @@ // 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()`: 15 probes over 11 named source files) — a new ungated route +// (`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 +// (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). // [#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 diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index eb971673cb..15fdb0a6a7 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -74,9 +74,48 @@ const tripwireKey = (surface: string): string => `${surface}(${TRANSPORT_WIRED_M // → new key → a missing `covers` fails CI. Keys are derived from source // CONTENT (route literals / handler names), never line numbers, so they don't // churn on edits. -const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray) => string }> = [ +// ── the three INSTRUMENT KINDS, declared per probe ──────────────────────── +// +// The table mixes three instruments with three different promises, and reading +// them with one number is the mistake this declaration exists to prevent: it +// reads working tripwires as holes and gate pins as route enumerations. The +// taxonomy was established by the reach census +// (`authz-probe-blind-spot.census.ts`) and lived there as PROSE; it lives here +// now, as data on the probe it describes, because a comment cannot be checked +// and a `kind` can. +// +// ROUTE_ENUMERATION — pattern-based over a route/handler population. Its +// stated promise is auto-discovery: "ANY new route matching this pattern +// mints a new key". For these, entry points outside every mintable key +// ARE the blind spot. +// GATE_PIN — the key exists only while a NAMED gate call still exists in the +// file (`shouldDenyAnonymous(`, `buildMcpBridge(deps, context)`, +// `resolveStdioExecutionContext(`). It mints exactly one key by +// construction; the promise is ANTI-REGRESSION on that one gate, never +// route completeness. Zero keys here means "this file funnels through the +// pinned gate", NOT "every route here is enumerated". +// TRIPWIRE — deliberately matches nothing today. Zero keys is the CORRECT +// reading, not a gap; armed alarms are not holes. +// +// What the declaration BUYS, beyond saying so: `checkProbeInstrumentIntegrity` +// below turns it into a checked claim. A ROUTE_ENUMERATION or GATE_PIN probe +// that mints ZERO keys has lost the population it promises to watch, and that +// is now RED instead of silent — the second, independent blind-spot mechanism +// (see that function's header for the measured instance). +type ProbeKind = 'ROUTE_ENUMERATION' | 'GATE_PIN' | 'TRIPWIRE'; + +interface Probe { + /** Which instrument this is. DECLARED, never inferred from the pattern. */ + kind: ProbeKind; + file: string; + re: RegExp; + key: (m: RegExpExecArray) => string; +} + +const PROBES: readonly Probe[] = [ // REST /meta umbrella registrar — one guarded registrar covers all ~17 routes. { + kind: 'ROUTE_ENUMERATION', file: 'packages/rest/src/rest-server.ts', re: /private\s+registerMetadataEndpoints\s*\(/g, key: () => 'meta:rest-server.ts:registerMetadataEndpoints', @@ -84,6 +123,7 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray // Dispatcher meta handler — curated NAME only (NOT handleAI / // handleData / handleSecurity, which are separate surfaces/rows). { + kind: 'ROUTE_ENUMERATION', file: 'packages/runtime/src/http-dispatcher.ts', re: /async\s+(handleMetadata)\s*\(/g, key: (m) => `meta:http-dispatcher.ts:${m[1]}`, @@ -97,11 +137,13 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray // path from the `@objectstack/rest` one that gates `/data` and `/meta`, which // is exactly why they diverged unnoticed. { + kind: 'GATE_PIN', file: 'packages/runtime/src/domains/actions.ts', re: /shouldDenyAnonymous\s*\(/g, key: () => 'actions:domains/actions.ts:anonymous-gate', }, { + kind: 'GATE_PIN', file: 'packages/runtime/src/domains/automation.ts', re: /shouldDenyAnonymous\s*\(/g, key: () => 'automation:domains/automation.ts:anonymous-gate', @@ -111,14 +153,42 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray // `shouldDenyAnonymous`. Delete the domain floor and the key vanishes → the // covering `anonymous-deny-packages` row goes STALE → red CI. { + kind: 'GATE_PIN', file: 'packages/runtime/src/domains/packages.ts', re: /shouldDenyAnonymous\s*\(/g, key: () => 'packages:domains/packages.ts:anonymous-gate', }, - // Raw-hono standard /data routes — genuinely pattern-based: ANY new - // `rawApp.(`${prefix}/data...`)` → a new key → CI fails until a row covers it. + // ── a probe whose POPULATION WAS DELETED, re-declared for what it is ──── + // + // This probe once read as ROUTE_ENUMERATION and its comment claimed live + // discovery: "ANY new `rawApp.(`${prefix}/data...`)` mints a new key". + // That spelling occurs ZERO times in the file and has since 2026-07-31, when + // commit e5a4d26901 deleted the plugin CRUD/discovery surface — 3 matching + // mounts before, 0 after. The probe stayed behind and has minted nothing + // since, IN SILENCE, because STALE fires only for a key some matrix row + // `covers` and no row ever covered a `data:hono-plugin.ts` key. + // + // ⚠️ It is re-declared TRIPWIRE, NOT deleted, and the difference matters: + // deleting it would make this gate see LESS. As a declared tripwire its zero + // is a CHECKED reading rather than an accident — the census carries a + // positive control from this same file so a zero from a moved or emptied + // file cannot pass as "nothing found" — and the day a `/data` route is + // mounted here again the key appears and the surface is UNCLASSIFIED, which + // is exactly a tripwire's promise. + // + // ⛔ Its key deliberately carries NO `TRANSPORT-WIRED` marker, so the + // admission rule below does NOT apply to it. That rule is the realtime + // vocabulary's, and it demands a per-recipient DELIVERY authorization site; + // a re-mounted `/data` route is an ordinary data surface, and holding it to + // a realtime remedy would be the wrong checklist on a red. + // + // ⚠️ The live spelling this pattern watches for exists one file away, in + // `current-user-endpoints.ts` (3 mounts, none of them `/data`), which this + // table does not name. Naming it is a POPULATION decision and is deliberately + // not taken here — see the census's population-source record. { + kind: 'TRIPWIRE', file: 'packages/plugins/plugin-hono-server/src/hono-plugin.ts', re: /rawApp\.(get|post|put|patch|delete)\(\s*`\$\{prefix\}(\/data[^`]*)`/g, key: (m) => `data:hono-plugin.ts:${m[1].toUpperCase()} ${m[2]}`, @@ -128,6 +198,7 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray // Realtime delivery fan-out: pins the trusted-internal-only posture of the // in-memory adapter's publish loop (`realtime-delivery-authz` row). { + kind: 'GATE_PIN', file: 'packages/services/service-realtime/src/in-memory-realtime-adapter.ts', re: /async\s+publish\s*\(/g, key: () => 'realtime:in-memory-realtime-adapter.ts:publish(trusted-fan-out)', @@ -148,21 +219,25 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray // classified by a row that is not `enforced`, so the shortest path from red // back to green runs through an enforcement site rather than around it. { + kind: 'TRIPWIRE', file: 'packages/services/service-realtime/src/in-memory-realtime-adapter.ts', re: /handleUpgrade\s*\(/g, key: () => tripwireKey('realtime:in-memory-realtime-adapter.ts:handleUpgrade'), }, { + kind: 'TRIPWIRE', file: 'packages/services/service-realtime/src/realtime-service-plugin.ts', re: /handleUpgrade\s*\(|new\s+WebSocketServer|text\/event-stream/g, key: () => tripwireKey('realtime:realtime-service-plugin.ts:transport'), }, { + kind: 'TRIPWIRE', file: 'packages/runtime/src/http-dispatcher.ts', re: /async\s+handle(Realtime|Upgrade|Subscribe)\w*\s*\(/g, key: (m) => tripwireKey(`realtime:http-dispatcher.ts:handle${m[1]}`), }, { + kind: 'TRIPWIRE', file: 'packages/client/src/realtime-api.ts', re: /new\s+WebSocket\b|new\s+EventSource\b/g, key: () => tripwireKey('realtime:client/realtime-api.ts:transport'), @@ -212,11 +287,13 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray // widened to the documented literal `/api/v1/stream` would have stayed just // as blind as the one it replaced. { + kind: 'TRIPWIRE', file: 'packages/rest/src/rest-server.ts', re: /handleUpgrade\s*\(|new\s+WebSocketServer\b|new\s+WebSocket\b|new\s+EventSource\b|upgradeWebSocket\b|WebSocketPair\b|Sec-WebSocket-|text\/event-stream|streamSSE\s*\(/g, key: () => tripwireKey('realtime:rest-server.ts:transport'), }, { + kind: 'TRIPWIRE', file: 'packages/rest/src/rest-server.ts', // Word-boundary lookaheads on `ws`/`stream` keep the file's 31 unrelated // response-streaming references (and paths like `/workspaces`) out: they @@ -266,11 +343,13 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray // Drop the threading (or build a system/unscoped bridge for HTTP) → the // context-threaded key vanishes → the mcp-http-identity row goes STALE → red CI. { + kind: 'ROUTE_ENUMERATION', file: 'packages/runtime/src/http-dispatcher.ts', re: /async\s+handleMcp\s*\(/g, key: () => 'mcp:http-dispatcher.ts:handleMcp', }, { + kind: 'GATE_PIN', file: 'packages/runtime/src/domains/mcp.ts', re: /buildMcpBridge\(deps, context\)/g, key: () => 'mcp:domains/mcp.ts:buildMcpBridge(context-threaded)', @@ -281,25 +360,87 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray // reader. Dropping that resolution (reverting to a raw/unscoped bridge) makes // this key vanish → the mcp-stdio-authority row goes STALE → red CI. { + kind: 'GATE_PIN', file: 'packages/mcp/src/plugin.ts', re: /resolveStdioExecutionContext\s*\(/g, key: () => 'mcp:plugin.ts:stdio-principal-bound', }, ]; +/** + * Keys ONE probe mints against today's source. + * + * Split out from the walk below so per-probe reach is measurable on its own. + * The union is what the ratchet classifies; the per-probe count is what says + * whether an instrument is still pointed at anything, and a union cannot + * answer that — a probe that has gone blind contributes nothing to the union + * and is indistinguishable, there, from a probe that never existed. + */ +function keysMintedBy(probe: Probe): Set { + const src = readFileSync(join(REPO_ROOT, probe.file), 'utf8'); + // Fresh lastIndex per read (the RegExp is shared, `g`-flagged). + probe.re.lastIndex = 0; + const found = new Set(); + let m: RegExpExecArray | null; + while ((m = probe.re.exec(src)) !== null) found.add(probe.key(m)); + return found; +} + /** Statically enumerate the anonymous-deny HTTP entry points from source. */ function discoverAnonymousDenySurfaces(): Set { const found = new Set(); - for (const probe of PROBES) { - const src = readFileSync(join(REPO_ROOT, probe.file), 'utf8'); - // Fresh lastIndex per file (the RegExp is shared, `g`-flagged). - probe.re.lastIndex = 0; - let m: RegExpExecArray | null; - while ((m = probe.re.exec(src)) !== null) found.add(probe.key(m)); - } + for (const probe of PROBES) for (const k of keysMintedBy(probe)) found.add(k); return found; } +// ── a probe whose POPULATION was deleted must not fail SILENTLY ─────────── +// +// The second, independent blind-spot mechanism — and the one the surface +// ratchet structurally cannot catch. UNCLASSIFIED fires on a key that APPEARS; +// STALE fires on a `covers` key that DISAPPEARS. Both are keyed on a KEY. A +// probe that stops minting a key it never had a `covers` row for produces +// neither signal: no key appears, no `covers` goes stale, and the table keeps +// a dead instrument that reads exactly like a healthy one. +// +// Measured instance: the raw-hono `/data` probe above watched a spelling that +// commit e5a4d26901 (2026-07-31) deleted — 3 matching mounts before, 0 after — +// and said nothing for the entire time since, because no row had ever covered +// a `data:hono-plugin.ts` key. +// +// The check is the declared `kind`, applied: +// +// ROUTE_ENUMERATION / GATE_PIN — both promise a population that EXISTS. One +// promises to enumerate a route family, the other to hold a named gate in +// place. Either minting zero keys means the thing it watches is gone from +// the file, and that is now a failure rather than silence. +// TRIPWIRE — exempt BY DECLARATION, because matching nothing is its whole +// job. That exemption is exactly why the kind is declared per probe rather +// than guessed from the pattern: it must be a written, reviewable claim, +// not something an instrument infers about itself. +// +// ⛔ The exemption is not an escape hatch for a probe that has gone blind. +// Re-declaring a ROUTE_ENUMERATION probe as TRIPWIRE to clear a red is only +// honest when its population really is gone AND the zero is backed by a +// positive control from that same file (the reach census holds one per file) — +// the hono probe above is written that way, dated evidence and all. +function checkProbeInstrumentIntegrity(probes: readonly Probe[]): string[] { + const problems: string[] = []; + for (const probe of probes) { + if (probe.kind === 'TRIPWIRE') continue; + if (keysMintedBy(probe).size > 0) continue; + problems.push( + `DEAD PROBE — ${probe.file}: a ${probe.kind} probe minting ZERO keys. ` + + 'Its population is gone from that file, and neither UNCLASSIFIED nor STALE can say so ' + + '(both are keyed on a key: one that appears, one that disappears). ' + + 'Three honest exits, and no fourth: repoint the probe at the surface that replaced the ' + + 'deleted one; re-declare it TRIPWIRE if the population really is gone, with the dated ' + + 'evidence and a positive control from that same file; or delete the probe TOGETHER WITH ' + + 'the surface it watched. Silence is what this check exists to remove.', + ); + } + 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 @@ -401,6 +542,72 @@ describe('ADR-0056 D10 — authorization conformance matrix', () => { }); expect(problems, problems.join('\n')).toEqual([]); }); + + it('every non-tripwire probe is still pointed at a population that exists', () => { + // The dead-probe check, on the REAL table and the REAL sources. It is a + // separate `it` rather than a member of `checkAuthzLedger` on purpose: + // `checkAuthzLedger` grades ROWS against a supplied `discover`, and every + // controlled-input case below hands it a synthetic one. Folding a + // source-reading probe check into it would make those cases depend on the + // repo's real files for a property none of them is about. + const problems = checkProbeInstrumentIntegrity(PROBES); + expect(problems, problems.join('\n')).toEqual([]); + }); +}); + +// ── the dead-probe check BITES, and is not vacuous ──────────────────────── +// +// Same discipline as every other block here: a check that has never been shown +// to fail is not evidence. Each case drives `checkProbeInstrumentIntegrity` +// with synthetic probes, so it is deterministic and needs no source edits. +describe('a probe that has lost its population is caught, not silent', () => { + const REAL_FILE = 'packages/rest/src/rest-server.ts'; + // A pattern certain to be absent from that file, standing in for a spelling + // whose surface was deleted. Paired with a live pattern below, so a zero here + // is a reading about the PATTERN and not about a missing file. + const ABSENT = /zzz_no_such_spelling_zzz/g; + const PRESENT = /private\s+registerMetadataEndpoints\s*\(/g; + + it('CONTROL — the file is readable and the live pattern fires (the zero is a reading)', () => { + expect(keysMintedBy({ kind: 'ROUTE_ENUMERATION', file: REAL_FILE, re: PRESENT, key: () => 'k' }).size) + .toBeGreaterThan(0); + expect(keysMintedBy({ kind: 'ROUTE_ENUMERATION', file: REAL_FILE, re: ABSENT, key: () => 'k' }).size) + .toBe(0); + }); + + it('a ROUTE_ENUMERATION probe minting zero keys is a DEAD PROBE', () => { + const problems = checkProbeInstrumentIntegrity([ + { kind: 'ROUTE_ENUMERATION', file: REAL_FILE, re: ABSENT, key: () => 'k' }, + ]); + expect(problems.some((p) => /DEAD PROBE/.test(p) && p.includes(REAL_FILE))).toBe(true); + }); + + it('a GATE_PIN whose named gate vanished is caught even when NO row covers it', () => { + // The exact hole: STALE needs a `covers` key to fire against. A gate pin + // nobody classified loses its gate in total silence under the old gate, + // and this is the check that speaks instead. + const key = 'gate:no-row-covers-this-one'; + expect(AUTHZ_CONFORMANCE.flatMap((r) => r.covers ?? [])).not.toContain(key); + const problems = checkProbeInstrumentIntegrity([ + { kind: 'GATE_PIN', file: REAL_FILE, re: ABSENT, key: () => key }, + ]); + expect(problems.some((p) => /DEAD PROBE/.test(p))).toBe(true); + }); + + it('a TRIPWIRE minting zero keys is CORRECT and stays silent', () => { + // The other direction, and the one a uniform count gets wrong: six armed + // alarms are not six holes. + expect(checkProbeInstrumentIntegrity([ + { kind: 'TRIPWIRE', file: REAL_FILE, re: ABSENT, key: () => 'k' }, + ])).toEqual([]); + }); + + it('the real table is all three kinds — the check is not passing for lack of subjects', () => { + const kinds = new Set(PROBES.map((p) => p.kind)); + expect([...kinds].sort()).toEqual(['GATE_PIN', 'ROUTE_ENUMERATION', 'TRIPWIRE']); + // …and it really is grading something: the exempt kind is not the whole table. + expect(PROBES.filter((p) => p.kind !== 'TRIPWIRE').length).toBeGreaterThan(0); + }); }); // #2567 — prove the ratchet actually BITES. Drives `checkLedger` with controlled 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 6aa10534e9..68c870e32d 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -24,6 +24,15 @@ // Reading every probe as "route discovery" is the mistake this file exists to // prevent; the table mixes three instruments with three different promises. // +// ⭐ These kinds are no longer prose. Each `PROBES` entry now DECLARES its +// `kind`, `deriveProbeFileCensus()` reads the declarations back out of the +// companion test's source, and the census test holds the `kinds` recorded +// below equal to them — so the taxonomy is a checked artifact rather than a +// comment two files away from the thing it describes. The declaration also +// earns its keep at runtime: a ROUTE_ENUMERATION or GATE_PIN probe minting +// ZERO keys now fails as a DEAD PROBE, which is the mechanism row 7 below was +// measured losing. +// // ROUTE_ENUMERATION — the probe is pattern-based over a route/handler // population, and its stated promise is auto-discovery: "ANY new route // matching this pattern mints a new key". For these, "entry points outside @@ -72,18 +81,76 @@ // protocol). The static reading is the one pinned because it is the one this // package can re-derive without depending on `@objectstack/rest`. // -// ── A COMPLETE ROUTE ENUMERATION ALREADY EXISTS ELSEWHERE ───────────────── +// ── THE POPULATION SOURCE: measured, and still an OPEN CONTRACT DECISION ── +// +// 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 +// anything was written. The reading, in full, because it is the kind of +// conclusion that gets re-derived from scratch otherwise: +// +// WHAT THE LEDGERS DO COVER — richly, and more than this table ever has. +// `packages/rest/src/rest-route-ledger.ts`: 94 audited rows over 19 families, +// every route `@objectstack/rest` mounts, enumerated through +// `RestServer.getRoutes()` on a booted server and guarded per route by +// `rest-route-ledger.conformance.test.ts`. It reaches all 17 registrars; +// this table reaches 1. +// `packages/runtime/src/route-ledger.ts`: 80 rows over 21 domains. Its +// machine contract is DOMAIN-level, by live registry introspection +// (`domainRegistry.list()`), the per-route rows being documentation. It +// covers all 15 `async handle*(` methods in `http-dispatcher.ts` and all +// 16 `DomainRoute` prefixes declared by the 15 domain files. +// Nine more ledgers exist repo-wide (290 rows in total). +// +// ⭐ On FILE SELECTION the ledgers are simply the right answer, and that is +// worth stating separately: a domain file that no probe names emits no signal +// at all today — no key, no STALE, no UNCLASSIFIED — so its absence is +// structurally unobservable, and a probe table naming 4 of 17 domain files +// cannot see the other 13. Ledger domains are enumerated from the LIVE +// registry, so a new domain file cannot be silently absent from them. +// +// ⛔ WHAT THEY CANNOT SUPPLY IS THIS RATCHET'S GUARANTEE. Three measured +// blockers, each independently sufficient: +// +// 1. NO NOTION OF "GATED", and this ratchet's promise is about UNGATED +// routes. Ledger dispositions grade SDK expressibility, not authorization: +// REST reads `sdk` 84 / `server-only` 7 / `public` 3. Cross-checked +// directly rather than assumed — of the 8 REST route mounts measured to +// carry no `enforceAuth`, the ledger grades 3 `server-only`, 3 `public` +// and 2 `sdk`; and one of those two `sdk` rows is +// `GET /api/v1/ui/view/:object/:type`, the single route in this whole +// population ever measured unguarded. Its ledger row is shape-identical to +// the 83 `sdk` rows that ARE gated. `public` states INTENT for 3 +// browser-facing form routes; it is not a gate measurement and was never +// built as one. +// +// 2. DERIVING "gated" FROM SOURCE SYNTAX IS UNSAFE — measured, not assumed. +// Scanning each of the 80 `this.routeManager.register(` call sites in +// `rest-server.ts` for `enforceAuth` reads 50 gated / 30 ungated, and 22 of +// those 30 are FALSE, in two structural shapes: `registerMetadataEndpoints` +// installs a wrapping `guardedRouteManager` so its 19 inner routes are +// gated with no `enforceAuth` at the call site, and +// `registerSecurityExplainEndpoints` shares one `handler` const declared +// outside its 3 `register(` calls. A 73% false-ungated rate, concentrated +// on the largest registrar, and hand-annotating the exceptions is the same +// rot this instrument already has. +// +// 3. A LEDGER IS A DERIVED DATA FILE, ONE GUARDED STEP BEHIND THE SOURCE. +// Adding a route to a registrar in `rest-server.ts` does not touch +// `rest-route-ledger.ts`, so a ledger-sourced population mints no new key +// and this ratchet stays GREEN on exactly the mutation it should catch. +// The red lands in `rest-route-ledger.conformance.test.ts` instead — a +// different gate, in a different package, promising something else. A +// COMPOSED guarantee (no route without a ledger row; no ledger row without +// a classification) is a defensible design, but it is a different promise +// from the one this matrix header states, and adopting it is a contract +// decision rather than a repair. // -// Recorded because the repair this census sizes should start from it rather -// than from a wider regex: `packages/rest/src/rest-route-ledger.ts` holds 94 -// audited rows over every route `@objectstack/rest` mounts, enumerated through -// `RestServer.getRoutes()` and guarded by `rest-route-ledger.conformance.test.ts` -// (measured green, 7/7, at the time of writing), and -// `packages/runtime/src/route-ledger.ts` does the same for the dispatcher with -// 80 rows. The authz ratchet's route population is a regex table that reaches 1 -// of 17 registrars; a complete, runtime-derived, already-guarded enumeration of -// the same surface is sitting one package away. Choosing between them is the -// follow-up card's decision, not this file's. +// ⇒ ⛔ 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. import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -136,14 +203,23 @@ export interface ProbeTableReading { export const PROBE_TABLE: ProbeTableReading = { entries: 16, files: 11, keys: 9 }; /** - * ⚠️ RECORDED DRIFT, deliberately not repaired here. `authz-conformance.matrix.ts`'s - * header says "`discover()`: 15 probes over 11 named source files". The table - * has held 16 entries since a 16th probe landed on 2026-08-18 without the prose - * moving; the "15" was accurate when written two days earlier. Pinned so the - * next author meets the discrepancy instead of inheriting it — this card is a - * measurement, and correcting the sentence belongs to whoever repairs the probe. + * The probe count `authz-conformance.matrix.ts`'s header states. + * + * ⭐ REPAIRED, and the pin INVERTED with it. This was recorded drift: the + * header said "15 probes" from 2026-08-16, when it was true, and the table has + * held 16 since a 16th probe landed on 2026-08-18 without the sentence moving. + * A measurement pinned the discrepancy deliberately rather than fixing it, so + * that its own no-repair fence stayed unambiguous; the repair belonged with + * whoever touched the probe table, and this is that change. + * + * ⚠️ The assertion that reads this constant flipped from "these must DIFFER" + * (drift pinned) to "these must be EQUAL" (drift closed). That inversion is + * the point: the sentence is now held equal to the table by a test, so the + * next probe added without moving the prose is RED instead of a fact recorded + * 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 = 15; +export const MATRIX_HEADER_PROBE_CLAIM = 16; export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ { @@ -230,7 +306,11 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ }, { file: 'packages/plugins/plugin-hono-server/src/hono-plugin.ts', - kinds: ['ROUTE_ENUMERATION'], + // Re-declared from ROUTE_ENUMERATION: the probe's population was deleted + // and the probe stayed, which is the dead-probe mechanism this census + // measured. It is re-aimed as an armed tripwire, NOT deleted — deleting it + // would make the ratchet see less. + kinds: ['TRIPWIRE'], probes: 1, keys: 0, population: 6, @@ -239,13 +319,17 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ populationRule: '`rawApp.(` mount sites', controls: { rawApp: 11, 'rawApp.get(': 3, serveStatic: 3 }, note: - 'A DEAD PROBE, dated. Its comment claims live discovery ("ANY new rawApp.(`${prefix}/data...`) ' + - 'mints a new key"), but that spelling occurs ZERO times in this file: commit e5a4d26901 (2026-07-31) ' + - 'deleted the plugin CRUD/discovery surface — 3 matching mounts before, 0 after — and the probe stayed. ' + - 'It has minted nothing since, in silence, because STALE only fires for a key some row COVERS and no row ' + - 'ever covered a data:hono-plugin.ts key. The spelling it watches is alive one file away, in ' + - 'current-user-endpoints.ts (3 mounts, none of them /data), which the table does not name. The 6 mounts ' + - 'counted here are middleware, static-asset and SPA-fallback routes, not data surfaces.', + 'WAS a dead probe, dated; now a declared TRIPWIRE. Its comment claimed live discovery ("ANY new ' + + 'rawApp.(`${prefix}/data...`) mints a new key"), but that spelling occurs ZERO times in this ' + + 'file: commit e5a4d26901 (2026-07-31) deleted the plugin CRUD/discovery surface — 3 matching mounts ' + + 'before, 0 after — and the probe stayed. It minted nothing for the whole time since, in silence, ' + + 'because STALE only fires for a key some row COVERS and no row ever covered a data:hono-plugin.ts key. ' + + 'That silence is now closed generically: a non-tripwire probe minting zero keys fails as a DEAD PROBE, ' + + 'so this probe had to be either repaired or honestly re-declared, and re-declaring is what its measured ' + + 'population supports. The spelling it watches is alive one file away, in current-user-endpoints.ts ' + + '(3 mounts, none of them /data), which the table does not name — naming it is a POPULATION decision, ' + + 'recorded below and not taken here. The 6 mounts counted here are middleware, static-asset and ' + + 'SPA-fallback routes, not data surfaces.', }, { file: 'packages/services/service-realtime/src/in-memory-realtime-adapter.ts', @@ -317,8 +401,19 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ export const BLIND_SPOT_TOTAL_STATIC = 75; export const BLIND_SPOT_TOTAL_RUNTIME = 80; -/** Re-measure every row above from the same sources the probes read. */ -export function deriveProbeFileCensus(): { table: ProbeTableReading; files: Map }> } { +/** + * Re-measure every row above from the same sources the probes read. + * + * `kinds` comes back from the companion test's OWN source: each `PROBES` entry + * declares its instrument kind, and reading the declarations back is what makes + * the taxonomy recorded above a checked artifact instead of a comment that can + * quietly stop describing the table. + */ +export function deriveProbeFileCensus(): { + table: ProbeTableReading; + files: Map }>; + kinds: Map; +} { const files = new Map }>(); // ── rest-server.ts ────────────────────────────────────────────────────── @@ -427,6 +522,23 @@ export function deriveProbeFileCensus(): { table: ProbeTableReading; files: Map< const to = testSrc.indexOf('\n];', from); const block = testSrc.slice(from, to); const probeFiles = [...block.matchAll(/^\s*file: '([^']+)'/gm)].map((m) => m[1]); + + // Kind ↔ file pairing, read in DOCUMENT ORDER. Every entry spells `kind` + // immediately before `file`, so walking both tokens in order pairs them + // without parsing TypeScript. The pairing is self-checking: if the two counts + // ever disagree, an entry is missing one of them and the census test's + // `entries` assertion catches it rather than a silently short map. + const kinds = new Map(); + const tokens = [...block.matchAll(/^\s*(kind|file): '([^']+)'/gm)]; + let pendingKind: ProbeKind | undefined; + for (const t of tokens) { + if (t[1] === 'kind') { pendingKind = t[2] as ProbeKind; continue; } + const file = t[2]; + const list = kinds.get(file) ?? []; + if (pendingKind && !list.includes(pendingKind)) list.push(pendingKind); + kinds.set(file, list.sort()); + pendingKind = undefined; + } const matrixSrc = readFileSync(join(HERE, 'authz-conformance.matrix.ts'), 'utf8'); const coverKeys = [...matrixSrc.matchAll(/covers: \[([^\]]*)\]/g)] .flatMap((m) => [...m[1].matchAll(/'([^']+)'/g)].map((k) => k[1])); @@ -434,5 +546,6 @@ export function deriveProbeFileCensus(): { table: ProbeTableReading; files: Map< return { table: { entries: probeFiles.length, files: new Set(probeFiles).size, keys: new Set(coverKeys).size }, files, + kinds, }; } 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 63590318a8..c45af3cfd5 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.test.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.test.ts @@ -91,14 +91,44 @@ describe('authz probe blind-spot census (#13260)', () => { expect(BLIND_SPOT_TOTAL_RUNTIME - BLIND_SPOT_TOTAL_STATIC).toBe(5); }); - it('the matrix header probe count is still the one the census recorded as drifted', () => { + it('the matrix header probe count EQUALS the table — the drift is closed, not recorded', () => { const matrix = readFileSync(join(HERE, 'authz-conformance.matrix.ts'), 'utf8'); const m = /`discover\(\)`: (\d+) probes over (\d+) named source files/.exec(matrix); expect(m, 'the matrix header no longer states a probe count in the pinned shape').not.toBeNull(); expect(Number(m![1])).toBe(MATRIX_HEADER_PROBE_CLAIM); expect(Number(m![2])).toBe(PROBE_TABLE.files); - // The drift itself, pinned: the prose says 15, the table holds 16. Repairing - // the sentence is the follow-up card's, not this measurement's. - expect(MATRIX_HEADER_PROBE_CLAIM).not.toBe(PROBE_TABLE.entries); + // ⭐ INVERTED. This assertion used to pin the drift (`not.toBe`): the prose + // said 15 while the table held 16, and the measurement that found it left + // the sentence alone on purpose so its no-repair fence stayed unambiguous. + // The sentence is repaired, so the pin becomes the permanent invariant it + // should always have been — a probe added without moving the prose is now + // RED here instead of a discrepancy recorded in a third file. + expect(MATRIX_HEADER_PROBE_CLAIM).toBe(PROBE_TABLE.entries); + // …and the header's own text is what was read, not a hand-copied number. + expect(Number(m![1])).toBe(PROBE_TABLE.entries); + }); + + it('every probe DECLARES its instrument kind, and the census records what it declares', () => { + // The taxonomy used to live only as prose in the census. It is now data on + // each probe, read back out of the companion test's source — so a probe + // whose kind changes (a dead ROUTE_ENUMERATION honestly re-declared + // TRIPWIRE, say) cannot leave this record describing the old table. + for (const row of PROBE_FILE_CENSUS) { + const derivedKinds = derived.kinds.get(row.file); + expect(derivedKinds, `no kind declaration derived for ${row.file}`).toBeDefined(); + expect(derivedKinds).toEqual([...row.kinds].sort()); + } + }); + + it('every PROBES entry carries a kind — the pairing is complete, not short', () => { + // Non-vacuity for the walk above: `kinds` is built by pairing `kind` and + // `file` tokens in document order, so an entry missing its `kind` would + // silently pair with the wrong file. The total is what catches that. + const declared = [...derived.kinds.values()].flat().length; + expect(declared).toBeGreaterThanOrEqual(new Set(PROBE_FILE_CENSUS.map((r) => r.file)).size); + // All three instruments are still represented — a table that had lost one + // would make the kind-specific readings below vacuous. + const all = new Set([...derived.kinds.values()].flat()); + expect([...all].sort()).toEqual(['GATE_PIN', 'ROUTE_ENUMERATION', 'TRIPWIRE']); }); });