Skip to content

perf(core): batch resolveUserAuthzGrants' independent reads — 8 sequential legs become 4 (#10825) - #10981

Closed
os-zhuang wants to merge 2 commits into
mainfrom
claude/issue-10825-batch-grant-legs
Closed

perf(core): batch resolveUserAuthzGrants' independent reads — 8 sequential legs become 4 (#10825)#10981
os-zhuang wants to merge 2 commits into
mainfrom
claude/issue-10825-batch-grant-legs

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Refs #10757
Fixes#10825

resolveUserAuthzGrants is legs 6–13 of every authenticated request. It read eight tables one after another, each await blocking the next, although only three of the seven edges between them are real data dependencies.

cloud#1539 established causally (latency injection, R² = 0.9994) that server time follows ≈ 33 + L × 36.6 ms where L is the count of sequential legs, not of queries — "L, not N, is the multiplier. Batching is worth exactly as much as deleting." So this card's win is measured in legs, and the query count is deliberately left alone.

What changed

The reads that depend on nothing are now issued in one wave. One genuine chain remains — sys_position needs the position names wave 1 produces, sys_position_permission_set keys on the position idssys_position produces, and sys_permission_set needs the union of directly- and position-granted set ids:

before after
────── ─────
1 sys_member {user_id} ┐
2 sys_user_position │
3 sys_member {organization_id} ├─ wave 1 (Promise.all)
4 sys_user_permission_set │
5 sys_user {id} (ai_seat) ┘
6 sys_position {name $in} ── wave 2
7 sys_position_permission_set ── wave 3
8 sys_permission_set {id $in} ── wave 4
queriessequential legs
before88
after84

Fewer than four when the principal has less to resolve: 3 with no active sys_position row backing any position name, 2 with no permission sets at all. Per-fixture leg counts are written out in BATCHED_LEGS in the test file rather than derived, so a regression that re-serialises one read fails as a number instead of as a slower suite.

Four is the floor, not the card's hoped-for two or three. Collapsing waves 2→3 needs sys_position_permission_set filtered by position name, i.e. a join or a relationship traversal. resolveUserAuthzGrants takes ql: any and is called with several different engines and test doubles; a traversal one of them silently mis-handles returns the wrong rows on the authorization path with nothing going red. That trade is not worth one leg, and it would need a contract this card is fenced out of.

No caching

Nothing survives a request. Every read is live, so a grant revoked at T is not honoured at T+1; there is no TTL, no invalidation contract and no staleness window. That is why this is separable from #10757's caching tranche and why it can ship on its own review.

Legs 9 and 13 are batched, not deleted

Both duplicate a read made earlier in the request. PR #10824 measured that the removable half of each pair belongs to directions 1 and 2, so removing them here would be claiming another card's work. They are in wave 1, still issued, still live.

How the leg count was established

Three independent methods, because the query count is not the leg count and cannot be converted into one:

  1. Latency injection — cloud#1539's own method, applied to this function: every read costs a fixed D ms, so wall clock / D is the leg count regardless of how many queries a leg contains. At D = 50: 412 ms → 206 ms (8 legs → 4). At D = 25: 230 ms → 106 ms. Both runs resolve an identical grants envelope.
  2. A leg-counting engine double in the test suite. Every read yields on a real macrotask boundary before answering, so reads issued together are genuinely in flight together; a read that starts while nothing is in flight opens a leg, one that starts while another is in flight joins the open one. Sequential awaits count one leg each and a Promise.all of any width counts one. Asserted per fixture across the 11-shape matrix.
  3. The await graph in the source — four await points on the longest path.

⚠️ A local rig cannot show this as latency: better-sqlite3 is synchronous and in-process, so there is no round trip to save. That is exactly why cloud#1539 measured by injection rather than by reading a trace. What the live rig is used for below is equivalence, not speed.

Equivalence — proven, not asserted

On an authorization path a batch that returns even one different row is a privilege bug that a green suite cannot see. So the deliverable here is the differential control, not the passing suite.

packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts — every expectation was captured from the sequential implementation (git show 38bc74ed1:…/resolve-authz-context.ts) running against the same fixtures, then asserted against the batched one. Two independent goldens per fixture:

  1. The whole resolved envelope, deep-equal including array orderpositions, permissions, systemPermissions, org_user_ids, accessible_org_ids, tabPermissions, posture, email.
  2. The exact multiset of {object, where, limit} triples issued, each with context.isSystem === true, plus an equality assertion on the query count. This is the "same filters, same tenancy scoping, same limits" half: widening an $in, dropping a tenancy filter or merging two reads changes this list even when the envelope happens to agree. Multiset rather than sequence, because parallelising is a change of issue order — what must not change is which reads happen and with what.

The engine double enforces the limit the caller passes, like a real driver, so a changed limit is observable rather than theoretical.

Principal shapes covered

