Repository navigation
fix(core)!: org_user_ids holds every member of an organization with more than 1,000 members - #29356
Conversation
…en org_user_ids' first read comes back at its bound The fellow-org peer list that scopes sys_user reads was built from one sys_member read bounded at 1,000 rows, so in an organization with more than 1,000 members the members past the 1,000th sys_member.id were silently missing for every ordinary member (search empty, read by id 404). A first read under the bound is still the whole membership and nothing else is read; a first read at the bound is now a first page, and the membership is read to completion in id-ordered keyset pages, said once per organization. Claude-Session: https://claude.ai/code/session_01ADHkmSAV6tENpyY8j1VYz5 Co-authored-by: Claude <noreply@anthropic.com>
…ast it; record the completed peer list in the batch-equivalence golden A short page is the membership's tail and is taken as read; a full page's rows must carry the id the next page resumes after. The batch-equivalence fixture whose fellow-org read comes back at its bound now records the six members the bound used to cut, the completion read, and a third leg (the move is written into the test header as its rule requires). Adds the patch changeset. Claude-Session: https://claude.ai/code/session_01ADHkmSAV6tENpyY8j1VYz5 Co-authored-by: Claude <noreply@anthropic.com>
…-imac18 Claude-Session: https://claude.ai/code/session_01ADHkmSAV6tENpyY8j1VYz5 Co-authored-by: Claude <noreply@anthropic.com>
…ound, which the completion read finds already complete Claude-Session: https://claude.ai/code/session_01ADHkmSAV6tENpyY8j1VYz5 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 39 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 5672e540605ff4f3060b23beee29629165c1377a && git checkout 5672e540605ff4f3060b23beee29629165c1377a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 57cde8cb3da5970f2c3f6512569cacdb9b2b06a8 c1b4f69fc606705e82b5ac1db705b6bf058dcc28 && git checkout -B drift-repro 57cde8cb3da5970f2c3f6512569cacdb9b2b06a8 && git merge --no-ff c1b4f69fc606705e82b5ac1db705b6bf058dcc28
node scripts/docs-audit/affected-docs.mjs --json 57cde8cb3da5970f2c3f6512569cacdb9b2b06a8
|
… past the database's bound-parameter limit Claude-Session: https://claude.ai/code/session_01ADHkmSAV6tENpyY8j1VYz5 Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 Contract-review-claim: session_01ADHkmQJdgarUjPLNgFBY67 · head c1b4f69 · 2026-11-02T13:51Z |
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 Contract reviewServed-tier: Inputs read: card #29281 body and all seven comments (triage 6575447789 and 6575719368, claim 6576308060, os-dev-report 6577634380, claim amendment 6577655349, seat ruling 6577690137), PR #29356 body and file list, ① Derived judgmentsAccept-set and surface changes the diff implies, each judged against the tree:
② Semver level
③ Boundary flagsDev
Dev Dev Forced batch-equivalence edits (golden, test header, ④ Design judgmentAgainst the published spec on Trying to break it:
Findings, graded:
No must-fix. The tree, not the body, was judged: every claim in the body I checked (first read unchanged, cursor ANDed, 503 on failed/non-advancing page, warn once, exactly-1,000 silent, no spec change, no export change) is what the diff does. Implemented-by: VERDICT: PASS |
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 席位核对 · ACCEPT · PR #29356 · 2026-11-02T14:04ZHead-sha: 维护者速读(席位终稿)
核对六项
|
Requested by Steve · project thread
Fixes #29281
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no skill or doc teaches the 1,000 bound or a size limit for the organization peer list;
content/docs/permissions/rls.mdxdescribescurrent_user.org_user_idsas "User ids in the user's org", which this makes true, and the bind-parameter ceiling is the interim's, removed by the relational predicate (#29360).The claim declared
nofor an interim that reads the membership to completion. The seat's ruling (6577690137 on #29281) reads the refusal past the bound-parameter limit as a narrowing, so this PR carries!and the full dogfood battery.维护者速读
What changes
sys_user's organization-members row scope isid in current_user.org_user_ids: the object'sdefaultRowScopeandmember_default'ssys_user_org_members. The authorization resolver builds that list per request from onesys_memberread for the active organization, bounded at 1,000 rows. The bound used to be the list. In an organization with more members, everyone past the 1,000thsys_member.idwas left out with no signal: a people picker's search found nothing, and a read by id answered404.This PR is the server-only interim the card's ruling asks for. It makes no spec change and changes no metadata.
countPositionHoldersgives its own reads. §5 then callsreadOrganizationMembersToCompletion(packages/core/src/security/resolve-authz-context.ts). It is a keyset read of the active organization'ssys_memberrows: ordered byid,id > lastper page, 10,000 rows per page, columnsidanduser_idonly. It starts over from the first id, because the first read asked for no order and its 1,000 rows need not be the lowest ids.AuthzStoreUnavailableError(503). So do a page that repeats an id an earlier page returned (a driver that ignored the cursor) and a full page whose rows carry no id. None of them ever answers a shorter list.console.warnper organization per engine names the organization, its member count and the cost: every request in that organization reads its whole membership, and eachsys_userread binds one parameter per member. It is awarnand not anerrorunder AGENTS.md's degradation rule, because nothing is lost: the list is complete.tryFindtakes an optionalorderBy. Changeset:@objectstack/corepatch, with a BREAKING banner and an ADR-0087 disposition (not-required (no-migration-prescription)).sys_userread by its members now fails loudly at the driver (500) where it used to answer a list missing everyone past the 1,000th member. That is the only accept-set change; below the limit, nobody gains or loses visibility of anyone.Who sees whom
sys_member.id, with the rest silently missingEvery completion page filters
organization_id = <active org>, and the cursor is added to that filter, never in place of it. I found no path where the old bound or the new read could add someone who is not a member. This is a visibility bug that hid rows.Options weighed (driver-sql over better-sqlite3 in process, median of three, on a shared box)
Every request pays the downstream
sys_userread with the full list:sys_usersearchtoo many SQL variables(500)The truncated 1,000-id list cost about 6 to 8 ms.
Does the RLS compiler already have a relational form? No. ADR-0139's
ReadableIdssemi-join leaf is engine-internal and cannot be authored from a scope. It projects the parent'sid, notsys_member.user_id, and its inner filter is the caller's read scope, notorganization_id = <tenant>.rls.zod.ts§7.3.1 states that subquery-shaped needs are pre-resolved. Replacing the list therefore needs an ADR and the spec lane. The design notes are in the card's os-dev-report, and no ADR file is drafted here.Why
The card's ruling is that the visible set must stay exactly the same: every member, and nobody else. On
mainan ordinary member of an organization with 1,001 members could not find the member whosesys_member.idsorts 1,001st, by search or by id. The ruling also says the pre-resolved list is to be replaced by a relational predicate, which is an ADR and spec-lane change. This PR is the interim the card asks for in the meantime: it keeps the set exact up to the backend's bind-parameter limit, fails loudly past it, and never shrinks the scope silently.Tests
packages/core/src/security/resolve-authz-context.org-members-past-first-read.test.ts(new, 8 tests). It uses a double that honours equality,$gt,orderByandlimit, and answers an unordered read in insertion order, which is not id order.packages/plugins/plugin-security/src/sys-user-org-members-past-first-read.test.ts(new, 4 tests). It runs a realObjectQLover a realSqlDriver(better-sqlite3), the real identity objects, the realSecurityPluginfalling back to the shippedmember_default, and a principal fromresolveUserAuthzGrants.sys_member.idsorts 1,001st is found by search and by id, and the full list is exactly the organization.maina19759ac. The core file was 5 failed and 1 passed at the time; the id-less and exactly-1,000 tests were added later and were shown red by ablation. The real-driver file was 3 failed and 1 passed: the 1,001-member test and the warning test failed on the defect, and the outsider pin passed onmainas it should. The third failure was the under-bound test, whose read counter then also matched another plugin'ssys_memberread; it was narrowed to the organization read afterwards. That test pins unchanged behaviour, so it is meant to pass onmain.scripts/ablation-replace.mjs, ran red, and was restored to an emptygit diff HEAD. Core'sdistwas rebuilt for the real-driver leg and checked withablation-dist-preflight.mjs.organization_idfilter: core 3 red; the real-driver file 3 red, includingexpected [...] to not include 'usr_outsider'.expected false to be true.expected "warn" to not be called at all.read-limits-truncateis the one batch-equivalence fixture whose fellow-org read comes back at its bound, with 1,006 rows):resolve-authz-context.batch-equivalence.golden.json:grants.org_user_idsgains the six members the bound cut (peer_0999topeer_1004), andqueriesgains the completion read. No other golden moved.resolve-authz-context.batch-equivalence.test.ts: a fifth "recorded move" paragraph in the header.BATCHED_LEGS['read-limits-truncate']goes from 2 to 3, because the completion read is a leg of its own. The wave-1 filter excludes that read, which exists only because wave 1 came back at its bound.__tests__/resolve-authz-context.batch-equivalence.testkit.ts: a comment only. The 1,000 bound is still pinned by the recordedqueries, and the 200 bound byaccessible_org_ids.Acceptance notes
sys_userread in such an organization now fails loudly at the driver (500) where it used to answer a silently short200. On SQLite that limit was crossed between 32,000 and 40,000 members; Postgres's wire protocol caps a statement at 65,535 parameters. The seat ruled this a narrowing (6577690137), hence the!and the(narrowing)arm. The relational predicate (ADR card: membership-scoped RLS (id in current_user.org_user_ids) lowers to an engine-internal semi-join leaf instead of a pre-resolved id array, removing the per-request completion read and the bind-parameter ceiling #29360) removes the ceiling.warn, noterror. The card's example named anerrorline.warnfollows AGENTS.md's degradation rule, which reserveserrorfor silent loss, and here nothing is lost. The seat ruledwarn(6577690137), and the 10,000-row page ofidanduser_idtoo.mongoddownload answers 403. The completion read uses onlywhereequality,$gt,orderByandlimit.sys_memberreads in plugin-sharing and plugin-approvals as plugins: sharingexpandRoleand approvals role-tier recipients read sys_member at a 10,000-row bound and turn a failed read into an empty list, so a large or unreadable membership silently shrinks the set #29362. Also noted in the report:rlsMembershipkey has the same pre-resolved, bound-per-request shape.org_user_idsas a value: audit mention-recipient gate, explain engine, the engine's run-as seeding, hook wrappers and the current-user endpoints. The design report lists them.Verification
Final head
c1b4f69f. It is6b708b8aplus the changeset that declares the narrowing; no code changed.pnpm exec turbo run build --filter='@objectstack/dogfood^...'(exit 0, 68 tasks), thenpnpm --filter @objectstack/dogfood test(exit 0): Test Files 392 passed and 3 skipped (395), Tests 3227 passed and 28 skipped (3255). Lock VERDICT:command-exit 0, held 25m43s on a shared box.{"kind":"declared","value":"no","arm":"narrowing"}throughscripts/pm/clause2-line.mjs.check-adr-0087-registration --base origin/main: exit 0, "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … not-required (no-migration-prescription)".check-changeset-no-major,check-empty-changeset,check-issue-citations,check:nul-bytesandcheck:pm-changeset-deadline-censusall exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(the same list as round 1) were each run again with< /dev/null, and all 65 exited 0. The--ranreconciliation reports "65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero …)". Lock VERDICT:command-exit 0.c1b4f69f: every check run concluded success or skipped, including TypeScript Type Check, Test Core (8 shards), Lint & Repo Gates, Check Changeset and Dogfood Regression Gate. The two failures on6b708b8a(TypeScript Type Check, Test UI) were aggregators whose lanes were cancelled whenc1b4f69fwas pushed.Commands on the round-1 head
Head
6b708b8a, which includesorigin/mainat738971d2. Heavy runs went throughscripts/pm/os-verify-lock.sh, and every VERDICT wascommand-exit 0. Times are from a shared box.pnpm turbo run build --filter='@objectstack/plugin-security^...': exit 0, 18 tasks.pnpm --filter @objectstack/core test: exit 0, Test Files 130 passed (130), Tests 2917 passed (2917).pnpm --filter @objectstack/core typecheck: exit 0, includingcheck:test-typecheck.pnpm --filter @objectstack/plugin-security exec vitest run: exit 0, Test Files 240 passed (240), Tests 5175 passed and 45 skipped (5220). None of the skips is in the new file.pnpm --filter @objectstack/plugin-security typecheck: exit 0, includingcheck:test-typecheck, which covers the new test file.--no-inline-configon the five changed TypeScript files: exit 0.pnpm lint(repo-wide): exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 65 commands, and each ran with< /dev/null.check-plugin-teardown-shape --self-test(its pinned commit was not in the shallow clone),check:dual-build-cjs-loadsandcheck:i18n(they need every package built). After fetching that commit and runningturbo run build --filter='./packages/**'(exit 0, 78 tasks), all three exited 0.--ranreconciliation: "65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero …)".mainused a scratch harness that is not committed. Its numbers are in the tables above.Human Minimum
packages/specholds no runtime logic. (packages/specis not touched.)pnpm lintpasses.main, one pull request. (mainwas merged at738971d2before opening.)packages/spectouched: its generated artifacts are regenerated and committed. (Not touched.)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.## 维护者速读lines, which the maintainer asks for in Chinese.content/docs/permissions/rls.mdxalready describescurrent_user.org_user_idsas "User ids in the user's org", which is now true at any size, so no documentation change is needed.Generated by Claude Code