Skip to content

test(qa): source the authz ratchet population from the two route ledgers (family/domain), and declare authz posture at the producer - #13773

Merged
os-steve merged 4 commits into
mainfrom
claude/issue-13411-authz-population-family-domain
Aug 31, 2026
Merged

test(qa): source the authz ratchet population from the two route ledgers (family/domain), and declare authz posture at the producer#13773
os-steve merged 4 commits into
mainfrom
claude/issue-13411-authz-population-family-domain

Conversation

@claude

@claude claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #13411

Implements the maintainer's ruling of 2026-08-30 (批 #7): option F direction + B
executed now, with the matrix header sentence narrowed in the same change
.

Nothing here asserts that any route is unguarded, unauthenticated or
exploitable.
Route-level enforcement is mostly in-handler and is not measured
anywhere in this change. What is repaired is a defect in a guarantee — what the
instrument can see — and the change makes the gate see strictly more.


B — the population now comes from the two route ledgers, at family/domain granularity

authz-conformance.test.ts gains two ROUTE_ENUMERATION probes, one per ledger,
each scoped (within:) to that ledger's exported array so the patterns read the
table rather than the file that carries it.

before after
probe entries / files 16 / 11 18 / 13
keys the population mints 9 49 (9 probe + 40 ledger)
REST families 19 (94 rows)
dispatcher domains 21 (80 rows)

Re-derived, not carried over. The ruling's estimate was ~40 keys / ~31 new
UNCLASSIFIED. Keys: 40, exactly. UNCLASSIFIED: 34, not 31 — and the
difference is worth stating. 31 was 40 − 9, subtracting today's nine minted keys.
Three of those nine are not HTTP route families at all: the realtime publish
fan-out pin and the MCP stdio principal pin mount no HTTP route, and the second
MCP key is a co-key on the same /mcp domain as the first. The nine keys map onto
six distinct family/domain surfaces, so 40 − 6 = 34.

Why the ledgers and not a wider regex: both are enumerated from a running server
and guarded in both directions by their own conformance tests (RestServer.getRoutes();
domainRegistry.list()). A new family or domain cannot be silently absent from them,
so it cannot be silently absent from the ratchet. Widening the regex rots on the next
added file — that is the mechanism being replaced, not repeated.

The 34, and why this baseline shape is not a weakening

packages/qa/dogfood/test/authz-ledger-population.baseline.tsenumerated key by
key, dated, shrink-only
, with the reason in the file. Four rules, all mechanical:

  1. growth is red against a pinned literal (LEDGER_POPULATION_BASELINE_MAX = 34);
  2. a stale entry is red — every entry must still be minted;
  3. a classified entry is red — the moment a matrix row covers a key, the line must
    be deleted in the same change (this is the burn-down that makes it shrink);
  4. a duplicate is red — the count has to mean what it says.

