Skip to content

perf(core): batch the independent reads in resolveUserAuthzGrants — 8 sequential legs become 4 - #10980

Closed
os-elon wants to merge 2 commits into
mainfrom
claude/issue-10825-batch-authz-grants
Closed

perf(core): batch the independent reads in resolveUserAuthzGrants — 8 sequential legs become 4#10980
os-elon wants to merge 2 commits into
mainfrom
claude/issue-10825-batch-authz-grants

Conversation

@os-elon

@os-elonos-elon commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Fixes#10825

resolveUserAuthzGrants — legs 6–13 of every authenticated data request — issued
eight reads one awaited call at a time. Five of them build their where entirely
out of the two inputs (userId, tenantId): sys_user, sys_member {user_id},
sys_user_position, sys_member {organization_id} and sys_user_permission_set.
Not one feeds another's filter; nothing but the await kept them apart. They now
go out in a single wave.

Result: legs and queries, measured separately

legsqueries
fully-populated principal8 → 48 → 8

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.ts opens a new wave
when a query is issued while nothing is in flight, so every query launched inside
one Promise.all lands in one bucket and every awaited-in-turn read opens its
own. 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:

fixturelegsqueries
empty-principal5 → 25 → 5
multi-org-membership6 → 26 → 6
position-derived-grants8 → 48 → 8
permission-set-derived-platform-admin7 → 37 → 7
ai-seat5 → 25 → 5
ai-seat-already-seeded-plus-email4 → 24 → 4
seeded-permissions-and-email6 → 36 → 6
deactivated-position-and-set7 → 47 → 7
validity-windows6 → 36 → 6
fellow-org-over-200-members6 → 26 → 6
org-less-principal-with-org-scoped-rows7 → 47 → 7
no-engine0 → 00 → 0

Against 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:

LEG 2 sys_position { name: $in [ position NAMES ] }
LEG 3 sys_position_permission_set { position_id: $in [ position IDS ] }
LEG 4 sys_permission_set { id: $in [ permission-set IDS ] }

sys_position_permission_set.position_id is a lookup to sys_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.position
carry — 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:

  • a denormalisation so the junction is reachable by position name — a
    packages/spec contract change, and packages/spec is off limits here; or
  • a driver-side join (expand), which a caller-supplied ql double may
    silently ignore. resolveUserAuthzGrants accepts ql: any and is called with
    test doubles, the verify harness and the automation engine's engine. An
    engine that ignores expand would return the junction rows without the
    permission 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) and
executed side by side with the batched one over the same twelve fixtures, through
an in-memory engine that honours limit and logs every query tuple. Both sides
were compared on:

  1. the whole resolved envelope, row for row, array order included
    positions, permissions, systemPermissions, tabPermissions,
    org_user_ids, accessible_org_ids, posture, email; and
  2. the full query log as { object, where, limit, context } tuples.

All twelve matched on both. resolve-authz-grants-batching.test.ts then pins those
measured 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) · the ai_seat read ·
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_member reads — same
object, 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-members fixture carries exactly 251 peers and the control
asserts two sys_member reads with limits [200, 1000] that still share one wave.

Note this is invisible to the suite's existing makeQl double, which ignores
limit — 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:

mutationcontrol
drop organization_id from the fellow-org read7 failed / 33 passed
fold the two sys_member reads into one12 failed / 28 passed
widen the final $in (sys_permission_set → unfiltered)9 failed / 31 passed
lower the peer read's limit 1000 → 2006 failed / 34 passed

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-code 0, git status --porcelain empty.

src/dist in both directions: the control imports the subject relatively
inside @objectstack/core, so vitest resolves it from src/ — which the mutation
runs prove directly (they reddened with no rebuild). For the downstream consumer
sweep the direction is the opposite — those packages resolve @objectstack/core
through its exports to dist/ with no alias — so packages/core was rebuilt
first 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

