Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/route-ledger-authz-disposition-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/rest': patch
'@objectstack/runtime': patch
---

Route ledger rows can declare their reviewed authorization posture

`RestRouteLedgerEntry` and `RouteLedgerEntry` gain an optional `authz` field
naming the authorization-conformance row that classifies the route. It is
phased exactly like `responseSchema` in the same two files: optional, filled
only where conformance coverage already exists, never mass-produced. Five rows
are seeded; the rest stay undeclared.

Both modules are package-internal (neither type reaches either package's
published `.d.ts`), and nothing reads the field at runtime — the authorization
conformance ratchet resolves every declaration against the live matrix and
refuses a name that is not an `enforced` row.
103 changes: 82 additions & 21 deletions packages/qa/dogfood/test/authz-conformance.matrix.ts

Large diffs are not rendered by default.

427 changes: 424 additions & 3 deletions packages/qa/dogfood/test/authz-conformance.test.ts

Large diffs are not rendered by default.

126 changes: 126 additions & 0 deletions packages/qa/dogfood/test/authz-ledger-population.baseline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// THE UNCLASSIFIED BASELINE for the route-ledger population — enumerated,
// dated, shrink-only.
//
// ── WHY IT EXISTS ─────────────────────────────────────────────────────────
//
// `authz-conformance.test.ts` sources its route population from the two route
// ledgers at FAMILY / DOMAIN granularity (2026-08-31). That mints 40 keys
// where the probe table alone minted 9, and 34 of the 40 name a surface no
// authorization row in `authz-conformance.matrix.ts` classifies today.
//
// Those 34 are a REAL, MEASURED gap and they are written down here one by one.
// The alternative shapes were both refused:
//
// ⛔ CLASSIFYING THEM IN BULK. A `covers` append is a claim that a reviewed
// enforcement site accounts for the surface. Writing 34 of them in one
// change would manufacture 34 unreviewed claims — the exact "declared but
// unverified" surface every ratchet in this repo exists to remove, and the
// triage that opened this work forbade it explicitly when the number was
// 16.
// ⛔ SILENCING THEM. A count, a pattern, a prefix skip or a "known gaps"
// tolerance would make the gate see LESS than it does with the keys
// enumerated. None of those can tell a NEW unclassified family from an old
// one, which is the only thing this ratchet is for.
//
// ⭐ WHAT THIS SHAPE BUYS, and why it is not a weakening. Before this file the
// 34 surfaces below were not merely unclassified — they were UNMINTABLE: no
// key, no UNCLASSIFIED, no STALE, nothing to be silent about. Enumerating them
// converts an invisible absence into a written, dated, checked list that can
// only get shorter. A 41st family or domain is not on this list and fails CI
// as UNCLASSIFIED on the day it lands.
//
// ── THE FOUR RULES, ENFORCED BY `authz-conformance.test.ts` ───────────────
//
// 1. GROWTH IS RED. `LEDGER_POPULATION_BASELINE_MAX` pins the length. Adding
// an entry needs that literal moved, which is a reviewed edit and not a
// side effect of adding a route.
// 2. A STALE ENTRY IS RED. Every entry must still be a key the ledgers mint
// today. A family that is renamed or retired takes its entry with it.
// 3. A CLASSIFIED ENTRY IS RED. The moment a matrix row `covers` one of these
// keys, the entry here is a duplicate and must be deleted in the same
// change. The list cannot silently outlive the gap it records.
// 4. A DUPLICATE ENTRY IS RED. The count has to mean what it says.
//
// ⛔ NOTHING HERE ASSERTS THAT ANY ROUTE IS UNGUARDED, UNAUTHENTICATED OR
// EXPLOITABLE. Route-level enforcement is mostly in-handler (`enforceAuth` /
// `shouldDenyAnonymous` / per-object permission checks) and is not measured
// here at all. What is recorded is what the RATCHET can SEE. That is a defect
// in a GUARANTEE, not a breach.
//
// ── HOW AN ENTRY LEAVES THIS LIST ─────────────────────────────────────────
//
// Write (or cite) the enforcement site, add a matrix row whose `covers` names
// the key, and delete the line below. Rule 3 makes that deletion mandatory
// rather than optional, so the list burns down instead of accreting.

