Skip to content

Commit 469cbc9

Browse files
claude[bot]os-steveclaude
authored
test(qa): source the authz ratchet population from the two route ledgers (family/domain), and declare authz posture at the producer (#13773)
* wip: ledger-sourced authz population (family/domain) + producer authz field * fix(qa): strip the tracker id from the census note string * chore: changeset for the route-ledger authz disposition field --------- Co-authored-by: os-steve <steve@objectstack.ai> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1467b19 commit 469cbc9

8 files changed

Lines changed: 903 additions & 43 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/rest': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
Route ledger rows can declare their reviewed authorization posture
7+
8+
`RestRouteLedgerEntry` and `RouteLedgerEntry` gain an optional `authz` field
9+
naming the authorization-conformance row that classifies the route. It is
10+
phased exactly like `responseSchema` in the same two files: optional, filled
11+
only where conformance coverage already exists, never mass-produced. Five rows
12+
are seeded; the rest stay undeclared.
13+
14+
Both modules are package-internal (neither type reaches either package's
15+
published `.d.ts`), and nothing reads the field at runtime — the authorization
16+
conformance ratchet resolves every declaration against the live matrix and
17+
refuses a name that is not an `enforced` row.

packages/qa/dogfood/test/authz-conformance.matrix.ts

Lines changed: 82 additions & 21 deletions
Large diffs are not rendered by default.

packages/qa/dogfood/test/authz-conformance.test.ts

Lines changed: 424 additions & 3 deletions
Large diffs are not rendered by default.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// THE UNCLASSIFIED BASELINE for the route-ledger population — enumerated,
4+
// dated, shrink-only.
5+
//
6+
// ── WHY IT EXISTS ─────────────────────────────────────────────────────────
7+
//
8+
// `authz-conformance.test.ts` sources its route population from the two route
9+
// ledgers at FAMILY / DOMAIN granularity (2026-08-31). That mints 40 keys
10+
// where the probe table alone minted 9, and 34 of the 40 name a surface no
11+
// authorization row in `authz-conformance.matrix.ts` classifies today.
12+
//
13+
// Those 34 are a REAL, MEASURED gap and they are written down here one by one.
14+
// The alternative shapes were both refused:
15+
//
16+
// ⛔ CLASSIFYING THEM IN BULK. A `covers` append is a claim that a reviewed
17+
// enforcement site accounts for the surface. Writing 34 of them in one
18+
// change would manufacture 34 unreviewed claims — the exact "declared but
19+
// unverified" surface every ratchet in this repo exists to remove, and the
20+
// triage that opened this work forbade it explicitly when the number was
21+
// 16.
22+
// ⛔ SILENCING THEM. A count, a pattern, a prefix skip or a "known gaps"
23+
// tolerance would make the gate see LESS than it does with the keys
24+
// enumerated. None of those can tell a NEW unclassified family from an old
25+
// one, which is the only thing this ratchet is for.
26+
//
27+
// ⭐ WHAT THIS SHAPE BUYS, and why it is not a weakening. Before this file the
28+
// 34 surfaces below were not merely unclassified — they were UNMINTABLE: no
29+
// key, no UNCLASSIFIED, no STALE, nothing to be silent about. Enumerating them
30+
// converts an invisible absence into a written, dated, checked list that can
31+
// only get shorter. A 41st family or domain is not on this list and fails CI
32+
// as UNCLASSIFIED on the day it lands.
33+
//
34+
// ── THE FOUR RULES, ENFORCED BY `authz-conformance.test.ts` ───────────────
35+
//
36+
// 1. GROWTH IS RED. `LEDGER_POPULATION_BASELINE_MAX` pins the length. Adding
37+
// an entry needs that literal moved, which is a reviewed edit and not a
38+
// side effect of adding a route.
39+
// 2. A STALE ENTRY IS RED. Every entry must still be a key the ledgers mint
40+
// today. A family that is renamed or retired takes its entry with it.
41+
// 3. A CLASSIFIED ENTRY IS RED. The moment a matrix row `covers` one of these
42+
// keys, the entry here is a duplicate and must be deleted in the same
43+
// change. The list cannot silently outlive the gap it records.
44+
// 4. A DUPLICATE ENTRY IS RED. The count has to mean what it says.
45+
//
46+
// ⛔ NOTHING HERE ASSERTS THAT ANY ROUTE IS UNGUARDED, UNAUTHENTICATED OR
47+
// EXPLOITABLE. Route-level enforcement is mostly in-handler (`enforceAuth` /
48+
// `shouldDenyAnonymous` / per-object permission checks) and is not measured
49+
// here at all. What is recorded is what the RATCHET can SEE. That is a defect
50+
// in a GUARANTEE, not a breach.
51+
//
52+
// ── HOW AN ENTRY LEAVES THIS LIST ─────────────────────────────────────────
53+
//
54+
// Write (or cite) the enforcement site, add a matrix row whose `covers` names
55+
// the key, and delete the line below. Rule 3 makes that deletion mandatory
56+
// rather than optional, so the list burns down instead of accreting.
57+
58+
/**
59+
* Ledger-sourced population keys with no classifying matrix row.
60+
*
61+
* MEASURED 2026-08-31 against `rest-route-ledger.ts` (94 rows / 19 families)
62+
* and `route-ledger.ts` (80 rows / 21 domains): 40 keys minted, 6 classified
63+
* by rows that already pin the same surface through the probe table, 34 here.
64+
*
65+
* ⛔ SHRINK-ONLY. See rules 1–4 above; the test enforces all four.
66+
*/
67+
export const LEDGER_POPULATION_BASELINE: readonly string[] = [
68+
// ── REST families (`packages/rest/src/rest-route-ledger.ts`) ────────────
69+
// `metadata` is absent because it IS classified — `anonymous-deny-meta`
70+
// covers it through the guarded registrar. The other 18 families are here.
71+
'rest-family:rest-route-ledger.ts:analytics',
72+
'rest-family:rest-route-ledger.ts:approvals',
73+
'rest-family:rest-route-ledger.ts:batch',
74+
'rest-family:rest-route-ledger.ts:crud',
75+
'rest-family:rest-route-ledger.ts:data-actions',
76+
'rest-family:rest-route-ledger.ts:discovery',
77+
'rest-family:rest-route-ledger.ts:email',
78+
'rest-family:rest-route-ledger.ts:external-datasource',
79+
'rest-family:rest-route-ledger.ts:forms',
80+
'rest-family:rest-route-ledger.ts:openapi',
81+
// ⚠️ NOT the same surface as `dispatcher-domain:route-ledger.ts:/packages`,
82+
// which IS classified: that key names the dispatcher domain whose single
83+
// handler body carries the domain-wide gate. This one names the four routes
84+
// `@objectstack/rest` mounts itself, through a different registrar.
85+
'rest-family:rest-route-ledger.ts:packages',
86+
'rest-family:rest-route-ledger.ts:record-shares',
87+
'rest-family:rest-route-ledger.ts:reports',
88+
'rest-family:rest-route-ledger.ts:search',
89+
'rest-family:rest-route-ledger.ts:security',
90+
'rest-family:rest-route-ledger.ts:security-explain',
91+
'rest-family:rest-route-ledger.ts:sharing-rules',
92+
'rest-family:rest-route-ledger.ts:ui',
93+
94+
// ── dispatcher domains (`packages/runtime/src/route-ledger.ts`) ─────────
95+
// Absent because classified: `/meta`, `/actions`, `/automation`,
96+
// `/packages`, `/mcp`. The other 16 domains are here.
97+
'dispatcher-domain:route-ledger.ts:/.well-known/objectstack',
98+
'dispatcher-domain:route-ledger.ts:/ai',
99+
'dispatcher-domain:route-ledger.ts:/analytics',
100+
'dispatcher-domain:route-ledger.ts:/apps',
101+
'dispatcher-domain:route-ledger.ts:/auth',
102+
'dispatcher-domain:route-ledger.ts:/data',
103+
'dispatcher-domain:route-ledger.ts:/discovery',
104+
'dispatcher-domain:route-ledger.ts:/health',
105+
'dispatcher-domain:route-ledger.ts:/i18n',
106+
'dispatcher-domain:route-ledger.ts:/keys',
107+
// ⚠️ Separate from `/mcp`, deliberately. The `/mcp` key is classified by
108+
// `mcp-http-identity`, whose enforcement site is `handleMcp`; `/mcp/skill`
109+
// is a different handler body (`handleMcpSkillRequest`) that the probe
110+
// table was already measured not to reach.
111+
'dispatcher-domain:route-ledger.ts:/mcp/skill',
112+
'dispatcher-domain:route-ledger.ts:/notifications',
113+
'dispatcher-domain:route-ledger.ts:/ready',
114+
'dispatcher-domain:route-ledger.ts:/security',
115+
'dispatcher-domain:route-ledger.ts:/share-links',
116+
'dispatcher-domain:route-ledger.ts:/ui',
117+
];
118+
119+
/**
120+
* The pinned ceiling. ⛔ SHRINK-ONLY — lower it as entries are classified
121+
* away; raising it is a reviewed decision, never a side effect of adding a
122+
* route family.
123+
*
124+
* 34 at 2026-08-31, the day the ledger population was adopted.
125+
*/
126+
export const LEDGER_POPULATION_BASELINE_MAX = 34;

packages/qa/dogfood/test/authz-probe-blind-spot.census.ts

Lines changed: 126 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,16 @@
77
// "ratchets completeness over a CURATED table of HTTP/transport entry points"
88
// and that "a new ungated route there is UNCLASSIFIED ... and breaks CI". That
99
// promise is true only for the entry points a probe can actually mint a key
10-
// for. This module measures, for EVERY one of the 11 files the `PROBES` table
10+
// for. This module measures, for EVERY one of the 13 files the `PROBES` table
1111
// names, how far that reach extends — and records the result so it cannot rot.
1212
//
13+
// ⭐ Since 2026-08-31 two of those files are the ROUTE LEDGERS, and they are
14+
// the reason the matrix header now claims family/domain completeness rather
15+
// than route completeness. Their rows read `blindSpot: 0` for a reason no
16+
// other row here has — every ledger row is reachable by a mintable key — and
17+
// that must NOT be read as "the route blind spot is closed". It is not:
18+
// `BLIND_SPOT_TOTAL_STATIC` / `_RUNTIME` below are UNCHANGED at 75 / 80.
19+
//
1320
// ⛔ WHAT IS **NOT** CLAIMED HERE. Nothing in this file asserts that any route
1421
// is unguarded, unauthenticated or exploitable. Route-level enforcement is
1522
// mostly in-handler (`enforceAuth` / `shouldDenyAnonymous` / per-object
@@ -81,7 +88,7 @@
8188
// protocol). The static reading is the one pinned because it is the one this
8289
// package can re-derive without depending on `@objectstack/rest`.
8390
//
84-
// ── THE POPULATION SOURCE: measured, and still an OPEN CONTRACT DECISION ──
91+
// ── THE POPULATION SOURCE: measured, then DECIDED (2026-08-31) ────────────
8592
//
8693
// The obvious repair is to source this ratchet's route population from the
8794
// route ledgers instead of from a regex table, and that was measured before
@@ -145,12 +152,35 @@
145152
// from the one this matrix header states, and adopting it is a contract
146153
// decision rather than a repair.
147154
//
148-
// ⇒ ⛔ NOT DECIDED HERE, and deliberately not worked around: widening the
149-
// regex is the rot this instrument already has, and inventing a syntactic
150-
// "gated" reading would convert a visible gap into a written-down false
151-
// assurance — strictly worse than an honest UNCLASSIFIED. What this file does
152-
// instead is close the mechanism that was SILENT (the dead probe), leave the
153-
// population untouched, and hand the decision on with the reading attached.
155+
// ⇒ ⭐ DECIDED 2026-08-31, and the three blockers above are why the answer is
156+
// what it is rather than the obvious one. The ledgers now supply the
157+
// POPULATION at FAMILY / DOMAIN granularity — 19 REST families + 21 dispatcher
158+
// domains = 40 keys — and nothing else. They do NOT supply the
159+
// CLASSIFICATION: blocker 1 stands, so "is it gated" remains a reviewed matrix
160+
// row, never a ledger disposition. Blocker 2 stands as the reason no syntactic
161+
// reading was attempted anywhere. Blocker 3 is not fixed and is not hidden: a
162+
// route added inside an existing family mints nothing and this gate stays
163+
// green, which is stated at that exact granularity in the matrix header with
164+
// no caveat attached to a wider claim.
165+
//
166+
// What the change buys is the FILE-SELECTION layer rather than the route
167+
// layer: a family or domain can no longer be silently absent, because both
168+
// ledgers are enumerated from a running server and guarded in both directions.
169+
// 6 of the 40 keys are classified by rows that already pinned the same
170+
// surface; the other 34 are enumerated, dated and pinned shrink-only in
171+
// `authz-ledger-population.baseline.ts`. Before that date those 34 surfaces
172+
// minted no key at all — no UNCLASSIFIED, no STALE, nothing.
173+
//
174+
// The DIRECTION half went to the producer: a ledger row may declare the authz
175+
// posture it has been reviewed to have (`authz:`, phased exactly like
176+
// `responseSchema` — optional, no coverage no fill, never mass-produced), and
177+
// the companion test resolves every declaration against an `enforced` matrix
178+
// row. That is what eventually makes blocker 3 answerable at the ledger review
179+
// point, where a new route is already being read.
180+
//
181+
// ⛔ Two readings stay REJECTED and are recorded here so they are not
182+
// re-proposed: deriving "gated" from source syntax (73% false-ungated), and
183+
// taking a ledger disposition as an authorization fact (blocker 1).
154184

155185
import { readFileSync } from 'node:fs';
156186
import { fileURLToPath } from 'node:url';
@@ -200,7 +230,7 @@ export interface ProbeTableReading {
200230
keys: number;
201231
}
202232

203-
export const PROBE_TABLE: ProbeTableReading = { entries: 16, files: 11, keys: 9 };
233+
export const PROBE_TABLE: ProbeTableReading = { entries: 18, files: 13, keys: 15 };
204234

205235
/**
206236
* 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
219249
* in a third file. ⛔ Do not re-point this at a hand-written number — it is
220250
* read out of the matrix header's own text.
221251
*/
222-
export const MATRIX_HEADER_PROBE_CLAIM = 16;
252+
export const MATRIX_HEADER_PROBE_CLAIM = 18;
223253

224254
export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
255+
// ── the two LEDGER files: the population source since 2026-08-31 ───────
256+
//
257+
// ⭐ These two rows read differently from every other row here, and the
258+
// difference is the point of the change that added them: `blindSpot` is 0
259+
// NOT because the file mounts nothing (the reading four rows below give) but
260+
// because EVERY row in the table is reachable by a mintable key. Each ledger
261+
// row carries a `family` / `domain`, and each distinct value mints a key, so
262+
// there is no row the ratchet cannot name.
263+
//
264+
// ⚠️ `population` here counts LEDGER ROWS, not registrar call sites, and the
265+
// two are not interchangeable. A `blindSpot` of 0 says every ledger row is
266+
// covered by some key at FAMILY / DOMAIN granularity; it does NOT say every
267+
// route mints its own key, and it does not touch the 75/80 route-level blind
268+
// spot recorded below — that number is unchanged by this change and Leg A of
269+
// the reverse verification is what proves it unchanged.
270+
{
271+
file: 'packages/rest/src/rest-route-ledger.ts',
272+
kinds: ['ROUTE_ENUMERATION'],
273+
probes: 1,
274+
keys: 19,
275+
population: 94,
276+
reachable: 94,
277+
blindSpot: 0,
278+
populationRule: 'ledger rows inside REST_ROUTE_LEDGER; reachable = rows carrying a `family` (each distinct value mints a key)',
279+
controls: { "route: '": 94, "family: '": 94, RestRouteLedgerEntry: 2 },
280+
note:
281+
'The audited disposition of every route @objectstack/rest mounts, enumerated through ' +
282+
'RestServer.getRoutes() on a booted server and guarded per route by rest-route-ledger.conformance.test.ts. ' +
283+
'That guard is why this file can be a population source and a regex table cannot: a mounted route with no ' +
284+
'row here is already RED in another package, so a new family cannot be silently absent from this file, ' +
285+
'and therefore cannot be silently absent from the authz ratchet either. 19 families; 1 classified by a ' +
286+
'matrix row (metadata), 18 enumerated in the shrink-only baseline.',
287+
},
288+
{
289+
file: 'packages/runtime/src/route-ledger.ts',
290+
kinds: ['ROUTE_ENUMERATION'],
291+
probes: 1,
292+
keys: 21,
293+
population: 80,
294+
reachable: 80,
295+
blindSpot: 0,
296+
populationRule: 'ledger rows inside ROUTE_LEDGER; reachable = rows carrying a `domain` (each distinct value mints a key)',
297+
controls: { "route: '": 80, "domain: '": 80, RouteLedgerEntry: 2 },
298+
note:
299+
'The dispatcher half. Its machine contract is DOMAIN-level by live registry introspection ' +
300+
'(domainRegistry.list()), guarded in BOTH directions by route-ledger.conformance.test.ts: every ' +
301+
'registered domain needs a row, and every ledger domain must be a live prefix or a pinned legacy / ' +
302+
'non-dispatch branch. That two-way guard is what settles the FILE-SELECTION layer by ' +
303+
'construction — all 16 DomainRoute prefixes declared across the 15 domain files that declare one are ' +
304+
'ledger domains today, including the 11 files no probe has ever named. 21 domains; 5 classified ' +
305+
'(/meta, /actions, /automation, /packages, /mcp), 16 in the shrink-only baseline.',
306+
},
225307
{
226308
file: 'packages/rest/src/rest-server.ts',
227309
kinds: ['ROUTE_ENUMERATION', 'TRIPWIRE'],
@@ -432,6 +514,40 @@ export function deriveProbeFileCensus(): {
432514
} {
433515
const files = new Map<string, { population: number; reachable: number; controls: Record<string, number> }>();
434516

517+
// ── the two ledger files (the population source) ────────────────────────
518+
//
519+
// Scoped to the exported array literal, exactly as the probes are: the
520+
// patterns are the ledger's own row vocabulary, so a doc-comment or a type
521+
// declaration spelling the same tokens outside the table would inflate the
522+
// reading. `controls` stay WHOLE-FILE counts, like every other row here —
523+
// they answer "is this still the file I think it is", which is a question
524+
// about the file and not about the table.
525+
for (const [rel, marker, keyField] of [
526+
['packages/rest/src/rest-route-ledger.ts', 'REST_ROUTE_LEDGER', 'family'],
527+
['packages/runtime/src/route-ledger.ts', 'ROUTE_LEDGER', 'domain'],
528+
] as ReadonlyArray<readonly [string, string, string]>) {
529+
const src = read(rel);
530+
const from = src.indexOf(`export const ${marker}`);
531+
const to = from < 0 ? -1 : src.indexOf('\n];', from);
532+
// A marker that has moved reads as an EMPTY table, never as the whole file:
533+
// a silently wider scope would still produce plausible numbers.
534+
const table = from < 0 || to < 0 ? '' : src.slice(from, to);
535+
const rowRe = /route: '/g;
536+
const keyRe = new RegExp(`${keyField}: '`, 'g');
537+
const entryName = keyField === 'family' ? 'RestRouteLedgerEntry' : 'RouteLedgerEntry';
538+
files.set(rel, {
539+
population: occurrences(table, rowRe),
540+
// Every row carrying the key field is reachable: each distinct value
541+
// mints a key. A row that ever loses it shows up as a blind spot here.
542+
reachable: occurrences(table, keyRe),
543+
controls: {
544+
"route: '": occurrences(src, /route: '/g),
545+
[`${keyField}: '`]: occurrences(src, new RegExp(`${keyField}: '`, 'g')),
546+
[entryName]: occurrences(src, new RegExp(entryName, 'g')),
547+
},
548+
});
549+
}
550+
435551
// ── rest-server.ts ──────────────────────────────────────────────────────
436552
{
437553
const src = read('packages/rest/src/rest-server.ts');

packages/qa/dogfood/test/authz-probe-blind-spot.test.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,18 @@ describe('authz probe blind-spot census (#13260)', () => {
4343
expect(derived.table).toEqual(PROBE_TABLE);
4444
});
4545

46-
it('every mintable key is classified by exactly the rows that exist — 9 keys, no more', () => {
47-
// The matrix's `covers` keys and the probes' minted keys are the same set of
48-
// 9 today. If a probe starts minting a key no row covers, the ratchet itself
49-
// goes red as UNCLASSIFIED — that is its job, and this pin does not duplicate
50-
// it. What this asserts is only that the census's key count is current.
51-
expect(PROBE_TABLE.keys).toBe(9);
46+
it('every classified key is accounted for — 15 `covers` keys, no more', () => {
47+
// The matrix's `covers` keys number 15 today: the 9 probe-minted keys this
48+
// census was first measured against, plus the 6 ledger family/domain keys
49+
// classified when the population moved (2026-08-31). If a probe starts
50+
// minting a key no row covers, the ratchet itself goes red as UNCLASSIFIED
51+
// — that is its job, and this pin does not duplicate it. What this asserts
52+
// is only that the census's key count is current.
53+
//
54+
// ⚠️ This is NOT the size of the population. The ledgers mint 40 keys; 6
55+
// are classified here and 34 are enumerated in the shrink-only baseline,
56+
// which `authz-conformance.test.ts` holds to its own four rules.
57+
expect(PROBE_TABLE.keys).toBe(15);
5258
});
5359

5460
it.each(PROBE_FILE_CENSUS.map((r) => [r.file, r] as const))(

0 commit comments

Comments
 (0)