Uh oh!
There was an error while loading. Please reload this page.
fix(security): measure the unscoped existence page cap instead of trusting it - #11962
Conversation
…sting it (#11518) `buildExistingByName`'s UNSCOPED page was capped at `limit: names.length`, exact only while one row can exist per name. Since #8461 / ADR-0120 D1 the identity tables are unique PER ORGANIZATION and ADR-0066 D1 encourages admins to EXTEND the registry inside their own organization, so one name legitimately carries a row per organization plus the platform's. The rows that fall off a full page are the highest ids under #4363's `ORDER BY id ASC`, so whole names vanish — and a vanished name reads as `absent`, which INSERTS. No constant multiplier is correct (the bound is the organization count), so the cap is now a measurement: the read asks for one row MORE than it will hold, and a page carrying that extra row is a PREFIX of the answer. It joins the module's existing "could not answer" causes and degrades to the per-item read — the fallback already there for a driver without `$in` — with its own diagnostic.⚠️ Behaviour change on two shipped seeders in the truncating case: from a silent wrong answer to a loud slow one. Non-truncating installs are unchanged, one read and no warning. The SCOPED arm keeps #10103's `names.length * 2` — there the number is a proven bound, not a budget — and gains the same probe, so a scoped page that overflows it (an absent unique index) degrades loudly instead of truncating silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
📓 Docs Drift Check7 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5bfa01440689d67350dea7af31d1adc675e5eb0d && git checkout 5bfa01440689d67350dea7af31d1adc675e5eb0d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0140e678fa4771ea8330a9da075af96e1f8798bd cb9d109c258aa6039df7482c9d983965245bb544 && git checkout -B drift-repro 0140e678fa4771ea8330a9da075af96e1f8798bd && git merge --no-ff cb9d109c258aa6039df7482c9d983965245bb544
node scripts/docs-audit/affected-docs.mjs --json 0140e678fa4771ea8330a9da075af96e1f8798bd |
os-sam
commented
Aug 25, 2026
Docs hand-read done — no page is falsified, and one page is un-falsifiedPM note ( Method — pinned to a ref rather than a working tree ( Result — two pages name this seeder's implementation directly, which the anchor set did not surface: ⭐ The load-bearing one is
That sentence was false on ⇒ No docs edit is owed by this PR, and none should be added to it. The page already documents the intended contract correctly; the code was the half that disagreed. Worth recording because it is independent corroboration of severity from a source neither the card nor the implementation cited: the documentation asserted an invariant the read path had quietly stopped honouring, and nothing connected the two.
⛔ No action requested from the implementer. This is a review-side measurement, recorded so it is not re-derived. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
The initial measurement was taken at merge base 311e207. Merging 42 commits of main moved the corpus underneath it: three new test files arrived carrying find doubles, and main's #11962 independently taught the bootstrap-declared-capabilities double to honour the bound. Re-measured: 251 -> 253 files, 167 -> 168 blind, 54 -> 55 unjudged, wrong unchanged at 32. Ratchet DOWN applied, three arrivals seated as pre-existing debt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
Fixes#11518
The defect
buildExistingByName(packages/plugins/plugin-security/src/seed-name-lookup.ts) is the batchedexistence oracle the identity seeders consult in place of a per-item read. Its UNSCOPED page was
capped at
limit: names.length— exact only while one row can exist per name. Since #8461 /ADR-0120 D1
sys_capability.nameandsys_permission_set.nameare unique per organization, andADR-0066 D1 explicitly encourages admins to EXTEND the registry inside their own organization, so one
name legitimately carries a row per organization plus the platform's.
The rows that fall off a full page are the highest
ids under #4363'sORDER BY id ASCtie-breaker,so whole names vanish from the page — and a name missing from the page reads as
absent, whichroutes its caller to the INSERT branch. #10103 had already found and repaired exactly this on the
SCOPED arm (its comment sat directly above the line); the unscoped arm never got the repair, and two
seeders on
mainread unscoped —bootstrapDeclaredCapabilitiesandpermission-set-projection'senv-overlay pass.
⛔
names.length * 2is not the fix — it is the same defect with a larger constant. Rows-per-nameis bounded only by the number of organizations, so no constant multiplier is correct.
The shape implemented
The cap stopped being a promise and became a measurement.
readNamePageasks the driver for onerow more than the budget it is willing to hold:
page.length <= budget— the driver had nobudget + 1-th row to give, so the page is provably thecomplete set for these names;
page.length > budget— there is at least one more row than this read will hold, so the page is aprefix of the answer and cannot be read as one.
Both directions are exact, which is what makes the budget a free choice: no complete page is ever
mistaken for a truncated one, and no truncated page for a complete one. An over-budget page joins the
module's existing "could not answer" causes and reuses the degradation path that was already there
for a driver that cannot do
$in—perItemIndex. No second fallback was invented.Budgets:
max(names.length * 4, 20)security-plugin.ts's permission-set loadernames.length * 2(unchanged, #10103)applyTenantScopereturns this organization's rows plus organization-less ones, and the declared name index is unique per organizationThe scoped arm keeps #10103's exact cap and gains the same probe, so a scoped page that overflows the
proven bound — reachable only where the unique index is absent or not yet created, the deployment the
module header already calls out — becomes a loud degradation instead of a silent truncation.
⭐ Declared behaviour change
This changes existing callers' behaviour in the truncating case: from a silent wrong answer to a
loud slow one. That is the intended direction — a read that under-reports and then inserts is the
defect — but it lands on two shipped seeders and is stated here rather than slipped in:
absent(and re-inserted as duplicates, or refused by the unique keyas a "collision" naming a row nobody ever saw) are now answered correctly;
inside.
[security] batched seed existence read TRUNCATED — …is a separate diagnostic from theexisting failed-read one, because the remedies differ: an outage versus an install whose catalog is
simply wider than one page.
issues exactly the same single read it issued before and says nothing.
Scope
bootstrapSystemCapabilitiesis still per-item, and batching it is a ruling, not a refactor — it trades away #8751'splatformStampedInOrgsignal or reverses part of #8552 #11520's option A (batching the derived half) is deliberately NOT in thisPR, per the claim decision on
buildExistingByName's UNSCOPED page cap truncates once a name can have more than one row — and a truncated page reads as "absent", which inserts #11518: The DERIVED half ofbootstrapSystemCapabilitiesis still per-item, and batching it is a ruling, not a refactor — it trades away #8751'splatformStampedInOrgsignal or reverses part of #8552 #11520 ispm:blockedand its own text says that change"trades away [finding] The derived capability seeder rewrites a platform-marked row INSIDE an organization and still leaves the platform bucket unseeded — silently, on the UPDATE branch #8536 did not scope #8751's
platformStampedInOrgsignal or reverses part of [Decision] When a curated capability name is already held in the platform bucket, should the seeder keep declining — or adopt, backfill, or refuse the collision at authoring time? #8552 — a ruling, not arefactor." The truncation-detection machinery is built here; The DERIVED half of
bootstrapSystemCapabilitiesis still per-item, and batching it is a ruling, not a refactor — it trades away #8751'splatformStampedInOrgsignal or reverses part of #8552 #11520 re-grades against it.seed-name-lookup.tsexports exactly what it exported before (NAME_CHUNK_SIZE,SeedLookupLogger,ExistingLookupResult,ExistingByNameIndex,buildExistingByName); the budget constants and theNamePageoutcome type are module-private on purpose.packages/specuntouched. ⛔content/docs/releases/**untouched.pinned by this very commit):
packaged-permission-set-lock.tsand its test file both documented thisfail-open as live on main. Left alone they would tell the next reader the opposite of the truth.
Both are re-dated to "before
buildExistingByName's UNSCOPED page cap truncates once a name can have more than one row — and a truncated page reads as "absent", which inserts #11518" and keep their argument, because what those controls actuallypin outlives the repair: unscoped,
buildExistingByNamestill answers with the FIRST row by id, so ona name several organizations hold it answers with somebody else's row — a different wrong answer to
the same question, and one no page budget reaches. No assertion in that file changed.
Verification
Every exit code captured before any pipe; each gate quoted by its own verdict line. Union run at
cb9d109c25, the head commit of this branch.The failing case the card handed over transplants.
bootstrap-system-capabilities.test.ts'smakeQldouble is the one that HONOURSlimitand orders byidascending (the two shipped-driverbehaviours the defect is made of), and its 8-curated-platform-rows + 2-organization-rows fixture is
reused directly. The test that pinned the defect — "WITHOUT the predicate the page also TRUNCATES, and
a truncated page reads as "absent"" — asserted the wrong outcome and is rewritten in place, with
the change flagged in its body as the repair rather than a weakened assertion. #11451's own argument is
preserved and sharpened: what the predicate buys, and what no page budget can reach, is which row
answers.
Reverse-verification. Prediction written before the run;
seed-name-lookup.tsrestored to itsorigin/mainblob by plain file write (nevergit checkout <ref> -- <path>, which stages the ablatedblob), under a
trap … EXIT INT TERMrestore. Mutation confirmed on disk by marker counts, not byan editor exit code:
limit: budget + 11 → 0,names.length * 2 : names.length0 → 1.No rebuild is involved and none is owed: the ablated subject is imported relatively
(
./seed-name-lookup.js) from inside the same package, so vitest readssrc/— there is noexports/disthop for the mutation to miss.Predicted RED: 5 named tests. Observed: exactly those 5, at exactly the predicted assertions.
#11451 › WITHOUT the predicate the page hauls every organization's copyabsentstudio.access: expected 'absent' to be 'present'#11518 › reads every name whose row exists, in ONE round tripabsent === [names[6], names[7]]expected [ 'studio.access', 'manage_sharing' ] to deeply equal []#11518 › an overflowing page degrades to the per-item read — LOUDLYabsentmanage_org_users: expected 'absent' to be 'present'#11518 › a SCOPED page that overflows that bound degrades tooexpected "vi.fn()" to be called 1 times, but got 0 times#11518 › re-seeds a healthy multi-organization install WITHOUT writing anythingout.seeded === 2, two duplicate INSERTsexpected 2 to be +0Tests 5 failed | 71 passed (76). Every control stayed green as predicted — both POSITIVE CONTROLs(they pin the DOUBLE, so they hold on both sides), the unreadable-page diagnostic, and the scoped
exact-bound test. Restored and verified byte-identical: disk
0775e020d9…== index == HEAD,git status --porcelainempty.The consequence measured on the seeder, not argued.
bootstrap-declared-capabilities.test.ts'sdouble ignored
limitentirely, which made this whole class of page-cap defect inexpressible in thefile where its consequence actually lands. It now honours
limitwith the #4363 id-ascendingtie-breaker, matching the sibling double. On a REBUILD over a healthy multi-organization install the
old code inserted two duplicate rows (
out.seeded === 2); it now writes nothing.Gates, each by its own verdict line:
pnpm --filter @objectstack/plugin-security test—Test Files 80 passed (80)/Tests 1523 passed (1523),os-verify-lock: VERDICT command-exit 0pnpm --filter @objectstack/plugin-security typecheck—VERDICT command-exit 0pnpm check:type-check-debt(the ratchet half,--re-measure, run on a built closure) —check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 243.5s, 1898 raw tsc error(s) total, none above its recorded number.@objectstack/plugin-securityis not among thedrifted; the one reported surplus is
@objectstack/plugin-approvals(−1, an entry that can belowered), a package this PR does not touch. The ledger was not raised.
pnpm check:type-check-coverage—OK — 65/78 workspace packages type-checked … 19 package(s) still hide their own testspnpm check:engine-double-contract—OK — 405 pinned, 133 in the DEBT ledger, 2 exempt.pnpm check:where-matcher—297 matcher(s) discovered, 297 answer the combinator battery correctly or refuse it loudly … none new.pnpm check:query-options-erasure—ratchet holds: 67 unswept non-test site(s) … none new·test surface: 240 site(s) … at the ceilingpnpm check:cross-package-test-inputs—OK: 16 package(s) read outside themselves, all declaredpnpm check:test-source-alias—OK — 72 packages with tests scannedpnpm check:nul-bytes—OK (scanned 6655 text file(s) … no raw ASCII control bytes)pnpm check:i18n—OK (9 package(s) — all bundles in sync, no undeclared authoring keys).first run returned
PREREQUISITE NOT MET — the workspace CLI is not built, which is not measured,never a pass; re-run green after the closure build.
pnpm lint(the full repo scan,eslint . --no-inline-config) —VERDICT command-exit 0, so nonarrowing is claimed here
check:published-files,check:slot-lookup,check:type-source-resolution,check:changeset-gate-self-tests,check:objectui-changeset,check-plugin-teardown-shape,check-ci-filter-parity,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,release-rehearsal-clone --self-testGate family derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(
gate list derived from the tree of 'objectstack-ai/objectstack' at commit cb9d109c25), not from arecalled list.
Changeset:
.changeset/seed-name-lookup-unscoped-page-budget.md(@objectstack/plugin-security: patch)— the behaviour change is stated there too, since it is what a reader of the release notes needs.
Generated by Claude Code