⭐ The load-bearing point: before this change those 34 surfaces were not merely
unclassified, they were unmintable — no key, no UNCLASSIFIED, no STALE, nothing to
be silent about. Enumerating them turns an invisible absence into a dated list that can
only get shorter, and a 41st family or domain is red on the day it lands. Bulk
classification was refused (34 unreviewed covers claims is the "declared but
unverified" surface these ratchets exist to remove); silencing by count, pattern or
prefix was refused (none of those can tell a new gap from an old one).

6 keys are classified, all on rows that already pinned the same surface through a
probe, each with its own dated justification comment: rest-family:...:metadata and
dispatcher-domain:...:/meta (anonymous-deny-meta), /actions, /automation,
/packages (the domain-wide gates), /mcp (mcp-http-identity). ⛔ Deliberately not
classified: the REST packages family (a different registrar on a different server)
and /mcp/skill (a second handler body the classifying row does not name).

F — the producer-side authz declaration, phased

RestRouteLedgerEntry and RouteLedgerEntry gain an optional authz naming the
matrix row that classifies the route — phased exactly like responseSchema in those
same two files (optional, "no coverage, no fill", never mass-produced), with the
resolver in the guard that can import the vocabulary. It refuses a name that is not a
row id, and refuses a row that is not enforced.

5 rows of 174 filled. The rule used to pick them, stated in the field's own doc
comment: the named matrix row must be enforced, its cited dogfood proof must drive
that route at a literal wire path
(nothing inferred from a sibling), and its
enforcement text must name the site that serves it.

  • GET /api/v1/meta and POST /api/v1/meta/_migrate-stored (REST) and their
    dispatcher twins — showcase-anonymous-deny-surfaces.dogfood.test.ts drives those
    exact paths on a booted showcase, anonymous 401 with an authenticated not-401 as the
    positive control.
  • * /mcp/** — the cleanest case in either ledger: a one-row domain, so the
    family-granular classification is route-granular there with nothing inferred.

⛔ The 19 sibling metadata rows are left blank on purpose. The family-wide gate is a
real property, but writing it onto 19 rows in one change is the mass production the
ruling forbids.

The matrix header sentence, narrowed — and no caveat

It now claims completeness at route-family and dispatcher-domain granularity, and
says the bound out loud in the same breath:

A route added inside an EXISTING family or domain mints no key and this gate stays
green — measured, not assumed: injecting an ungated route into registerUiEndpoints
leaves every assertion here passing, because ui is already a family.

That is a known, accepted bound stated as the claim itself, not a caveat hung off a
wider one. ⛔ This change does not close the route-level blind spot and does not claim
to: BLIND_SPOT_TOTAL_STATIC / _RUNTIME are unchanged at 75 / 80, and Leg A below
is the proof they are unchanged.

The file-selection layer, verified by measurement rather than assumed

15 of the 17 runtime/src/domains/ files declare a route prefix. The probe table names
4. All 15 now mint a ledger key ⇒ 11 files no probe has ever named now mint
one
. Controls both ways: a prefix that does not exist is correctly absent; the domain
key set is non-empty at 21. So the file-selection layer is settled by construction, at
the domain layer — never the route layer.


Verification

All readings on final head 1c24f47f, after merging origin/main and re-deriving the
gate union with --repo objectstack-ai/objectstack (stderr banner confirms commit and
checkout; the assertion holds).

GREEN. dogfood authz-conformance.test.ts + authz-probe-blind-spot.test.ts
Test Files 2 passed (2) / Tests 80 passed (80) (62 before this change).
rest-route-ledger.conformance.test.ts 7/7 · route-ledger.conformance.test.ts 6/6 ·
client rest-route-ledger-coverage + route-ledger-coverage +
route-ledger-response-schema + client-url-conformance 9/9. typecheck exit 0 for
dogfood, rest, runtime, client.

Not vacuous: tsc --noEmit --listFiles in dogfood lists 1282 files and contains all
five edited/added files at 1 hit each, with a negative control at 0. The rest/runtime
typecheck readings carry to the final head by proof, not assumption:
git diff f3158899f0 1c24f47fdb -- packages/rest packages/runtime is 0 lines, and
both ledger conformance suites were re-run at the final head regardless.

pnpm lint repo-wide (eslint . --no-inline-config) exit 0 — 5567 files, 0
errors, 0 warnings
. No narrowing was used and none needs declaring.

Ablations — direction predicted in writing before each run

Every leg ran under trap ... EXIT INT TERM with absolute paths, restored via
git checkout HEAD -- ... (never bare), and proved mutation and restore on disk by
blob hash plus occurrence counts in both directions. No rebuild leg applies: both
instruments are pure source-text scanners (readFileSync over the files under test,
no import of the packages under test), so nothing resolves through dist/ and a stale
build cannot produce a false green.

Leg A — predicted GREEN, and IS GREEN. One ungated route injected into
registerUiEndpoints. Mutation proven both ways: this.routeManager.register( 80 to
81 and the marker 0 to 1, blob bc216e78 to 239e80a8. Result:
authz-conformance.test.ts 47 passed, 0 failed — green with an ungated route on
disk
, reproducing round 1's reading unchanged; authz-probe-blind-spot.test.ts RED
with expected 81 to be 80. ⭐ This negative result is reported as the accepted bound,
not smoothed over.

Leg B — predicted RED, IS RED. A row carrying a brand-new family: 'webhooks'
added to REST_ROUTE_LEDGER (marker 0 to 1, family: ' 94 to 95). Result:
UNCLASSIFIED surface — add a ledger row (ADR-0060): rest-family:rest-route-ledger.ts:webhooks.
The rot the probe table had is closed: nobody has to remember to add a probe.

Leg C — predicted RED, IS RED. One line deleted from the baseline (entries 34 to
33). Result: UNCLASSIFIED surface ...:search, plus expected 33 to be 34 and
expected 39 to be 40. ⭐ This is the proof that the 34 keys really are in the
population and the baseline holds them rather than hiding them.

Restore proven on all three legs: restored blob identical to the non-empty HEAD blob,
git diff HEAD 0 lines, git status --porcelain empty, marker count back to 0.

Gate union

NAMED reconciled against RAN, mechanically — union named 39, ran 39, unreconciled 0.
A family list is not a green, so the harvested list was used as a checklist rather than
only as a diff between derivations:

comm -23 <(sort gates.list) <(sort ran.list)   # -> empty

Two things that reconciliation caught, and neither would have shown up in a prose claim:
check:type-check-debt was genuinely unrun (I had cap-killed it and written it off),
and check:slot-lookup's first log was a partial from the batch the foreground
ceiling killed mid-command — its completed rerun is the one matched, and the partial is
excluded from ran.list by name. Both derivations were harvested (31 families before the
changeset landed, 39 after) and the reconciliation is against the superset.

Each gate read from its own printed verdict line, exit code captured before any pipe.
38 green, 1 NOT MEASURED, 0 unrun. Green includes check:doc-authoring,
check:type-check-debt, check:objectql-double-limit, check:cross-package-test-inputs,
check:engine-double-contract, check:where-matcher, check:type-check-coverage,
check:query-options-erasure, check:test-source-alias, check:type-source-resolution,
check:slot-lookup, check:dual-build-cjs-loads, check:empty-changeset,
check:changeset-gate-self-tests, check:objectui-changeset,
check:adr-0087-registration and the four spec families.

check:type-check-debt is GREEN and it was the one worth chasing. packages/runtime
carries a TEST_DEBT entry (recorded 217) and its tsconfig.json excludes **/*.test.ts,
so its typecheck exit 0 said nothing about the six hidden test files that import
route-ledger — exactly the shape that reddens a shrink-only ratchet after a green local
run. Re-measured under the verify lock: "29 ledger entr(ies) re-measured in 258.1s, 1542
raw tsc error(s) total, none above its recorded number"
, with "surplus: none — every
entry sits exactly at its measurement, so any new error is red."
The earlier exit 143
was wall-clock only; serialised under the lock it finishes in 259s. ⛔ No TEST_DEBT entry
was raised. @objectstack/rest and @objectstack/dogfood carry no DEBT/TEST_DEBT entry
at all (checked against the ledgers in the gate script), and rest's own
check:test-typecheck ratchet is green at 1 file / 2 errors / 1 pinned signature.

check:objectql-double-limit is green and non-vacuously so: this diff introduces no
engine double
— every .find( it adds is Array.prototype.find over a plain array —
and the gate's own line confirms "baseline key set verified against adf4bf4: no files
added."

check:doc-authoring initially went RED on a tracker id inside a runtime string in
the census note. Repaired by stripping the id (⛔ not by a baseline entry, which that
gate marks maintainer-only); it is now green and demonstrably non-vacuous — its
sibling-package leg reads 80806 strings across 1096 parsed sources over 831 pinned
sites and reports no growth, with no baseline entry added.

NOT MEASURED — reported separately and ⛔ not folded into the pass list:

  • check:test-completeness — exit 3, its own words: "Nothing was measured: this gate
    exited before parsing a single summary line ... ⛔ It is NOT a finding."

    That is the only one. check:type-check-debt was on this list in an earlier draft
    and is not any more — it was run to completion and is green (above).

Clause ② — NO, with the measurement

Neither ledger type reaches a published surface. RestRouteLedgerEntry and
RouteLedgerEntry appear 0 times in all four built type entry points
(packages/rest/dist/index.d.ts / .d.cts, packages/runtime/dist/index.d.ts /
.d.cts); positive control on the same greps: RestServer 18, HttpDispatcher 32.
Both modules are package-internal by their own headers and are not re-exported from
either index. No published surface is widened and no accept/reject behaviour changes.

Changeset

patch for @objectstack/rest and @objectstack/runtime. The alternative was the
skip-changeset label — this PR releases nothing observable — and it was not taken
because the diff reaches publishable packages/*/src/, which sits outside the closed
list that route is defined over. A wrong changeset is release noise; a wrong
skip-changeset label is a gate bypass, so the conservative route was taken. Happy to
swap it for the label if the seat prefers.

Fences honoured

⛔ No landed edit in packages/rest/src/rest-server.ts (held by #13406) or
packages/runtime/src/http-dispatcher.ts (held by #13408). rest-server.ts was
mutated temporarily for Leg A and restored with the proofs above; nothing was
committed. Both were read. Nothing lands in docs/adr/**, .claude/**, skills/**,
AGENTS.md, CLAUDE.md or content/docs/releases/**. All 23 open PRs were checked
against the merge base for the files touched here: no collision.

⛔ Not re-litigated: option E (73% false-ungated) and "just use today's ledger as the
population" stay rejected and are recorded in the census so they are not re-proposed.
PR #13436's landed half is untouched. #13214 is not reopened, re-graded or re-judged.
#13376 keeps its own card state — the reading above is a measurement, not a state change.

Generated by Claude Code


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/runtime, touching 7 documentable anchor(s).

34 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 8ab4ace4dd9b17b6e75b90b1cc77193685a748d1.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8ab4ace4dd9b17b6e75b90b1cc77193685a748d1packageMentionDocs.

Which tree this was computed on

This run read content/docs from 30ef9382ff22885ec205141aa04cc9c706af18ee — the merge of head 1c24f47fdb04e57c2e61e5bf3c91109e729572e5 into base 8ab4ace4dd9b17b6e75b90b1cc77193685a748d1, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 30ef9382ff22885ec205141aa04cc9c706af18ee && git checkout 30ef9382ff22885ec205141aa04cc9c706af18ee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8ab4ace4dd9b17b6e75b90b1cc77193685a748d1 1c24f47fdb04e57c2e61e5bf3c91109e729572e5 && git checkout -B drift-repro 8ab4ace4dd9b17b6e75b90b1cc77193685a748d1 && git merge --no-ff 1c24f47fdb04e57c2e61e5bf3c91109e729572e5

node scripts/docs-audit/affected-docs.mjs --json 8ab4ace4dd9b17b6e75b90b1cc77193685a748d1

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 8ab4ace4dd9b17b6e75b90b1cc77193685a748d1 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Repair the authz-conformance ratchet's route population — decide where it comes from before widening any regex (80 entry points measured invisible)

2 participants