empty-principal · multi-org-membership · lapsed-own-membership-among-active-peers · position-derived-grants · permission-set-derived-grants · tenant-admin-via-position · ai-seat-and-email-from-sys-user · ai-seat-denied · seeded-permissions-and-email · read-limits-truncate · no-active-org — spanning MEMBER / TENANT_ADMIN / PLATFORM_ADMIN, ADR-0091 validity windows (lapsed, not-yet-valid, until exclusive), ADR-0049 deactivated positions and permission sets, org-scoped vs unscoped grants, and tab-permission merging.

The divergence case

The card asked for a case where the two could plausibly diverge. It is lapsed-own-membership-among-active-peers, and it is the reason the two sys_member reads are not merged.

sys_member {user_id} and sys_member {organization_id} now read the same table in the same wave, so the obvious next "improvement" is one $or read partitioned in memory. On this fixture that is a silent privilege escalation: the caller's own membership in org_a has lapsed while peers hold active owner/admin rows in it. A merged read feeds those peer rows to the accessible_org_ids loop (granting org_a — the entire read reach of the group posture) and to the org-role loop (granting org_owner, and with it TENANT_ADMIN). The golden pins the sequential answer: accessible_org_ids: [], no org_owner, posture MEMBER, with the peers still present in org_user_ids because that is what the fellow-org read is for.

read-limits-truncate covers the other named hazard — 205 own memberships against the 200 limit and 1005 peers against the 1000 limit, with truncation observable.

Ablation — the controls can fail

Each leg mutated the batch, proved the mutation on disk by counting both the injected marker and the deleted text (never an editor's exit code), ran, restored, and proved restoration by git hash-object == git rev-parse HEAD:PATH, git diff --exit-code = 0 and empty porcelain. The script carries trap … EXIT INT TERM so a mid-mutation kill cannot leave a mutated tree behind. No rebuild is involved: the test imports ./resolve-authz-context.js relative, inside its own package, so vitest resolves it to source — which the red results themselves demonstrate, since a dist-resolved test would have stayed green.

legmutationon-diskresult
Afellow-org read loses its tenancy scopingdel 1→0, ins 0→110 failed / 24 — query multiset on 8 fixtures, envelope on 2
Bsys_permission_set$in wideneddel 1→0, ins 0→16 failed / 28 — multiset on 4, envelope on 2
Csys_member {user_id} limit 200 → 1000del 1→0, ins 0→112 failed / 22 — multiset on 11, envelope on 1
Dthe wave re-serialised into sequential awaitsdel 1→0, ins 0→111 failed / 23 — only the leg assertion, on all 11

Leg D is the pair that makes both controls meaningful: re-serialising leaves both equivalence goldens green on all 11 fixtures and reddens only the leg count. The equivalence controls are therefore measuring rows rather than scheduling, and the leg control is measuring scheduling rather than rows.

⚠️Leg D's first run was a declared no-op. Its deleted-text anchor was ] = await Promise.all([, which also occurs in resolveLocalizationContextUncached in the same file, so the count read 2→1 instead of →0. The guard treated that as "did not land", restored, and reported it; the leg was re-run with a unique anchor (orgMembers, upsRowsAll] = await Promise.all([) and the numbers above are from that run. Reporting it rather than quietly re-running to a clean number is the point of counting both strings.

Live-rig equivalence, against the real SQL driver

pnpm dev:crm --fresh with DEBUG=knex:query, twice — once with packages/core built from 38bc74ed1 (sequential), once from this branch — with packages/core/dist/index.js verified on disk each time to carry the intended shape:

resolved envelope (GET /api/v1/auth/me/permissions, 12,169 bytes, ids normalised)byte-identical
per-request query count (Server-Timing: db;desc="N queries")1616
authorization SQL shapes issued (parametrized, X-OS-Debug-Timing: json)13identical multiset

That exercises the real ObjectQL engine and the real SQL driver, so the $in translations and tenancy predicates are checked as compiled SQL rather than only against a double.

Expected non-effects, named before the runs

  • No authorization decision changes for any fixture principal — held: 11/11 envelopes deep-equal, and the live envelope byte-identical.
  • No read outside legs 6–13 changes — held: the live per-request count is 16 before and after, and the authz shape multiset is identical.
  • No other request path moves — the diff is one function; downstream suites below are unchanged.
  • Query count must not move in either direction — asserted per fixture (an extra read would mean the batch speculated, a missing one that it elided a read the sequential path made). sys_user joins wave 1 only when it will actually be consulted, so a caller that supplied both an email and the ai_seat scope still causes no read at all (seeded-permissions-and-email pins this).

Verification

Gate union run after the final commit, on b3f2c26eb. Gate list derived by node scripts/pm/dispatch-gates.mjs with no path arguments; exits captured before any pipe.

gateexit
check:authz-resolver0
check:changeset-gate-self-tests0
check:cross-package-test-inputs (+ scripts/check-cross-package-test-inputs.mjs)0
check:kernel-hook-pairs0
check:slot-lookup0
check:test-source-alias0
check:type-source-resolution0
check:query-options-erasure0
check:type-check-coverage · check:type-check-debt0
check:engine-double-contract0
check:where-matcher0
check:nul-bytes0
scripts/check-adr-0087-registration.mjs · check-changeset-no-major.mjs · check-ci-filter-parity.mjs · check-empty-changeset.mjs · check-plugin-teardown-shape.mjs · docs-audit/check-affected-docs.mjs0
pnpm lint (full repo scan, eslint . --no-inline-config)0
check:objectui-changeset1 — host defect, see below

check:objectui-changeset cannot run on this macOS host. All 7 of its self-test failures are scripts/bump-objectui.sh: line 324: mapfile: command not found / status=127; mapfile is a bash ≥ 4 builtin and this host's shell is GNU bash 3.2.57. scripts/bump-objectui.sh is byte-identical to origin/main (git diff origin/main...HEAD -- scripts/bump-objectui.sh is empty) and this diff touches no objectui pin. CI runs it on ubuntu/bash 5.

Tests:

suiteresult
@objectstack/core38 files, 921 passed
@objectstack/plugin-hono-server20 files, 225 passed
@objectstack/plugin-security69 files, 1348 passed
@objectstack/plugin-sharing25 files, 624 passed
@objectstack/service-automation84 files, 998 passed
@objectstack/runtime179 files, 2680 passed
@objectstack/rest133 files, 2173 passed

Those seven are the direct consumers of resolveAuthzContext / resolveUserAuthzGrants, found by grepping the call sites; the rest of the farm is CI's run. Consumer direction is downstream (...@objectstack/core). @objectstack/rest first showed one failure — import-integration.test.ts > parses a native xlsx workbook server-side, Test timed out in 5000ms on a cold exceljs dynamic import while six suites ran concurrently on one machine. Re-run alone: 31/31 green. It touches no authorization code.

@objectstack/core declares no typecheck script (it is ledger-covered), so type resolution is verified through its tsup DTS build, which succeeded, and through check:type-check-debt --re-measure on the built closure.

Out-of-scope finding

Filed as #10982, unassigned, and not addressed here: sys_member's org-role projection into positions skips the ADR-0091 validity window that the accessible_org_ids derivation from the same rows applies, so a lapsed membership would keep conferring the TENANT_ADMIN rung while granting no org access. Latent today — sys_member declares no validity columns — but the comment beside the honouring half promises it will "correct the moment they do", which holds for that half only. It changes authorization semantics, so it wants its own review rather than a rider on a batching PR. The lapsed-own-membership-among-active-peers golden added here pins the current behaviour either way.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/approvals.mdx(via sys_user_position (literal))
  • content/docs/data-modeling/objects.mdx(via sys_user_permission_set (literal), sys_user_position (literal))
  • content/docs/deployment/tenancy-modes.mdx(via sys_member (literal))
  • content/docs/permissions/administrator-guide.mdx(via sys_user_position (literal))
  • content/docs/permissions/authentication.mdx(via sys_member (literal), sys_user_position (literal))
  • content/docs/permissions/authorization.mdx(via sys_user_permission_set (literal), sys_user_position (literal))
  • content/docs/permissions/delegated-administration.mdx(via sys_member (literal), sys_user_permission_set (literal), sys_user_position (literal))
  • content/docs/permissions/permission-sets.mdx(via sys_member (literal), sys_user_permission_set (literal), sys_user_position (literal))
  • content/docs/permissions/positions.mdx(via sys_member (literal), sys_user_position (literal))

6 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via sys_member (literal), sys_user_permission_set (literal))
  • content/docs/releases/v13.mdx(via sys_user_permission_set (literal), sys_user_position (literal))
  • content/docs/releases/v14.mdx(via sys_user_permission_set (literal), sys_user_position (literal))
  • content/docs/releases/v15.mdx(via sys_user_position (literal))
  • content/docs/releases/v16.mdx(via resolveUserAuthzGrants (symbol), sys_member (literal), sys_user_permission_set (literal), sys_user_position (literal))
  • content/docs/releases/v17.mdx(via resolveUserAuthzGrants (symbol), sys_member (literal), sys_user_permission_set (literal), sys_user_position (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json) — pages documenting those are invisible to this run
  • 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 fb669a832bff321fd15ab60064907cdb313004f0 — the merge of head b3f2c26eb813aba71b71af4e60f803f5051ce3c8 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 fb669a832bff321fd15ab60064907cdb313004f0 && git checkout fb669a832bff321fd15ab60064907cdb313004f0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 28ad84af26ff3cac00cfd10d9868f8d3a45780f3 b3f2c26eb813aba71b71af4e60f803f5051ce3c8 && git checkout -B drift-repro 28ad84af26ff3cac00cfd10d9868f8d3a45780f3 && git merge --no-ff b3f2c26eb813aba71b71af4e60f803f5051ce3c8
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-zhuang

Copy link
Copy Markdown
ContributorAuthor

Closing: superseded by the earlier claim (00:48:59Z vs my 01:16:09Z) — PR #10980 is the surviving implementation. Full handoff, including a silent-privilege-escalation golden worth cherry-picking, on the issue: #10825. Branch left in place for cherry-picks.

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-zhuang@hotlong