predictionoutcome
query count unchangedheld — identical in all 12 fixtures
authorization decision unchanged for every fixture principalheld — whole envelope deep-equal, all 12
no read outside legs 6–13 movesheld — the diff touches resolveUserAuthzGrants only; resolveApiKeyAdmission (legs 1–5) and resolveLocalizationContext (#10826's three sys_setting reads) are untouched
sys_user still read at most onceheld
a principal arriving with both a seeded email and a seeded ai_seat still reads zerosys_user rowsheld — the hoist is conditional for exactly this reason
the card's 2–3 leg estimate is reachablemissed — the floor is 4; see above

One knowingly accepted, zero-leg difference: the sys_user hoist decides from the
seeds 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 set
exists in the platform catalogue or any fixture (ai_seat appears in the repo only
as 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 under
flock /tmp/os-heavy-verify.lock, exits captured before any pipe.

Affected packagepnpm --filter @objectstack/core test:
Test Files 38 passed (38) · Tests 927 passed (927), of which the new control
contributes Tests 40 passed (40).

Downstream consumer sweep — every package that calls resolveUserAuthzGrants
or resolveAuthzContext. The filter direction is DOWNSTREAM consumers; packages/core
was rebuilt first because none of them alias it back to source:

packageresult
@objectstack/rest133 files · 2173 tests passed
@objectstack/runtime179 files · 2679 tests passed
@objectstack/plugin-security69 files · 1348 tests passed
@objectstack/plugin-hono-server20 files · 225 tests passed
@objectstack/plugin-sharing25 files · 624 tests passed
@objectstack/service-automation84 files · 998 tests passed
@objectstack/verify8 files · 36 tests passed
@objectstack/cloud-connection24 files · 210 tests passed
@objectstack/dogfood122 files passed, 1 skipped · 884 passed, 3 skipped

turbo: Tasks: 33 successful, 33 total · 36 successful, 36 total ·
62 successful, 62 total.

Gate unionnode scripts/pm/dispatch-gates.mjs with no path arguments, at
0727739d71; every gate it named was run and each printed its own verdict:

gateverdict
check:authz-resolverexit 0
check: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 unjudged
check:engine-double-contractexit 0
check: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 number
check:type-check-coverageOK — 64/77 workspace packages type-checked (plus the root), 13 in the DEBT ledger
check:cross-package-test-inputs · check-cross-package-test-inputs.mjsexit 0
check:test-source-alias · check:type-source-resolutionexit 0
check:kernel-hook-pairs · check:slot-lookup · check:query-options-erasureexit 0
check:changeset-gate-self-tests · check:objectui-changesetexit 0
check-adr-0087-registration · check-changeset-no-major · check-empty-changesetexit 0
check-ci-filter-parity · check-plugin-teardown-shape · check-affected-docsexit 0
check:nul-bytes (any edit)exit 0

check:where-matcher was 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 on
any combinator it does not implement); the baseline was not touched.

check:type-check-debt first refused to measure (@objectstack/service-knowledge
had no built type entry point); the workspace closure was built and it was re-run, which
is the line quoted above. Its plugin-auth surplus note (-12) is pre-existing, tracked
in #6376, and untouched by this change.

Not done here, on purpose


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/core, touching 6 documentable anchor(s).

28 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 28ad84af26ff3cac00cfd10d9868f8d3a45780f3.

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

What this run could not see
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 23 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 28ad84af26ff3cac00cfd10d9868f8d3a45780f3packageMentionDocs.

Which tree this was computed on

This run read content/docs from 298a2ace0a612ae44aa3fbe1e24e7efc36106f68 — the merge of head 0727739d7167f2681af66909347415b830ce7bc8 into base 28ad84af26ff3cac00cfd10d9868f8d3a45780f3, 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 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

⚠️ 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 28ad84af26ff3cac00cfd10d9868f8d3a45780f3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-elonClaude

Copy link
Copy Markdown
CollaboratorAuthor

Closing per maintainer ruling: #10825 goes to the epic seat (session 30b1d4ba-aaec-4c03-a7b4-d0d9c746cb8a, Round A), which claimed it 27 minutes after this seat did while both loops were dispatching the same backlog. Not closed for any defect — all gates were green and the equivalence proof was complete.

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 claude/issue-10825-batch-authz-grants (0727739d71) is left in place as reference only — do not merge it.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Batch resolveUserAuthzGrants: 8 sequential round trips could be 2-3, with no caching and no staleness

2 participants

@os-elon@claude