/**
* Ledger-sourced population keys with no classifying matrix row.
*
* MEASURED 2026-08-31 against `rest-route-ledger.ts` (94 rows / 19 families)
* and `route-ledger.ts` (80 rows / 21 domains): 40 keys minted, 6 classified
* by rows that already pin the same surface through the probe table, 34 here.
*
* ⛔ SHRINK-ONLY. See rules 1–4 above; the test enforces all four.
*/
export const LEDGER_POPULATION_BASELINE: readonly string[] = [
// ── REST families (`packages/rest/src/rest-route-ledger.ts`) ────────────
// `metadata` is absent because it IS classified — `anonymous-deny-meta`
// covers it through the guarded registrar. The other 18 families are here.
'rest-family:rest-route-ledger.ts:analytics',
'rest-family:rest-route-ledger.ts:approvals',
'rest-family:rest-route-ledger.ts:batch',
'rest-family:rest-route-ledger.ts:crud',
'rest-family:rest-route-ledger.ts:data-actions',
'rest-family:rest-route-ledger.ts:discovery',
'rest-family:rest-route-ledger.ts:email',
'rest-family:rest-route-ledger.ts:external-datasource',
'rest-family:rest-route-ledger.ts:forms',
'rest-family:rest-route-ledger.ts:openapi',
// ⚠️ NOT the same surface as `dispatcher-domain:route-ledger.ts:/packages`,
// which IS classified: that key names the dispatcher domain whose single
// handler body carries the domain-wide gate. This one names the four routes
// `@objectstack/rest` mounts itself, through a different registrar.
'rest-family:rest-route-ledger.ts:packages',
'rest-family:rest-route-ledger.ts:record-shares',
'rest-family:rest-route-ledger.ts:reports',
'rest-family:rest-route-ledger.ts:search',
'rest-family:rest-route-ledger.ts:security',
'rest-family:rest-route-ledger.ts:security-explain',
'rest-family:rest-route-ledger.ts:sharing-rules',
'rest-family:rest-route-ledger.ts:ui',

// ── dispatcher domains (`packages/runtime/src/route-ledger.ts`) ─────────
// Absent because classified: `/meta`, `/actions`, `/automation`,
// `/packages`, `/mcp`. The other 16 domains are here.
'dispatcher-domain:route-ledger.ts:/.well-known/objectstack',
'dispatcher-domain:route-ledger.ts:/ai',
'dispatcher-domain:route-ledger.ts:/analytics',
'dispatcher-domain:route-ledger.ts:/apps',
'dispatcher-domain:route-ledger.ts:/auth',
'dispatcher-domain:route-ledger.ts:/data',
'dispatcher-domain:route-ledger.ts:/discovery',
'dispatcher-domain:route-ledger.ts:/health',
'dispatcher-domain:route-ledger.ts:/i18n',
'dispatcher-domain:route-ledger.ts:/keys',
// ⚠️ Separate from `/mcp`, deliberately. The `/mcp` key is classified by
// `mcp-http-identity`, whose enforcement site is `handleMcp`; `/mcp/skill`
// is a different handler body (`handleMcpSkillRequest`) that the probe
// table was already measured not to reach.
'dispatcher-domain:route-ledger.ts:/mcp/skill',
'dispatcher-domain:route-ledger.ts:/notifications',
'dispatcher-domain:route-ledger.ts:/ready',
'dispatcher-domain:route-ledger.ts:/security',
'dispatcher-domain:route-ledger.ts:/share-links',
'dispatcher-domain:route-ledger.ts:/ui',
];

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

import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -200,7 +230,7 @@ export interface ProbeTableReading {
keys: number;
}

export const PROBE_TABLE: ProbeTableReading = { entries: 16, files: 11, keys: 9 };
export const PROBE_TABLE: ProbeTableReading = { entries: 18, files: 13, keys: 15 };

/**
* The probe count `authz-conformance.matrix.ts`'s header states.
Expand All @@ -219,9 +249,61 @@ export const PROBE_TABLE: ProbeTableReading = { entries: 16, files: 11, keys: 9
* in a third file. ⛔ Do not re-point this at a hand-written number — it is
* read out of the matrix header's own text.
*/
export const MATRIX_HEADER_PROBE_CLAIM = 16;
export const MATRIX_HEADER_PROBE_CLAIM = 18;

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

// ── the two ledger files (the population source) ────────────────────────
//
// Scoped to the exported array literal, exactly as the probes are: the
// patterns are the ledger's own row vocabulary, so a doc-comment or a type
// declaration spelling the same tokens outside the table would inflate the
// reading. `controls` stay WHOLE-FILE counts, like every other row here —
// they answer "is this still the file I think it is", which is a question
// about the file and not about the table.
for (const [rel, marker, keyField] of [
['packages/rest/src/rest-route-ledger.ts', 'REST_ROUTE_LEDGER', 'family'],
['packages/runtime/src/route-ledger.ts', 'ROUTE_LEDGER', 'domain'],
] as ReadonlyArray<readonly [string, string, string]>) {
const src = read(rel);
const from = src.indexOf(`export const ${marker}`);
const to = from < 0 ? -1 : src.indexOf('\n];', from);
// A marker that has moved reads as an EMPTY table, never as the whole file:
// a silently wider scope would still produce plausible numbers.
const table = from < 0 || to < 0 ? '' : src.slice(from, to);
const rowRe = /route: '/g;
const keyRe = new RegExp(`${keyField}: '`, 'g');
const entryName = keyField === 'family' ? 'RestRouteLedgerEntry' : 'RouteLedgerEntry';
files.set(rel, {
population: occurrences(table, rowRe),
// Every row carrying the key field is reachable: each distinct value
// mints a key. A row that ever loses it shows up as a blind spot here.
reachable: occurrences(table, keyRe),
controls: {
"route: '": occurrences(src, /route: '/g),
[`${keyField}: '`]: occurrences(src, new RegExp(`${keyField}: '`, 'g')),
[entryName]: occurrences(src, new RegExp(entryName, 'g')),
},
});
}

// ── rest-server.ts ──────────────────────────────────────────────────────
{
const src = read('packages/rest/src/rest-server.ts');
Expand Down
18 changes: 12 additions & 6 deletions packages/qa/dogfood/test/authz-probe-blind-spot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,18 @@ describe('authz probe blind-spot census (#13260)', () => {
expect(derived.table).toEqual(PROBE_TABLE);
});

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

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