Skip to content

fix(core)!: org_user_ids holds every member of an organization with more than 1,000 members - #29356

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-imac18
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-imac18

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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.mdx describes current_user.org_user_ids as "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 no for 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.

维护者速读

  • 改了什么:成员超过 1,000 人的组织里,普通成员在人员选择器搜索和按 id 读取用户时,重新能看到每一位同组织成员;组织外的人依旧看不到。
  • 为什么改:同组织成员名单只读了前 1,000 条成员记录,之后的成员被静默隐藏(搜索为空、按 id 读返回 404),而且隐藏的是谁由随机 id 决定。
  • 风险与回滚:不足 1,000 人的组织读取次数和可见范围完全不变;更大的组织每个请求多读一次完整成员名单(实测 2 万人约 45 毫秒)。属于破坏性变更的只有超过数据库绑定参数上限的超大组织(SQLite 实测 3.2 万可用、4 万报错):其成员读用户时会明确报错,而不再返回残缺名单。回滚即撤销本 PR。
  • 先看:packages/core/src/security/resolve-authz-context.ts 中的 readOrganizationMembersToCompletion,以及第 5 步那一处“读满即续读”的判断。
  • 两分钟验证:运行 pnpm --filter @objectstack/plugin-security exec vitest run src/sys-user-org-members-past-first-read.test.ts,在真实 SQLite 上用 1,001 名成员验证搜索、按 id 读取和组织外成员不可见。
  • 需要你做的:无,席位落地。

What changes

sys_user's organization-members row scope is id in current_user.org_user_ids: the object's defaultRowScope and member_default's sys_user_org_members. The authorization resolver builds that list per request from one sys_member read 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,000th sys_member.id was left out with no signal: a people picker's search found nothing, and a read by id answered 404.

This PR is the server-only interim the card's ruling asks for. It makes no spec change and changes no metadata.

  • The first read is unchanged. It has the same filter, the same 1,000 bound and the same leg, so an organization under the bound issues the same single read it always did and gets the same list.
  • A first read that comes back at its bound is now a first page, not the membership. This is the same reading countPositionHolders gives its own reads. §5 then calls readOrganizationMembersToCompletion (packages/core/src/security/resolve-authz-context.ts). It is a keyset read of the active organization's sys_member rows: ordered by id, id > last per page, 10,000 rows per page, columns id and user_id only. 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.
  • Loud, never short. A page that fails to read throws the existing 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.
  • Said once. One console.warn per organization per engine names the organization, its member count and the cost: every request in that organization reads its whole membership, and each sys_user read binds one parameter per member. It is a warn and not an error under AGENTS.md's degradation rule, because nothing is lost: the list is complete.
  • tryFind takes an optional orderBy. Changeset: @objectstack/core patch, with a BREAKING banner and an ADR-0087 disposition (not-required (no-migration-prescription)).
  • The narrowing. In an organization whose membership passes the backend's bound-parameter limit, every sys_user read 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

Organization size Before After
under 1,000 members every member, from one read the same: one read, the same list (pinned)
exactly 1,000 every member the same members, and nothing logged; one extra read that comes back short, because a first read at its bound cannot tell 1,000 from more (pinned)
over 1,000 the first 1,000 by sys_member.id, with the rest silently missing every member plus the caller
anyone outside the organization invisible invisible (pinned and ablated)

Every 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)

Option Per-request cost measured Visible set Verdict
A. Read to completion only when the first read is at its bound (chosen) 0 extra reads under 1,000 members. Completion read: 1,001 members 1 read, 2.9 ms; 5,000: 1 read, 9.1 ms; 20,000: 3 reads, 44.5 ms; 40,000: 5 reads, 100 ms exact chosen
B. Refuse loudly in the resolver when the first read is at its bound 0 extra reads; every request by every member of an organization over 1,000 members fails narrows to nothing rejected: changes who sees whom
C. Log only and keep the truncated list 0 still wrong rejected by the ruling
D. Option A with the first read's page shape (1,000 rows, every column) 20,000 members: 21 reads, 219 ms; 40,000: 41 reads, 485 ms exact rejected: about 5× the cost of A's page

Every request pays the downstream sys_user read with the full list:

