Uh oh!
There was an error while loading. Please reload this page.
perf(core): batch the independent reads in resolveUserAuthzGrants — 8 sequential legs become 4 - #10980
perf(core): batch the independent reads in resolveUserAuthzGrants — 8 sequential legs become 4#10980os-elon wants to merge 2 commits into
Conversation
…legs -> 4) (#10825) Five of the resolver's reads build their `where` entirely out of the two inputs (userId, tenantId) and never feed each other's filters; only the `await` kept them apart. They now go out in one wave. The remaining three are a foreign-key chain (position names -> sys_position.id -> sys_position_permission_set -> sys_permission_set), so four legs is this data model's floor. Nothing is merged, cached or deleted: the query count is unchanged (8 -> 8) and every read keeps its object, where, limit and context. The two sys_member reads stay two reads because their limits differ (200 / 1000) and folding them would truncate the fellow-org peer list. Equivalence is pinned against goldens captured by running the pre-batch resolver itself over twelve principal shapes, with round trips measured directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
…implement check:where-matcher flagged the probe's matcher as combinator-blind: a `$or` read as a column name matches nothing, and "nothing" on the authorization path reads as a principal holding no grant — the assertions would then agree with a resolver that had stopped working. It now throws on any `$`-prefixed key and on any operator object other than `$in`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
📓 Docs Drift CheckThis PR changes 1 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 23 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 298a2ace0a612ae44aa3fbe1e24e7efc36106f68 && git checkout 298a2ace0a612ae44aa3fbe1e24e7efc36106f68
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 28ad84af26ff3cac00cfd10d9868f8d3a45780f3 0727739d7167f2681af66909347415b830ce7bc8 && git checkout -B drift-repro 28ad84af26ff3cac00cfd10d9868f8d3a45780f3 && git merge --no-ff 0727739d7167f2681af66909347415b830ce7bc8
node scripts/docs-audit/affected-docs.mjs --json 28ad84af26ff3cac00cfd10d9868f8d3a45780f3
|
os-elon
commented
Aug 22, 2026
Closing per maintainer ruling: #10825 goes to the epic seat (session The measurements are handed over in issuecomment-5377361827 rather than discarded. The load-bearing one: the card's 2–3 leg target is not reachable — 4 is this data model's floor, because the remaining three legs are a foreign-key chain in which each filter is the previous read's output. Branch Generated by Claude Code |
Fixes#10825
resolveUserAuthzGrants— legs 6–13 of every authenticated data request — issuedeight reads one awaited call at a time. Five of them build their
whereentirelyout of the two inputs (
userId,tenantId):sys_user,sys_member {user_id},sys_user_position,sys_member {organization_id}andsys_user_permission_set.Not one feeds another's filter; nothing but the
awaitkept them apart. They nowgo out in a single wave.
Result: legs and queries, measured separately
Queries are deliberately unchanged. Nothing was merged, nothing was deleted,
nothing is cached. Legs 9 and 13 duplicate reads made earlier in the request and
belong to #10757 directions 1/2 — they are batched here, not dropped, so this
card's leg count is its own win and not someone else's deletion.
How the leg count was established — measured, not inferred from the query
count. The probe in
resolve-authz-grants-batching.test.tsopens a new wavewhen a query is issued while nothing is in flight, so every query launched inside
one
Promise.alllands in one bucket and every awaited-in-turn read opens itsown. That is the card's own definition ("a batch that runs 3 queries in parallel
is 1 leg; 3 sequential is 3"), applied directly to the call sequence. The full
per-shape measurement:
empty-principalmulti-org-membershipposition-derived-grantspermission-set-derived-platform-adminai-seatai-seat-already-seeded-plus-emailseeded-permissions-and-emaildeactivated-position-and-setvalidity-windowsfellow-org-over-200-membersorg-less-principal-with-org-scoped-rowsno-engineAgainst cloud#1539's measured model (
server_ms ≈ 33 + L × 36.6, R² = 0.9994)this removes ~4 of an authenticated request's 23.4 legs — about 150 ms of ~890 ms.
Four legs is the floor, not an unfinished job
The card estimated 2–3. Traced against the schema, the remaining three legs are a
foreign-key chain in which each filter is the previous read's output:
sys_position_permission_set.position_idis a lookup tosys_position.id(
packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts),and position names are all that
sys_member.role/sys_user_position.positioncarry — so LEG 3 cannot be issued before LEG 2 turns names into ids, and LEG 4
cannot be issued before LEG 3 yields the position-bound set ids.
Going below 4 needs one of two things this card may not do:
packages/speccontract change, andpackages/specis off limits here; orexpand), which a caller-suppliedqldouble maysilently ignore.
resolveUserAuthzGrantsacceptsql: anyand is called withtest doubles, the
verifyharness and the automation engine's engine. Anengine that ignores
expandwould return the junction rows without thepermission sets — fewer grants, no error. That is the one failure shape
this path must never have, so it is reported rather than taken.
This is a finding about the reads, not a shortfall: the estimate predates the
dependency trace.
The equivalence proof is the deliverable
Fence 1 is the acceptance criterion — a batched read that quietly returns a
different row set does not fail, it grants differently, and every functional suite
stays green while it does. So equivalence is run, not argued.
Method. The pre-batch module was extracted verbatim from the merge-base
(
git show 926778bce0:packages/core/src/security/resolve-authz-context.ts) andexecuted side by side with the batched one over the same twelve fixtures, through
an in-memory engine that honours
limitand logs every query tuple. Both sideswere compared on:
positions,permissions,systemPermissions,tabPermissions,org_user_ids,accessible_org_ids,posture,email; and{ object, where, limit, context }tuples.All twelve matched on both.
resolve-authz-grants-batching.test.tsthen pins thosemeasured outputs as goldens (40 assertions), so the comparison is reproducible by
anyone rather than being a claim in a PR body.
Shapes covered: multi-org membership · position-derived grants ·
permission-set-derived
platform_admin(scoped vs unscoped) · theai_seatread ·an empty/no-grant principal · deactivated position + deactivated permission set ·
ADR-0091 validity windows · seeded API-key scopes + session email · an org-less
principal holding org-scoped rows · a 251-member organization · no engine at all.
The plausible-divergence case, made concrete
The tempting "optimisation" here is folding the two
sys_memberreads — sameobject, and the caller's own rows are a subset of the active org's. They carry
different limits for different reasons: 200 bounds how many organizations one
user may belong to, 1000 bounds how many collaborators an org may have. Folded at
200, an organization with 251 members hands RLS a peer list missing 51 people — a
narrowed read scope, no error, nothing functional to notice it. The
fellow-org-over-200-membersfixture carries exactly 251 peers and the controlasserts two
sys_memberreads with limits[200, 1000]that still share one wave.Note this is invisible to the suite's existing
makeQldouble, which ignoreslimit— hence the new probe. The general form of that gap is filed unassigned as#10978 (
finding); it is not fixed here.Falsification — the control was proven able to fail
A preserved-behaviour control is worthless until a mutation reddens it. Four
mutations of the batch, each applied to the committed implementation, run, then
restored:
organization_idfrom the fellow-org readsys_memberreads into one$in(sys_permission_set→ unfiltered)Row-equivalence assertions — not only the query-log ones — go red in every case.
Restoration proven on disk after each:
git hash-object PATH==git rev-parse HEAD:PATH(e4a6daead818ab26048a4445e7bed4a27d7a7718),git diff --exit-code0,git status --porcelainempty.src/distin both directions: the control imports the subject relativelyinside
@objectstack/core, so vitest resolves it fromsrc/— which the mutationruns prove directly (they reddened with no rebuild). For the downstream consumer
sweep the direction is the opposite — those packages resolve
@objectstack/corethrough its
exportstodist/with no alias — sopackages/corewas rebuiltfirst and the change proven present in the artifact:
node scripts/ablation-dist-preflight.mjs packages/core 'wantUserRow'→✓ marker present in 2 built files.Expected non-effects, named before the runs
resolveUserAuthzGrantsonly;resolveApiKeyAdmission(legs 1–5) andresolveLocalizationContext(#10826's threesys_settingreads) are untouchedsys_userstill read at most onceai_seatstill reads zerosys_userrowsOne knowingly accepted, zero-leg difference: the
sys_userhoist decides from theseeds whether the pre-batch code would have read the row. That answer differs
only if a resolved permission set is literally named
ai_seat— no such setexists in the platform catalogue or any fixture (
ai_seatappears in the repo onlyas the synthesised capability). In that one case this issues one extra query
inside an existing wave, zero extra round trips, and the row is still consumed
behind the unchanged guard.
Verification
Everything below was run at
0727739d71(the final commit), heavy steps underflock /tmp/os-heavy-verify.lock, exits captured before any pipe.Affected package —
pnpm --filter @objectstack/core test:Test Files 38 passed (38)·Tests 927 passed (927), of which the new controlcontributes
Tests 40 passed (40).Downstream consumer sweep — every package that calls
resolveUserAuthzGrantsor
resolveAuthzContext. The filter direction is DOWNSTREAM consumers;packages/corewas rebuilt first because none of them alias it back to source:
@objectstack/rest@objectstack/runtime@objectstack/plugin-security@objectstack/plugin-hono-server@objectstack/plugin-sharing@objectstack/service-automation@objectstack/verify@objectstack/cloud-connection@objectstack/dogfoodturbo:
Tasks: 33 successful, 33 total·36 successful, 36 total·62 successful, 62 total.Gate union —
node scripts/pm/dispatch-gates.mjswith no path arguments, at0727739d71; every gate it named was run and each printed its own verdict:check:authz-resolvercheck:where-matcher✓ where-matcher conformance holds: 276 matcher(s) discovered, 276 answer the combinator battery correctly or refuse it loudly (166 refuse). 0 silently-wrong and 0 unjudgedcheck:engine-double-contractcheck:type-check-debt--re-measure: OK — 33 ledger entr(ies) re-measured in 327.5s, 1908 raw tsc error(s) total, none above its recorded numbercheck:type-check-coverageOK — 64/77 workspace packages type-checked (plus the root), 13 in the DEBT ledgercheck:cross-package-test-inputs·check-cross-package-test-inputs.mjscheck:test-source-alias·check:type-source-resolutioncheck:kernel-hook-pairs·check:slot-lookup·check:query-options-erasurecheck:changeset-gate-self-tests·check:objectui-changesetcheck-adr-0087-registration·check-changeset-no-major·check-empty-changesetcheck-ci-filter-parity·check-plugin-teardown-shape·check-affected-docscheck:nul-bytes(any edit)check:where-matcherwas red on the first run — it caught the new probe reading a$-combinator as a column name. Repaired at the author's end (the probe now throws onany combinator it does not implement); the baseline was not touched.
check:type-check-debtfirst refused to measure (@objectstack/service-knowledgehad no built type entry point); the workspace closure was built and it was re-run, which
is the line quoted above. Its
plugin-authsurplus note (-12) is pre-existing, trackedin #6376, and untouched by this change.
Not done here, on purpose
staleness. A permission revoked at T is gone at T. That is Authenticated data requests issue ~20 DB queries in ~17 sequential legs — per-request auth/session/localization/metadata resolution has no cross-request caching, costing ~1.5s/request on remote Postgres #10757 tranche 2.
packages/specuntouched.resolveLocalizationContextuntouched — that file region is resolveLocalizationContext reads the same sys_setting namespace three times per request #10826's.Generated by Claude Code