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']); }); });