Members sys_user search read by id bound parameters
1,001 8.8 ms 4.9 ms 1,007
20,000 57.4 ms 39.5 ms
32,000 83 ms 58.9 ms 32,006
40,000 refused: too 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 ReadableIds semi-join leaf is engine-internal and cannot be authored from a scope. It projects the parent's id, not sys_member.user_id, and its inner filter is the caller's read scope, not organization_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 main an ordinary member of an organization with 1,001 members could not find the member whose sys_member.id sorts 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, orderBy and limit, and answers an unordered read in insertion order, which is not id order.
    • 1,005 members: the exact set, with no other organization's members.
    • 20,007 members: the first read is byte-identical, then the three exact completion reads with their cursors.
    • 999 members: one read, the same set, and no warning.
    • Exactly 1,000 members: the same set, one completion read, and no warning.
    • The warning is said once per organization and engine, and names the organization and the count.
    • Loud failures: a page that fails to read, a full page without ids, and a cursor that does not advance.
  • packages/plugins/plugin-security/src/sys-user-org-members-past-first-read.test.ts (new, 4 tests). It runs a real ObjectQL over a real SqlDriver (better-sqlite3), the real identity objects, the real SecurityPlugin falling back to the shipped member_default, and a principal from resolveUserAuthzGrants.
    • 1,001 members: the member whose sys_member.id sorts 1,001st is found by search and by id, and the full list is exactly the organization.
    • ⛔ A member of another organization stays invisible by search and by id.
    • 999 members: exactly one membership read and exactly the members.
    • The warning appears once and names the count.
  • Red first, on main a19759ac. 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 on main as it should. The third failure was the under-bound test, whose read counter then also matched another plugin's sys_member read; it was narrowed to the organization read afterwards. That test pins unchanged behaviour, so it is meant to pass on main.
  • Ablations: each forbidden behaviour was put back with scripts/ablation-replace.mjs, ran red, and was restored to an empty git diff HEAD. Core's dist was rebuilt for the real-driver leg and checked with ablation-dist-preflight.mjs.
    • The completion read drops its organization_id filter: core 3 red; the real-driver file 3 red, including expected [...] to not include 'usr_outsider'.
    • The cursor guard answers instead of throwing: the non-advancing-cursor test is red.
    • A failing page is swallowed: the failing-page test is red.
    • The completion read always runs: the 999 pin is red, with 2 reads.
    • The warning is said every time: the once test is red.
    • The id-less full page answers: that test is red with expected false to be true.
    • A membership of exactly 1,000 is logged: the exactly-1,000 test is red with expected "warn" to not be called at all.
  • Forced test edits (read-limits-truncate is 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_ids gains the six members the bound cut (peer_0999 to peer_1004), and queries gains 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 recorded queries, and the 200 bound by accessible_org_ids.

Acceptance notes

Verification

Final head c1b4f69f. It is 6b708b8a plus the changeset that declares the narrowing; no code changed.

  • Full dogfood battery: pnpm exec turbo run build --filter='@objectstack/dogfood^...' (exit 0, 68 tasks), then pnpm --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.
  • The changeset's declaration reads {"kind":"declared","value":"no","arm":"narrowing"} through scripts/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-bytes and check:pm-changeset-deadline-census all exit 0.
  • The 65 commands from 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 --ran reconciliation reports "65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero …)". Lock VERDICT: command-exit 0.
  • CI on 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 on 6b708b8a (TypeScript Type Check, Test UI) were aggregators whose lanes were cancelled when c1b4f69f was pushed.
Commands on the round-1 head

Head 6b708b8a, which includes origin/main at 738971d2. Heavy runs went through scripts/pm/os-verify-lock.sh, and every VERDICT was command-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, including check: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, including check:test-typecheck, which covers the new test file.
  • Root eslint with --no-inline-config on the five changed TypeScript files: exit 0.
  • pnpm lint (repo-wide): exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 65 commands, and each ran with < /dev/null.
    • 62 exited 0 on the first pass.
    • Three exited 3 because a prerequisite was not met: check-plugin-teardown-shape --self-test (its pinned commit was not in the shallow clone), check:dual-build-cjs-loads and check:i18n (they need every package built). After fetching that commit and running turbo run build --filter='./packages/**' (exit 0, 78 tasks), all three exited 0.
    • --ran reconciliation: "65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero …)".
  • The reproduction and cost measurements on main used a scratch harness that is not committed. Its numbers are in the tables above.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (packages/spec is not touched.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request. (main was merged at 738971d2 before opening.)
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (Not touched.)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English, except the six ## 维护者速读 lines, which the maintainer asks for in Chinese.
  • The Before Submitting list in CONTRIBUTING.md is done. content/docs/permissions/rls.mdx already describes current_user.org_user_ids as "User ids in the user's org", which is now true at any size, so no documentation change is needed.

Generated by Claude Code

…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>
…ound, which the completion read finds already complete

Claude-Session: https://claude.ai/code/session_01ADHkmSAV6tENpyY8j1VYz5
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/core, touching 11 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

16 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 57cde8cb3da5970f2c3f6512569cacdb9b2b06a8.

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

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 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 39 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 57cde8cb3da5970f2c3f6512569cacdb9b2b06a8 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5672e540605ff4f3060b23beee29629165c1377a — the merge of head c1b4f69fc606705e82b5ac1db705b6bf058dcc28 into base 57cde8cb3da5970f2c3f6512569cacdb9b2b06a8, 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 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

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

… past the database's bound-parameter limit

Claude-Session: https://claude.ai/code/session_01ADHkmSAV6tENpyY8j1VYz5
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot changed the title fix(core): org_user_ids holds every member of an organization with more than 1,000 members fix(core)!: org_user_ids holds every member of an organization with more than 1,000 members Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

Contract-review-claim: session_01ADHkmQJdgarUjPLNgFBY67 · head c1b4f69 · 2026-11-02T13:51Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c1b4f69fc606705e82b5ac1db705b6bf058dcc28
Local-runs: none
Scope: packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/tests/resolve-authz-context.batch-equivalence.testkit.ts, packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json, packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts, packages/core/src/security/resolve-authz-context.org-members-past-first-read.test.ts, packages/plugins/plugin-security/src/sys-user-org-members-past-first-read.test.ts, .changeset/29281-org-members-complete.md + packages/spec/src/security/rls.zod.ts, packages/spec/src/kernel/execution-context.zod.ts, packages/platform-objects/src/identity/sys-user.object.ts, packages/plugins/plugin-security/src/objects/default-permission-sets.ts, packages/plugins/plugin-security/src/rls-compiler.ts, packages/formula/src/cel-to-filter.ts, packages/drivers/driver-sql/src/sql-driver.ts, packages/drivers/driver-sql/src/sql-driver-predicate-emitters.ts, packages/drivers/driver-memory/src/memory-driver.ts, packages/drivers/driver-mongodb/src/mongodb-filter.ts, packages/drivers/driver-mongodb/src/mongodb-driver.ts, packages/objectql/src/engine.ts, packages/core/src/security/assemble-execution-context.ts, packages/core/src/security/resolve-user-grants-cache.ts, packages/plugins/plugin-security/src/explain-engine.ts, packages/plugins/plugin-audit/src/mention-recipient-gate.ts, docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md, docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md

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, git diff origin/main...pr29356, and the 67 check-runs on this head. Cited code read on origin/main via git show; the PR head via git show pr29356:PATH.

① Derived judgments

Accept-set and surface changes the diff implies, each judged against the tree:

  1. The visible set under 1,000 members: byte-identical. Leg 1 still issues tryFind(ql, 'sys_member', { organization_id: tenantId }, 1000) with the same { isSystem: true } context and no order; §5 takes the completion branch only when orgMembersLeg.length >= 1000. Under the bound the code path, the read multiset and the resulting org_user_ids are what main produced. Pinned by the core 999 test (one read, no warn), the real-driver under-bound test (knex query counter, exactly one where organization_id = ? read), and the unchanged batch-equivalence goldens for every fixture but read-limits-truncate. RIGHT.
  2. Exactly 1,000: same set, one extra short read, nothing logged. The first read cannot distinguish 1,000 from more, so one 10,000-row page is read and comes back short; members.length > 1000 is false so no warn. Pinned by the exactly-1,000 test. RIGHT (the extra read is a cost, not a set change).
  3. Over 1,000: the set becomes every member plus the caller. This is the correction the card orders; relative to the spec's declaration ("IDs of all users in the active organization", execution-context.zod.ts L371-378) it is the declared set, not a new one. Golden read-limits-truncate gains exactly peer_0999 … peer_1004 and one sys_member {organization_id} limit 10000 query. RIGHT.
  4. No path adds a non-member. Every completion page's where is { organization_id } or { organization_id, id: { $gt: after } }; the cursor is ANDed onto the organization equality, never substituted. The read runs under the same { isSystem: true } context as the first read, so applyTenantScope behaves identically for both. ids.add(userId) is pre-existing. The .filter(typeof v === 'string' && v.length > 0) on user_id is unchanged. Pinned by the outsider tests in both files and ablated (dev report: dropping the organization filter reds 3 core + 3 real-driver tests). RIGHT.
  5. Single-org and group posture agree. tenantId is the active organization in both; accessible_org_ids is derived from the user-keyed sys_member read ({ user_id }, limit 200), which this PR does not touch, and the driver wall is not reached by a system read. The group wall's membership source is untouched. RIGHT.
  6. A failing page is loud, never a shorter list. tryFind's catch throws AuthzStoreUnavailableError (503) for any thrown read not positively classified isMissingTableError; the completion read inherits it. A repeated id (driver ignored the cursor) or an id-less row on a FULL page also throws the same error. A short page is terminal by design. Pinned by three tests (failing page, id-less full page, non-advancing cursor). RIGHT. The 503 is the correct code: it is the resolver's existing "store did not answer" class, and the request was not served a fabricated set.
  7. full is page.length >= 10000. I looked for anything that could clamp a find limit below 10,000 and make full never true (silent truncation): no clamp in packages/objectql/src/engine.ts (fillQueryAstDefaults passes limit through), driver-sql applies builder.limit(query.limit) as given, driver-memory slices, driver-mongodb applies the limit. countPositionHolders already reads this object at the same 10,000. RIGHT.
  8. The narrowing (the one real accept-set change): above the backend's bind-parameter limit every sys_user read by an ordinary member now fails at the driver (500) where main answered a silently short 200. The lowering is unchanged: rls-compiler.ts#userContextOf copies org_user_ids to the CEL context, cel-to-filter.ts#lowerMembership emits { id: { $in: value } } with no size bound (the DEFAULT_LIMITS memo bounds the CEL source text, not a resolved array), and sql-driver-predicate-emitters.ts emits list('in') with one bind per member. The repo's only bind-limit awareness is the delete path's REFERENCE_PROBE_BATCH = 500 (engine.ts L19443, L21016), which cannot apply to a filter inside one statement. So the refusal is real, it is the driver's, and the ruling's reading (6577690137 item 1) that this is a narrowing is correct. Declared correctly, see ②.
  9. Public surface: tryFind gains an optional orderBy. tryFind is module-private (not exported); no published export, type or schema moves. Metadata-protocol: none is RIGHT.
  10. Readers of org_user_ids that copy the array (assemble-execution-context.ts L381, engine.ts SEED_USER_GRANTS L22710-22721, explain-engine.ts L776/L904, mention-recipient-gate.ts L261, service-automation run seeding) receive a longer array for organizations past 1,000; none bounds or re-reads it, none serialises it to the wire (current-user-endpoints.ts names it only in a comment). No accept-set change at those sites beyond item 3. RIGHT.

② Semver level

  • Changeset .changeset/29281-org-members-complete.md: '@objectstack/core': patch, a BREAKING banner, the Clause-② key declared once as a narrowing, Metadata-protocol: none, and exactly one ADR-0087 marker not-required (no-migration-prescription) with its reason. patch is the level AGENTS.md assigns a non-protocol breaking change ("ships patch and keeps its BREAKING banner and ADR-0087 disposition"); no-migration-prescription is in the gate's category list (check-adr-0087-registration.mjs L557) and the reason given (no key, export or type moves; the one refusal is the database's own) matches the diff. Title carries ! (fix(core)!:).
  • PR body: the Clause-② and Metadata-protocol: none declarations are line-initial, each once; the body also records why the claim's no was re-declared, citing the seat ruling. The claim amendment 6577655349 (not the widen-one-narrow-another value) is honoured in both carriers.
  • Gates on this head: Check Changeset success, Lint & Repo Gates success (carries check-adr-0087-registration, check-changeset-no-major, check-empty-changeset), PR-body guards success, Governed Surface Queue Guard success. No governed path in the file list.
  • Clause-②: no (narrowing). The key is org_user_ids' consumer surface: sys_user reads in an organization past the backend bind ceiling now refuse where they answered short. Nothing widens.

③ Boundary flags

Dev open_questions (6577634380), each answered by the seat ruling 6577690137 and implemented on this head:

Dev deviations: (1) real-driver test in plugin-security outside the claimed surface: ACCEPTED by the ruling. (2) PR body attribution block above Fixes #29281 (line 4): PR-body guards success; harmless. (3) assignee/trailer handling per the fleet relay rules: no action. (4) warn not error: ruled. (5) not measured on Postgres or MongoDB: ACCEPTED as noted; the completion read uses only equality, $gt, orderBy and limit, each of which mongodb-filter.ts ($gt L1325), mongodb-driver.ts (orderBy L805) and memory-driver.ts (orderBy L747, $gt L1897) implement, and a driver that drops the cursor is caught by the repeat guard. (6) no ADR file, no spec change: ruled.

Dev out_of_scope_findings: sharing expandRole and approvals role-tier sys_member reads at 10,000 with catch → []: the PR body says the seat filed #29362. rlsMembership keys share the bind ceiling: recorded for the ADR card (D-f). Stale "org_user_ids cap" prose in ADR-0057 (L85, L162) and admin-set-user-manager.ts L71: confirmed stale on main after this PR; governed path, routed to the ADR card. All carried, none dropped.

Forced batch-equivalence edits (golden, test header, BATCHED_LEGS['read-limits-truncate']: 3, wave-1 filter excluding the limit-10,000 sys_member read, testkit comment): ACCEPTED; each is the mechanical shadow of item ①.3 and nothing else moved in the golden.

④ Design judgment

Against the published spec on main. rls.zod.ts (L33-34, L108, L481-505) and execution-context.zod.ts (L371-378, L405-418) declare that subquery-shaped needs are pre-resolved arrays and that org_user_ids is "IDs of all users in the active organization", lowered as IN (current_user.org_user_ids). On main that capability was declared and not delivered past 1,000 members. This PR delivers it at the consumer's producer (the resolver) up to the physical limit of the declared mechanism, and makes the mechanism's own refusal (one bind per member) visible instead of masking it with a short list. It takes no declared capability away at the consumer: nothing in rls-compiler.ts, cel-to-filter.ts or the drivers is changed, and the shipped scopes (sys-user.object.ts defaultRowScope, default-permission-sets.ts sys_user_org_members) keep their spelling. The relational form is correctly left to an ADR: ADR-0139 D1 forbids a second leaf or correlated form ("⛔ No second leaf, no EXISTS, no correlated form"), and ReadableIds projects the parent's id under the caller's read scope, so it cannot express user_id FROM sys_member WHERE organization_id = tenant. The dev's "no relational form a scope can use" holds against the tree. The narrowing is therefore the spec's own refusal made real, not a consumer-side narrowing of a declared capability. The design holds.

Trying to break it:

  • Per-request cost: for an organization at or past 1,000 members, every uncached resolution adds one sequential leg of ceil(n/10,000) reads after Leg 1 (plus the Leg 1 read), and every sys_user read binds n parameters. The grants cache (resolve-user-grants-cache.ts, OS_AUTHZ_GRANTS_CACHE_TTL_MS, default off) amortises it where enabled, and openRequestGrantsMemo dedupes within one request. Measured by the dev on in-process SQLite (20,000 members: 3 reads, 44.5 ms). Accepted by the ruling as the interim; the ADR card retires it. Not a fix for this PR.
  • Warn-once state: WeakMap(engine → Set(organizationId)). Bounded by the number of distinct organizations with 1,001+ sys_member rows the engine serves; the key is a tenant id backed by real rows, not an author-controlled string, so the 500-entry clear-on-overflow the sibling memos (cel-to-filter.ts#warnedOverLimit, rls-compiler.ts#warnedDenials) carry is not owed here. Freed with the engine. No leak.
  • Concurrency: the said-set check and add are synchronous with no await between, so two concurrent first requests cannot both log. Concurrent first requests all pay the completion read (no cross-request in-flight dedupe); cost only. Keyset paging is snapshot-per-statement: a member added or removed between pages is seen or not as any list read would, and no non-member can enter.
  • Bind-ceiling 500's message: the PR adds none; the error is the database's (too many SQL variables on SQLite, the protocol's parameter cap on Postgres), surfaced as an unclassified 500. The warn line is what ties it to the organization and its count, and it is said once per process. The seat ruled option C (a resolver-side cap with a located refusal) out; accepted.
  • isMissingTableError false-positive on a completion page returns [] and would shrink the list to the caller alone: this is the pre-existing, maintainer-signed risk in tryFind's catch and applies equally to the first read on main; no new exposure class.

Findings, graded:

  • should-fix 1: readOrganizationMembersToCompletion trusts the driver's orderBy. A driver that honours $gt and limit but drops orderBy returns unordered pages; the repeat guard catches that whenever any page row sorts above the page's last row (practically always), but when the last row happens to be the page maximum the rows below it that were not returned are skipped silently. A monotonic check per page (id strictly greater than the previous row's) closes it at no cost and makes the "driver did not honour the order" failure as loud as the cursor one. Shipped drivers apply orderBy, so not a must-fix.
  • should-fix 2: the completion read replaces the first read's rows rather than unioning them. If the completion's pages return fewer members than the first read did (a connection seeing an older snapshot, or a misclassified read failure), the list is shorter than what was already in hand. Unioning orgMembersLeg into ids cannot add a non-member (that read was organization-filtered) and guarantees the result is never shorter than main's. Not a must-fix: both reads are honest snapshots and the misclassification risk is pre-existing and recorded.
  • should-fix 3 (for the ADR card, not this PR): the raw driver 500 past the bind ceiling carries no remedy text; the ADR's relational leaf removes the ceiling, and until then the once-per-process warn is the only operator pointer. Recorded against 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.

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: claude/project-thread-imac18
Reviewed-by: session_01ADHkmQJdgarUjPLNgFBY67

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

席位核对 · ACCEPT · PR #29356 · 2026-11-02T14:04Z

Head-sha: c1b4f69fc606705e82b5ac1db705b6bf058dcc28

维护者速读(席位终稿)

核对六项

  1. 形态:draft → main;标题带 !(fix(core)!:);Fixes #29281,与认领 6576308060 及其修正 6577655349 一致;7 个文件 +619/-5,都在申报面内,或经裁定接受(plugin-security 的真驱动测试)。
  2. 前提实测:契约复审(CONTRACT_REVIEW_TIER,收窄面)在本 head 上 PASS,记录 6579099249,无必修项。复审逐条对照树而非正文:游标只与组织等值条件 AND,不会引入非成员;find 的 limit 在引擎和三个驱动里都没有被压低;收窄来自驱动的绑定参数,判定正确。
  3. 门禁与 dogfood:Dogfood Regression Gate、Check Changeset、Lint & Repo Gates(含 ADR-0087 注册、no-major、empty-changeset)、PR-body guards、Governed Surface Queue Guard 在本 head 都是绿。dev 报告(6577634380)含消融:去掉组织过滤,core 3 条、真驱动 3 条转红。
  4. 接受的偏差:batch-equivalence 的 golden 与测试头被迫改动,只是 read-limits-truncate 多出 6 个成员和一次 10,000 行读取,其余 golden 未动,接受。未在 Postgres、MongoDB 上实测,接受:补读只用等值、$gt、orderBy、limit,三个驱动都实现了。
  5. 范围外去向:复审的 should-fix 1(不信任驱动的 orderBy,加单调检查)和 should-fix 2(补读结果与首次读取取并集)已立 core/security: the org-members completion read trusts the driver's orderBy and replaces the first read's rows, so a driver that drops the order, or a completion that sees fewer rows, can shorten org_user_ids silently #29398,p3 建议,不挡本 PR。should-fix 3(绑定上限的 500 无补救文字)记在 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。共享 expandRole 与审批的成员读取归 plugins: sharing expandRole and 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;ADR-0057 里过时的「上限」说法归 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。
  6. 落地条款:Clause-②: no (narrowing),PR 正文与 changeset 都有;收窄面契约复审与全量 dogfood 已做。semver:@objectstack/core patch,带 BREAKING 说明,ADR-0087 标记 not-required (no-migration-prescription)。Metadata-protocol: none。check-governed-merges --pr 29356:NOT governed(入队前复核)。落地授权,维护者原话:「按照 pm-dispatch 规则复核过的都授权」(2026-10-25T11:35Z)。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 14:05
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 14:05
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit f72bc9e Nov 2, 2026
68 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-imac18 branch November 2, 2026 14:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant