Skip to content

docs(adr): ADR-0147 — membership-scoped RLS lowers to an engine-internal org-member semi-join leaf; the visible set stays exactly what it is (Proposed) - #29501

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-qe1kwi
Nov 3, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-qe1kwi

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread

Fixes #29360

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:新增 ADR-0147(Proposed),决定 id in current_user.org_user_ids 这类成员范围 RLS 不再每次请求预读整个组织成员列表,而是下推为数据库子查询;另给 ADR-0139 D1/D3 与 ADR-0057 D3 各加一条带日期的指针注记。
  • 为什么改: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 修完后成员列表已精确,但大组织每个请求都要多读一次成员表,且成员数超过数据库绑定参数上限(SQLite 约 4 万、Postgres 65,535)时 sys_user 读取直接 500。
  • 风险与回滚:纯文档,不改代码、不改 schema;可见集合由 D-0「同集合不变式」锁死,任何会改变"谁能看到谁"的方案都列为已否决。回滚即撤销本 PR。
  • 先看:docs/adr/0147-…md 的 D-0(不变式)、D-d(不编译的驱动走精确数组兜底,绝不拒绝)、D-e(读取方清单)。
  • 两分钟验证:node scripts/check-adr-symbol-anchors.mjs && node scripts/check-adr-links.mjs,预期两行 ✅,ADR-0147 的每个代码锚点都能解析。
  • 需要你做的:批准本 PR(Tier H,含对 ADR-0139「只此一个叶子」「无能力位」两条禁令在本叶子上的修订),命名已由席位定为 OrgMemberIds(驱动编译声明的名字由 spec 卡定),无需你另行确认。

What changes

  • New: docs/adr/0147-membership-scoped-rls-lowers-to-an-engine-internal-org-member-semi-join-leaf.md, Status Proposed. It decides D-a to D-g as 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 lists them (the 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 design, os-dev-report 6577634380; seat ruling 6577690137 item 4), plus D-0, the same-set invariant triage 6580364238 asked for as a decision of its own: the visible set is S(org, caller) = {caller} ∪ {non-blank sys_member.user_id of the active org}, {caller} with no active org, and every lowering answers exactly S on every driver, posture, polarity and size.
    • D-a keeps the spelling. FIELD in current_user.org_user_ids lowers to a branded sibling of ReadableIds (proposed name OrgMemberIds; the S1 spec card names the driver declaration; naming is the lane seat's call), carrying the resolver's own (organizationId, callerId) anchor. A context that carries an array still lowers the array.
    • D-b fixes the inner read to today's read: active-org equality, system authority, no tenant wall (in group posture the wall would widen S), no validity, status or ban filter (each narrows S), and the blank-id guard (which also keeps !(… in …) NULL-safe).
    • D-c ORs the anchor's caller in, never the compiling context's userId (the delegated explainer carries the principal's list under the delegator's id).
    • D-a / D-d revisit two ADR-0139 prohibitions for this one leaf: D1's "No second leaf" (kept in spirit: the second meaning gets its own brand) and D3's "No DriverCapabilities bit returns" (a consulted declaration, because a refusal is excluded here).
    • D-d: compile, or the floor, the exact completion-read array of PR fix(core)!: org_user_ids holds every member of an organization with more than 1,000 members #29356, chosen up front and never a refusal. That covers MongoDB, OData, federated objects, the in-process judges and the analytics native-SQL read-scope compiler (service-analytics compileScopedFilterToSql). The floor substitution descends into composed ReadableIds scopes. The explainer publishes the array as readFilter, today's wire shape.
    • D-e is the reader census re-measured on cafe8ee6, corrected from the card: the current-user endpoint and the annotation pins read nothing; the automation, sharing and transport carriers and the presence probes were missing. It says which sites move to the anchor and which keep the array.
    • D-f records rlsMembership leaves as out of scope, with the reason.
    • D-g is the acceptance: PR fix(core)!: org_user_ids holds every member of an organization with more than 1,000 members #29356's two pins byte-unchanged, plus group-posture, NULL-user_id negation, caller-arm, no-active-org, delegation, floor-parity, analytics, nested and explain-readFilter pins.
    • Execution plan: S1 spec card → V1 lowering and floor → V2 SQL family and memory → V3 request path → V4 MongoDB (optional).
  • docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md: two dated, append-only pointer notes, under D1's "No second leaf" and under D3's capability-bit bullet. ReadableIds keeps both as written, and the Status line is untouched.
  • docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md: one dated amendment note under D3's "mirroring the org_user_ids cap" bullet, and one inline · **Amended** entry on its Status line pointing at it (after the ADR-0131 entry; the index still keys on Accepted). The cap no longer exists after PR fix(core)!: org_user_ids holds every member of an organization with more than 1,000 members #29356. D3's own bound requirement is unchanged.
  • docs/adr/README.md: regenerated by node scripts/adr-index.mjs (one row and the counts).

No code, no schema, no test. skip-changeset: docs/adr/** publishes nothing.

Tests

None: an ADR-only diff. No forced test edits.

Acceptance notes

  • Round 2 answers contract review 6583043471 (FAIL on f3a117fb): both must-fixes and should-fixes a, b, c and e land in the records. Round 3 applies the seat's two rulings on round 2: should-fix d (ADR-0057-erp's Status line gains its inline Amended entry), and naming is the seat's call, so O1 carries no maintainer gate.

  • ADR number. 0147 is the next free number. The highest on main cafe8ee6 is 0146, and none of the 43 open PRs adds a docs/adr/0*.md (each head fetched, git diff --name-status origin/main HEAD -- docs/adr, zero A rows). 0136 and 0140 are unused gaps; they are not taken, since numbers are never reassigned.

  • Out of scope, carried: the packages/plugins/plugin-auth/src/admin-set-user-manager.ts comment quotes ADR-0057 D3's "org_user_ids cap". The quotation stays accurate as a quotation, and correcting the comment is a server-lane code edit, so V3 carries it (ADR-0147 O6).

  • New public names: the leaf is OrgMemberIds; S1 names the driver's compile declaration. Naming is the lane seat's call (ADR-0147 O1).

  • Seat's mechanism assumptions, measured: the spec sites are rls.zod.ts's module header ("Subquery-shaped needs are pre-resolved…"), its "does not lower" paragraph and "Dynamic membership", and execution-context.zod.ts's org_user_ids and rlsMembership docs. That matches the card; the line numbers have drifted. Generalising ReadableIds would make S depend on the caller's grants on sys_member, so the sibling leaf is chosen (Alternatives).

Verification

Derived gates on `723e27a6`: 21 of 21 exit 0, --ran reconciled

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 21 commands (4 paths, +412 / -4). The lint closure was built first (at d55c14e4; formula and lint are untouched since), because check:doc-formula-expressions reads @objectstack/formula and @objectstack/lint dist: os-verify-lock.sh -c 'pnpm exec turbo run build --filter=@objectstack/formula --filter=@objectstack/lint --concurrency=2', VERDICT command-exit 0. Every command ran with stdin from /dev/null, its exit code captured before any pipe:

exit command
0 node scripts/check-adr-links.mjs
0 node scripts/check-adr-links.mjs --self-test
0 node scripts/check-closing-keyword-parity.mjs
0 node scripts/check-closing-keyword-parity.mjs --self-test
0 node scripts/check-comment-mask-corpus.mjs
0 node scripts/ci/plan-test-core-shards.mjs --self-test
0 pnpm --filter @objectstack/lint run check:doc-formula-expressions
0 pnpm check:adr-anchors
0 pnpm check:adr-index
0 pnpm check:adr-symbol-anchors
0 pnpm check:ci-filter-parity
0 pnpm check:closed-ceremony-text
0 pnpm check:cross-package-test-inputs
0 pnpm check:doc-authoring
0 pnpm check:driver-memory-census
0 pnpm check:gitlink-declared
0 pnpm check:nul-bytes
0 pnpm check:pm-governed-merges
0 pnpm check:pm-prior-rulings
0 pnpm check:refd-timer-probe
0 pnpm check:watch-hint-literal

node scripts/pm/dispatch-gates.mjs --ran FILE --repo objectstack-ai/objectstack (exit 0): "21 derived famil(ies) accounted for — 21 run, 0 NOT-MEASURED (a DERIVED zero — all 21 recorded an exit code and none of them is 3)".

check-adr-symbol-anchors: 3037 anchors across 149 records resolve. check-adr-links: 979 relative destinations resolve. adr-index --report: README current.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (n/a: no schema)
  • 2. Every name I added uses the casing for its kind. (OrgMemberIds, PascalCase like ReadableIds)
  • 3. No formatter ran; pnpm lint passes. (no lintable file in the diff)
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset. (publishes nothing: skip-changeset)
  • 6. packages/spec touched: n/a.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block is the maintainer-facing exception the lane prescribes).
  • The Before Submitting list in CONTRIBUTING.md is done.

Governed surface (docs/adr/**, Tier H): this PR stays DRAFT and waits for a GOVERNED_APPROVERS approval.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ADHkmSNasUm3e1dfbV5aaD


Generated by Claude Code

…nal org-member semi-join leaf (Proposed)

Proposes that `FIELD in current_user.org_user_ids` keep its spelling and
lower to a branded sibling of ReadableIds that the SQL family compiles to
`IN (SELECT user_id FROM sys_member WHERE organization_id = :org ...)`,
with the caller OR-ed in from the resolver's own (org, caller) anchor and
the exact completion-read array as the floor for every evaluator that
does not compile it. A same-set invariant (D-0) holds the visible set to
exactly what the resolver answers today; options that change it are
listed as rejected. ADR-0057-erp D3 gains an amendment note: the
`org_user_ids` cap it cites no longer exists. Index regenerated.

Claude-Session: https://claude.ai/code/session_01ADHkmSNasUm3e1dfbV5aaD
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Nov 2, 2026
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation labels Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-steve · session_01ADHkmSduNGC2SZXszyrNut

Contract-review-claim: session_01ADHkmSduNGC2SZXszyrNut · head f3a117f · 2026-11-02T16:41Z

The lane seat reviews this head itself at CONTRACT_REVIEW_TIER (triage ruling 6575141496 on #28741 returned contract review to the lane): a new ADR-0147 (Proposed) on lowering membership-scoped RLS to an engine-internal semi-join leaf, and an amendment note on ADR-0057-erp D3. Governed path: this PR stays DRAFT and waits for a GOVERNED_APPROVERS approval; this record does not stand in for it.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-steve · session_01ADHkmSduNGC2SZXszyrNut

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f3a117fbe05b586e75ca0cbfd578d6fc61e7c4ae
Local-runs: none
Scope: docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md, docs/adr/0147-membership-scoped-rls-lowers-to-an-engine-internal-org-member-semi-join-leaf.md, docs/adr/README.md + docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md, packages/core/src/security/resolve-authz-context.ts, packages/formula/src/cel-to-filter.ts, packages/plugins/plugin-security/src/rls-compiler.ts, packages/plugins/plugin-security/src/explain-engine.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/security-plugin-rls-filter.ts, packages/spec/src/data/readable-ids.ts, packages/spec/src/security/rls.zod.ts, packages/spec/src/kernel/execution-context.zod.ts, packages/spec/src/security/explain.zod.ts, packages/drivers/driver-sql/src/sql-driver.ts, packages/drivers/driver-mongodb/src/mongodb-filter.ts, packages/drivers/driver-odata/src/odata-driver.ts, packages/drivers/driver-memory/src/memory-driver.ts, packages/drivers/driver-turso/src/remote-transport.ts, packages/services/service-analytics/src/read-scope-sql.ts, packages/services/service-storage/src/attachment-access-hooks.ts, packages/plugins/plugin-audit/src/parent-record-read-gate.ts, packages/objectql/src/engine.ts, packages/objectql/src/hook-run-as.ts, packages/services/service-automation/src/runtime-identity.ts, packages/services/service-automation/src/run-resume.ts, packages/platform-objects/src/identity/sys-member.object.ts, packages/platform-objects/src/identity/sys-user.object.ts, packages/plugins/plugin-security/src/objects/default-permission-sets.ts, scripts/adr-index.mjs

Inputs read: card #29360 (body, triage 6580364238, claim 6581842163, os-dev-report 6582668525), PR #29501 (body, 3-file list, net diff against main at the merge-base cafe8ee6: +357 / -3), the check-runs on the head (every non-skipped run success: Check Changeset, Governed Surface Queue Guard, Lint · docs & code guards, Lint · PM dispatch-gates, Check Documentation Links, PR-body guards, Test Core, Type Check · workspace and the rest; none failed, none pending at the time of this record), the lane delta at /mnt/project-files/spec-seat2/29360-delta.md, and project memory adr-amendment-note-recipe. The live tree was read with git show at the head only.

① Derived judgments

  • Accept-set: no change. The diff is docs/adr/** only (one new record, one appended blockquote, one regenerated index). No package, schema, test, gate or scripts/adr-anchors/ entry changes. pnpm check:adr-symbol-anchors, check:adr-index, check:adr-links are the gates for these files and the head's check-runs are green. Right.
  • Public surface: nothing published. OrgMemberIds and "the driver's compile declaration" are proposed names, not exports; packages/spec is untouched (no OrgMemberIds / ActiveOrgMemberIds exists in the tree). The PR's claim "No code, no schema, no test" matches the tree. Right.
  • ADR number 0147 is free on main (highest is 0146) and the README gained exactly one row and the counts (147 → 148 records, Proposed 4 → 5). The row's title shows currentuser.orguserids because scripts/adr-index.mjs strips _ (line 192) — the generator's behaviour, with precedent at ADR-0068's row (currentuser); not this PR's defect. Right.
  • The 0057 note landed in the right one of the two ADR-0057 records (-erp-authorization-core-…, whose D3 bullet cites the cap). f72bc9e5 is PR fix(core)!: org_user_ids holds every member of an organization with more than 1,000 members #29356's merge on main. Right.
  • Status is Proposed, the Status line of 0057 is untouched, no Amended keyword sits in the index window. Right.
  • D-a/D-b/D-c read correctly off the live tree (measured below in ④): #resolveUserAuthzGrants reads sys_member { organization_id: tenantId } through #tryFind with { isSystem: true } and no tenantId (installation-wide, outside applyTenantScope), completes past 1,000 by keyset pages of id, keeps user_id values that are non-empty strings, adds the caller unconditionally, applies no validity / status / role filter to the organization's rows (ADR-0091 isGrantActive is applied only to the caller's own sys_member rows for accessible_org_ids); #lowerMembership emits { FIELD: { $in: value } } and refuses a non-array; #userContextOf copies org_user_ids from the context; resolveDelegatorContext overwrites the delegator's list with the live principal's (dctx.org_user_ids = context.org_user_ids) under the delegator's userId. The ADR's reading of each is right.
  • D-e census corrections verified by a tree-wide grep: current-user-endpoints.ts holds a comment only; assemble-execution-context.ts (L158 key list, L381 envelope), service-automation/plugin.ts, run-resume.ts, runtime-identity.ts#runOrgUserIds (answers undefined for a missing array), share-link-service.ts, sharing-plugin.ts, runtime/src/domains/share-links.ts, mention-recipient-gate.ts, explain-engine.ts#buildContextForUser, objectql's ScopedContext[SEED_USER_GRANTS] (needsPeers = !Array.isArray(ctx.org_user_ids), in engine.ts, keyed by the hook-run-as.ts symbol) are the readers and carriers. The one evaluator the census misses is named in ④ (must-fix 2). The write-verb question is closed by the tree: a predicate update/delete is narrowed by the read door's answer (security-plugin.ts, id IN (the rows the read returned), cap PREDICATE_WRITE_READABLE_ID_CAP), never by the compiled RLS filter in the write's own where, so D-a's "legal only on find, findOne, count, aggregate" loses nothing on main. Right.

② Semver level

skip-changeset is correct: docs/adr/** publishes nothing, and the Check Changeset run on the head is success. Clause-②: no on the PR body and the claim matches: nothing is narrowed or widened in a published surface by this diff; the record itself says "Each execution card declares its own Clause-②", which is where the SQL-family card's past-ceiling 500 → answer belongs. Metadata-protocol: none is right (no metadata-protocol surface touched). Right.

③ Boundary flags

Dev deviations (os-dev-report 6582668525):

  1. admin-set-user-manager.ts comment left out, carried by V3 (O6) — answered: the delta allows exactly this ("If the comment edit in plugin-auth is outside the lane, list it in out_of_scope"); the quotation stays a quotation.
  2. D-e corrected from the card — answered, verified (①).
  3. Rules beyond the card ("an array wins over the anchor"; "the anchor's caller, never the compiling context's userId") — answered, right: both keep S byte-identical for every hand-built context and for the delegated explainer, whose dctx carries the live principal's list under the delegator's id (explain-engine.ts L897–898). A re-derived caller would swap the principal for the delegator in S.
  4. MCP writes skipped; attribution and Fixes #29360 on the body — process, no finding.
  5. Second commit refines one D-d row — confirmed by git log (two commits over the merge-base).

Open questions (judged, not stood in for):

  • O1 — new public names (OrgMemberIds and the driver's compile declaration): a new export of @objectstack/spec beside ReadableIds, so a maintainer decision. Option A is the right shape (a branded value drivers recognise must be exported, as ReadableIds is; C would lock every third-party SQL driver onto the floor). The name is defensible; ActiveOrgMemberIds would say the binding, but S is "active org's members plus the caller" under either name, and the ADR says so. ⛔ Escalate to the maintainer with the Tier H approval: the record lands only under names the maintainer approves (the ADR says this itself, O1), and the declaration's name is deferred to S1, which is acceptable.
  • O2 — does D-d's departure from ADR-0139 D3 need a pointer on ADR-0139? Yes (B), and not only D3: see must-fix 1. ADR-0139 D1 ends "⛔ No second leaf, no EXISTS, no correlated form." and D3 says "⛔ No DriverCapabilities bit returns … a bit the engine does not consult is inert". ADR-0147 adds a second leaf and a consulted driver declaration, states the D3 departure, never names the D1 one, and lands no pointer on ADR-0139. The repo's own precedent is ADR-0139 itself: it revisited ADR-0055 row (a) and ADR-0056's non-goal and made "one pointer each … landed with this record" part of its acceptance criteria. Prime Directive 13's letter is met by a new ADR; its last sentence ("A decision nobody can find is a decision that will be reversed") is not, while ADR-0139 D1 reads as absolute.
  • O3 (the ADR's O4) — freshness against the opt-in grants cache: A, not a set change. OS_AUTHZ_GRANTS_CACHE_TTL_MS defaults to 0 (resolve-user-grants-cache.ts, Fork 4); with it on, the array may be as old as the entry (retired by any watched-object write on the same engine) and the leaf reads at statement time. S(org, caller) is the same function of the same inputs; only the inputs' age differs, and the staleness window is the cache's accepted contract. D-0's sentence ("the inputs' age, not the rule") is the right place and should stay. No separate escalation; the maintainer reads it in the record.

Security findings: withheld by the dev; none of my own beyond ④.

④ Design judgment

The bar (triage 6580364238): the record keeps EXACTLY the visible set #29281 holds. I tried to break the same-set invariant against the live code at the head: the resolver (both legs, the completion read, the claim-drop re-resolution, the API-key refusal, the guest path), the lowering, the compiler's user context and polarity guard, group posture (vetOrganizationClaim in single admits the claim unvetted; in walled postures it drops to no active organization and S = {caller} — the anchor is captured after that re-resolution, so it agrees), NULL/blank user_id under $not (SQL NOT IN over a NULL-bearing subquery is UNKNOWN; the guard reproduces today's non-empty-string filter and keeps the negation total), the caller arm (today ids.add(userId) unconditionally; the leaf ORs anchor.callerId), the delegated explainer (above), the array-wins rule, the isEmptyMembershipFilter polarity guard (S is never empty on the leaf path, since it holds the caller; a context with no caller carries today's [] and takes the array path, so the guard keeps its answers), sys_member's (organization_id, user_id) unique index (declared, sys-member.object.ts L414), soft-delete (none declared on sys_member, none applied by the engine), tenant wall (readableIdsSubquery applies applyTenantScope to its parent; D-b correctly forbids that here because the group-posture wall is organization_id IN accessible_org_ids), and the drivers (mongodb and odata refuse ReadableIds through findMisplacedReadableIds; memory evaluates in process; turso's remote transport compiles a resolved semi-join). On the paths the record names, S is unchanged, both polarities, every posture. D-0, D-a, D-b, D-c, D-f and D-g hold against the code they cite.

Two places the record's own invariant is falsified by the live tree, both repairable in the record:

Must-fix 1 — ADR-0147 reverses ADR-0139 D1's "⛔ No second leaf" without saying so, and ADR-0139 carries no pointer. docs/adr/0139-…md D1: "⛔ No second leaf, no EXISTS, no correlated form." D3: "⛔ No DriverCapabilities bit returns … a bit the engine does not consult is inert." ADR-0147 cites D1 for its brand device (L130) and for rejecting EXISTS (L301), states its D3 departure (D-d, "Builds on"), and never names D1's prohibition as revisited; its "Amends" header and Acceptance criteria list the ADR-0057-erp note only. On main after this PR, an Accepted record would read "no second leaf" with nothing pointing at the Proposed record that adds one. The repo's convention is the one ADR-0139 set when it revisited ADR-0055 (a) and ADR-0056: a dated one-line pointer on the older record, landed with the new one, named in the new record's "Amends" and Acceptance criteria. Fix: (a) ADR-0147 "Builds on"/"Amends" and D-a/D-d name ADR-0139 D1 ("no second leaf") and D3 ("no capability bit") as the two decisions this record revisits for this one leaf, with the reason (D1's reason — one meaning under one brand — is kept by giving the second meaning its own brand; D3's reason fails where a refusal is excluded); (b) append-only dated pointer notes under ADR-0139 D1's last paragraph and D3's capability-bit bullet, in the recipe's blockquoted **Amendment note (2026-11-02, ADR-0147 — #29360).** form, no Status-line edit; (c) Acceptance criteria name both pointers. (Answers O2 as B.)

Must-fix 2 — D-d's evaluator census misses the analytics raw-SQL read-scope compiler, which today compiles the array and would refuse the leaf. packages/services/service-analytics/src/read-scope-sql.ts#compileScopedFilterToSql compiles security.getReadFilter(object, ctx)'s answer to SQL for the native-SQL strategy; its $in arm binds one parameter per member (val.map(vbind), L2193–2197 — the same ceiling the record retires on driver-sql), it throws $in … needs an array (fail-closed) for a non-array comparand, and it refuses a ReadableIds leaf at entry (L1059). It is neither a driver, nor a federated object, nor an in-process judge of matchesFilterCondition, so no row of the D-d table and no sentence of D-d reaches it. If V1 emits the leaf when the context carries the anchor, every analytics query that reads sys_user (or any object whose RLS names org_user_ids) is refused where today it answers — the exact "narrows S to an error" D-d forbids and D-0 ("every driver … every evaluator") promises against — and D-g pins none of it. Fix: a D-d row for the analytics read-scope compiler (compile the subquery in its own dialect, or the floor through the same substitution the engine seam makes, chosen up front), a D-g floor-parity pin on the native-SQL strategy, and the analytics lane added to Consumers and the execution plan (V2 or its own card). The record's second commit claims to name "every in-process judge of a compiled RLS predicate"; this compiler is the one evaluator of that predicate outside plugin-security and the drivers.

Should-fix (none changes S; each is a sentence or a row):

  1. The explain API's published readFilter. explain-engine.ts L3141 publishes the composed predicate as ExplainDecisionSchema.readFilter (z.unknown(), "the machine artifact behind the prose"). Today that artifact carries the id array; a leaf there arrives at the wire unbranded as the anchor's shape. The record lists the explainer as a floor evaluator (judging records) but says nothing about what it publishes. State it: the explainer materialises and publishes the array (today's wire shape), or publishes the anchor and the V3 card declares the Clause-② for the readFilter consumer.
  2. Consequences over-claims the request path. "The request path issues no membership read, on any request" and "A sys_member outage used to fail every request's resolution (503). Now it fails the reads that use the leaf … Fewer requests fail" are wrong against #resolveUserAuthzGrants Leg 1, which still reads sys_member { user_id: userId } on every request for accessible_org_ids and the roles. What goes is the organization-membership read; a sys_member outage still fails every request's resolution. Say "no organization-membership read" and drop the failure-surface claim or restate it.
  3. The nested case. A ReadableIds leaf whose parent P is sys_user (or any object whose RLS names org_user_ids) carries this leaf inside its composed scope (ADR-0139 D2 step 3): parent-record-read-gate.ts, attachment-access-hooks.ts and controlled_by_parent compose from getReadFilter(P). driver-sql compiles leaf.scope recursively (readableIdsSubquery → applyFilters), memory evaluates the scope in process, but the engine's floor substitution must descend into composed scopes and the D-g pins should include one nested fixture. Name it in D-d and D-g.
  4. The ADR-0057-erp amendment vs adr-amendment-note-recipe. The note form, placement and path#symbol anchor are right; the file's own header convention for an amendment is the inline **Amended** (date, ADR-NNNN …) entry on the Status line (ADR-0131's sits there), and the record carries no header pointer to D3's note. The recipe says: when the acceptance says no other text changes, leave Status alone and put the Status-line question in open_questions — the dev did the first and not the second. Put the one-entry Status-line pointer to the seat/maintainer explicitly (appending after the ADR-0131 entry is safe for scripts/adr-index.mjs, which keys on the EARLIEST keyword, Accepted).
  5. Anchor precision in D-e. The "needs peers" probe lives in packages/objectql/src/engine.ts (ScopedContext[SEED_USER_GRANTS]), keyed by the hook-run-as.ts symbol the record cites; cite the implementation site too so the V3 card lands on it.

Implemented-by: claude/project-thread-qe1kwi
Reviewed-by: session_01ADHkmSduNGC2SZXszyrNut

VERDICT: FAIL

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-steve · session_01ADHkmSduNGC2SZXszyrNut

待裁:ADR-0147 的新公开名字与一处有意保留的不对称(#29360)

  • 一句话问题:大组织里"能看见本组织成员"这条行级权限,现在要先把全体成员 id 读出来塞进查询,成员一多就慢,过了数据库参数上限就直接报错。ADR-0147 提议让 SQL 驱动改成子查询直接算,看见的人一个不多一个不少。落地要在 @objectstack/spec 新增公开名字,另有一处不对称需要您定。
  • 背景:分诊 6580364238 定"可见集合必须与 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 现状完全一致"。席位契约复审(6583043471)确认可见集合不变;本轮判 FAIL 是因为两处清单遗漏,都在 ADR 文字里修,开发已在改。ADR 属治理路径,另需 GOVERNED_APPROVERS 批准。
  • 问题一:新叶子的名字(暂名 OrgMemberIds,与已有的 ReadableIds 并列导出),以及驱动"能否编译它"的声明(名字留到 S1 卡再定)。
    • A 保留 OrgMemberIds:直说"组织成员 id",与 ReadableIds 成对,零额外工作。
    • B 改名(例如 ActiveOrgMemberIds):点明只算当前组织,更精确但更长;改名只动 ADR 文字,约十分钟。
  • 问题二(ADR 的 O3):org_user_ids 不读 ADR-0091 的有效期窗口,而 accessible_org_ids 会读。今天 sys_member 没有有效期字段,所以没有任何一行的结果不同。
    • A 保留不对称:可见集合今天完全不变,ADR 记录下来,等 sys_member 真的加了有效期再议。
    • B 现在就对齐:将来 sys_member 一加有效期,可见集合就会变,这属于权限边界的变化,需要单独立卡。
  • 推荐:问题一选 A,问题二选 A。盲区:将来如果出现"跨组织成员"的叶子,OrgMemberIds 会显得含糊,那时再给新叶子起新名即可。
  • 自检:只看①选 A;②③④ 是否翻转:否。
  • 落地:此 PR 保持草稿,等标签取下且 GOVERNED_APPROVERS 批准。

… pointer notes; the analytics read-scope compiler takes the floor

Contract review 6583043471 on PR #29501:
- must-fix 1: ADR-0147 names ADR-0139 D1 ("No second leaf") and D3 ("No
  DriverCapabilities bit returns") as revisited for its one leaf, and
  ADR-0139 gains a dated append-only pointer note at each; Amends and
  Acceptance criteria list all three notes.
- must-fix 2: D-d gains the service-analytics compileScopedFilterToSql
  row (floor, chosen up front), the analytics lane in Consumers and V1,
  and an analytics pin in D-g.
- should-fix: the explainer publishes the array as readFilter; the
  Consequences say the organization-membership read goes (Leg 1's own
  sys_member read stays) and drop the failure-surface claim; the floor
  substitution descends into composed ReadableIds scopes, with a nested
  pin; D-e cites engine.ts ScopedContext[SEED_USER_GRANTS]; O4 is not a
  set change.

Claude-Session: https://claude.ai/code/session_01ADHkmSNasUm3e1dfbV5aaD
Co-authored-by: Claude <noreply@anthropic.com>
…0057-erp gains its Status-line Amended entry

Seat rulings on PR #29501 round 2: naming is the lane seat's call, so O1
names OrgMemberIds as proposed and leaves the driver-declaration name to
the S1 spec card, with no maintainer gate; ADR-0057-erp's Status line
gains one inline Amended entry pointing at its D3 note (after the
ADR-0131 entry; the indexer keys on the earliest keyword, Accepted).

Claude-Session: https://claude.ai/code/session_01ADHkmSNasUm3e1dfbV5aaD
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-steve · session_01ADHkmSduNGC2SZXszyrNut

Withdrawn: the decision request 6583078458 on this PR. Under pm-dispatch SKILL.md 〈升级与决策〉 both of its questions are the seat's call, not a maintainer fork. (1) The leaf name OrgMemberIds is fit and stays; the driver-declaration name is fixed by the S1 card. (2) The O3 asymmetry: the ADR keeps the visible set exactly as today, which is triage's bar, so keeping it is no change. The future card that would close it changes the set, and that card escalates then. needs-user-decision is removed. This PR stays DRAFT on the governed path and still waits for a GOVERNED_APPROVERS approval.

Prompted by the maintainer's question at 2026-11-02T17:03Z.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-steve · session_01ADHkmSduNGC2SZXszyrNut

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 723e27a6b8ab7580eeb451c4c7781e049044248e
Local-runs: none
Delta on: the FAIL record 6583043471 at f3a117fbe05b586e75ca0cbfd578d6fc61e7c4ae. The delta is two commits (e0104338, 723e27a6), docs/adr/** only: 0147-…md +68/−27, 0139-…md +11/−0, 0057-erp-…md 1 line (the Status line, appended). No package, schema, test, gate or scripts/ change; docs/adr/README.md is unchanged in the delta (judged right below). The live tree was read with git show at the head only.
Inputs read: the FAIL record in full; git diff f3a117fb..723e27a6; the head's check-runs (44; every non-skipped run success at the time of this record — Test Core was in_progress on first read and success on re-read; Check Changeset skipped by its job-level if: !contains(labels, 'skip-changeset') in .github/workflows/pr-automation.yml, the label being on the PR — the opt-out working as designed, not a failure); the card thread after 16:30Z (os-dev-report 6582668525, the decision request 6583078458 and its withdrawal 6583516156, "prompted by the maintainer's question at 2026-11-02T17:03Z"); project memory adr-amendment-note-recipe; scripts/adr-index.mjs at the head.

① The two must-fixes — both closed

Must-fix 1 (ADR-0139 D1/D3 revisited without saying so, no pointer) — closed. ADR-0147's header now says it: "Builds on" names the two prohibitions it revisits for this one leaf, with anchors into D-a and D-d; "Amends" lists three places (ADR-0139 D1, ADR-0139 D3, ADR-0057-erp D3), "each by a dated, append-only pointer note and nothing else", and says which Status lines move (0057-erp: one inline entry) and which do not (0139). D-a gains the bullet "A second leaf, so ADR-0139 D1 is revisited for it", with D1's reason (one brand, one meaning) and why it is kept (the second meaning gets its own brand, never a mode of ReadableIds); D-d's "Chosen up front" bullet now states the D3 revisit with D3's reason (an unconsulted bit is inert) and why it fails here (a refusal is excluded, so the engine must know before the call). Acceptance criteria name the three notes. On ADR-0139 the two notes sit where the FAIL record asked: directly under D1's last paragraph (the one ending "⛔ No second leaf, no EXISTS, no correlated form.") and under D3's capability-bit bullet, as a 2-space-indented blockquote inside that bullet (so it renders inside the list item). Both are dated 2026-11-02, name ADR-0147 and #29360, and say exactly what is revisited and what stands. The 0139 edit is +11/−0: no Accepted sentence is rewritten, the Status line (L3–L10) is untouched, and the README's 0139 row is unchanged. Closed, and O2 is answered as B.

Must-fix 2 (the analytics raw-SQL read-scope compiler missing from D-d) — closed. D-d's table gains the row for packages/services/service-analytics/src/read-scope-sql.ts#compileScopedFilterToSql: floor, chosen up front, with the reason (handed the leaf it would refuse every analytics read of sys_user that answers today) and the later-card note (O7). Its three code claims hold at the head: the $in arm throws needs an array (fail-closed) on a non-array comparand (L2194) and binds one parameter per member (val.map(vbind), L2197); the entry refuses a ReadableIds leaf by name (findReadableIdsLeaf, L843–851). The lane is added where the FAIL record asked: Consumers (@objectstack/service-analytics (its native-SQL read-scope compiler)), V1's "What lands" (domain:services, service-analytics) and its pins (analytics), D-g (the "Analytics" pin: a native-SQL query over sys_user answers the same rows as a direct find and is never refused), Consequences ("the analytics read-scope compiler takes the floor … four lanes"), and O7. Closed.

② The five should-fixes — all closed

  • (a) readFilter — closed. D-d gains "What the explainer publishes": as a floor evaluator it materialises the array and publishes the array, today's wire shape; ⛔ never the leaf; no Clause-② is owed for a readFilter consumer. The anchor is right: packages/spec/src/security/explain.zod.ts#ExplainDecisionSchema carries readFilter: z.unknown().optional() (L398), published by explain-engine.ts L3141. D-g gains the "Explain" pin (byte-identical readFilter for the same fixture); V1 says "the explainer publishes it".
  • (b) Consequences — closed. "Positive" now says the request path issues no read of the organization's membership, and names Leg 1 of #resolveUserAuthzGrants as still reading the caller's own sys_member rows on every request; "Failure surface" is now "Unchanged in kind: a sys_member outage still fails every request's resolution (503) through Leg 1's own read", and a subquery failure fails that read with the driver's envelope, "never a shorter list". Both match the resolver the FAIL record measured; no code moved in the delta.
  • (c) the nested case — closed. D-d gains "Every occurrence, nested ones included": the choice is made for every occurrence, including one inside a ReadableIds leaf's composed scope, naming the three composers (parent-record-read-gate.ts, attachment-access-hooks.ts, controlled_by_parent) and sql-driver.ts#readableIdsSubquery as the recursive compile. Verified at the head: readableIdsSubquery (L12996) runs this.applyFilters(sub, leaf.scope, options) (L13000), so an inner leaf meets applyFilterCondition the same as an outer one. D-g gains the "Nested" pin; V1 says the substitution descends into composed ReadableIds scopes.
  • (d) the ADR-0057-erp Status line — closed as the seat chose (yes). The line gained exactly one inline · **Amended** (2026-11-02, [ADR-0147](…), #29360): … entry after the ADR-0131 entry; everything before it is a byte-identical prefix (one-line diff, text appended). Checked against scripts/adr-index.mjs: statusKeywordOf takes the EARLIEST keyword within an 80-character window, and Accepted sits at index 0, so the record cannot be re-keyed by the trailing Amended; the Date column is the first ISO date (2026-06-21, unchanged); Supersedes/Superseded-by are read off header fields plus at most 160 characters after "superseded by" in the Status value, which never reaches the new entry (ADR-0131's entry, already there, is likewise absent from that column); the Status cell is cut at 64 characters. So the README row for 0057-erp is unchanged and no regeneration is owed — the README's non-appearance in the delta is right, not an omission. The recipe's ⛔ on the Status line names the re-key trap, which is measured not to fire here, and this file's own convention is the inline entry (ADR-0131's sits there); the record states the choice in its header and Acceptance criteria. A deliberate, measured deviation from the recipe, recorded; the approver reads it in the record.
  • (e) D-e anchor precision — closed. The Context-builders and Presence-probes rows now cite ScopedContext[SEED_USER_GRANTS] in packages/objectql/src/engine.ts, keyed by hook-run-as.ts#SEED_USER_GRANTS. At the head: engine.ts L22766 async [SEED_USER_GRANTS](), L22770 const needsPeers = !Array.isArray(ctx.org_user_ids).

O1 reworded — consistent with the record's own history: "The name." bullet in D-a (OrgMemberIds is the proposed name, a new export beside ReadableIds), O1 says the driver declaration is named by the S1 card and "naming is the lane seat's call, with no separate maintainer gate". provisional no longer occurs in the file. This follows the withdrawal 6583516156 of the decision request, prompted by the maintainer's question at 17:03Z; the record still lands only on a GOVERNED_APPROVERS approval (its Status line says so), which is the one gate that remains. O4 now reads as the FAIL record judged it (inputs' age, inside the cache's accepted staleness contract; not a set change).

③ The same-set invariant, re-tried where the delta touched it

  • The analytics floor row. compileScopedFilterToSql is handed getReadFilter's answer; on the floor that answer carries $in: S and compiles to today's IN (?, …) with today's bind ceiling (D-d: "the floor keeps today's costs"). Both analytics faces agree: NativeSQLStrategy and the /analytics/sql dry-run echo. The alternative the ReadableIds precedent suggests (decline in canHandle and let the ObjectQL strategy serve it) would keep the strategy's rows but refuse the echo, which reaches the compiler directly — so the floor is the right answer and the record picks it. S unchanged, both polarities.
  • The nested-scope descent. On the SQL family the inner leaf compiles inside readableIdsSubquery's applyFilters(sub, leaf.scope), as its own IN (SELECT user_id FROM sys_member …) with no tenant wall (D-b), while the outer subquery tenant-scopes P's table only (ADR-0139 D3) — the two walls do not mix. On a floor evaluator the substitution runs on the composed scope (ADR-0139 D2 step 2 lowers the scope "through the same lowering P's own direct read runs"), so the array is what the driver meets; driver-memory evaluates the leaf itself (V2) and evaluates a composed scope in process (replaceComposedReadableIds → readableParentIds, memory-driver.ts L1469). S for the nested occurrence is S(anchor) of the same context the scope was composed from; the delegated explainer's dctx carries the live principal's value (explain-engine.ts L904), so the nested S agrees with the direct one. Holds.
  • The readFilter statement. The published array is S from the unchanged materialiser (#resolveUserAuthzGrants, "keeps the array, default unchanged"), materialised at most once per request; a hand-built context with an array publishes that array (array wins); a context with no caller publishes today's []. The byte-identical pin is the right pin. Holds.

Nothing in the delta changes S on any path; D-0 still holds against the code the record cites.

④ Index and gates

docs/adr/README.md and scripts/adr-index.mjs are untouched in the delta and still right: no Status keyword changed, the 0147 row's title and status are the round-1 ones, the 0057-erp row is unchanged for the reasons in ②(d), the 0139 row is unchanged (Status line untouched). Counts (148 records / 143 numbers) stand. skip-changeset is still correct for a docs/adr/**-only diff. The governed path is unchanged: Tier H, the PR stays DRAFT until a GOVERNED_APPROVERS approval; this record does not stand in for it.

⑤ Remaining

No must-fix remains. One should-fix, new and small (a sentence; no set change):

  1. Name the seam that hands a non-engine getReadFilter caller the floor. The engine's floor substitution lives at the executor seam, which the in-process judges, the explainer and the analytics compiler never cross; D-d says each "is handed the floor" and V1 lands it, but not by which door. Two shapes keep S — materialise into the compiling context first (D-a's "an array wins"), or a shared substitution helper of the kind @objectstack/core already exports for ReadableIds (replaceComposedReadableIds). The V1 card should pick one; the record could say so in one sentence under D-d. Not blocking.

Implemented-by: claude/project-thread-qe1kwi
Reviewed-by: session_01ADHkmSduNGC2SZXszyrNut

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-steve · session_01ADHkmSduNGC2SZXszyrNut

Head-sha: 723e27a6b8ab7580eeb451c4c7781e049044248e · read 2026-11-02T17:30Z

维护者速读

  • 改了什么:新增 ADR-0147(Proposed)。id in current_user.org_user_ids 这类"能看见本组织成员"的行级权限,不再每次请求预读整个成员列表,而是让 SQL 驱动下推成子查询;不能编译的驱动(Mongo、OData、联邦对象、进程内判定、分析服务的原生 SQL)走现在的精确数组兜底,绝不拒绝。另给 ADR-0139 D1/D3 加两条带日期的指针注记,ADR-0057-erp 的 Status 行加一条 Amended。
  • 为什么改: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 之后成员集合已精确,但大组织每个请求多读一次成员表,成员数过了数据库绑定参数上限(SQLite 约 4 万、Postgres 65,535)时 sys_user 读取直接报错。
  • 风险与回滚:纯文档,不改代码。D-0「同集合不变式」锁死可见集合:任何会改变"谁能看见谁"的方案都列为已否决。回滚就是 revert 本 PR。
  • 先看:ADR-0147 的 D-0(不变式)、D-d(编译或兜底,含分析服务一行和嵌套作用域)、D-e(读取方清单)。
  • 两分钟验证:node scripts/check-adr-symbol-anchors.mjs && node scripts/check-adr-links.mjs,预期两行 ✅。
  • 需要你做的:Tier H,需要 GOVERNED_APPROVERS 批准本 PR(含对 ADR-0139「只此一个叶子」「无能力位」两条禁令在这一个叶子上的修订)。命名 OrgMemberIds 已由席位定。

席位意见:可批。契约复审在 f3a117f 判 FAIL(6583043471,两处遗漏),在现 head 的 delta 判 PASS(6583780256),两个 must-fix 与五个 should-fix 都已关;CI 全绿。

席位核对 — ACCEPT · domain:spec#2 · head 723e27a6

  1. Shape: draft, base main, Fixes #29360, label skip-changeset. Clause-②: no · Metadata-protocol: none (docs only). 4 files, +412/−4, all under docs/adr/**, inside claim 6581842163 plus the ADR-0139 notes the review asked for. Governed path (Tier H).
  2. Premise measured: on main sys_user's scope lowers through rls-compiler.ts → cel-to-filter.ts to { id: { $in: [...] } }, one bind per member, and no scope-usable semi-join leaf exists; the review read the resolver, compiler, drivers and the analytics compiler at the head's merge base and found the ADR's account of each true.
  3. Gates: every non-skipped check-run on 723e27a6 is success (44 runs; Check Changeset skipped by the skip-changeset label, as designed). ADR anchors and links resolve.
  4. Accepted deviations: the plugin-auth admin-set-user-manager.ts comment is left to V3 (code, server lane); the card's reader list is corrected by measurement; the ADR-0057-erp Status line takes an inline · **Amended** entry, which the indexer's earliest-keyword rule leaves byte-unchanged in docs/adr/README.md (recorded in 6583780256).
  5. Out of scope: the delta review's one non-blocking should-fix (name the seam that hands a non-engine getReadFilter caller the floor: materialise-first or a shared substitution helper) is left to the V1 card. The execution plan's S1 → V1 → V2 → V3 (→ V4) cards are filed after this ADR is accepted.
  6. Landing: Tier H. The PR stays DRAFT; ⛔ the seat does not ready, queue or arm auto-merge. It waits for a GOVERNED_APPROVERS approval, and after merge the seat writes the landing note on 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 and removes pm:dispatched.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-steve · session_01ADHkmSduNGC2SZXszyrNut

Hand-off: seat 2 is going idle (the maintainer's wind-down for the spec lane), and this PR is the only seat-2 item left.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 3, 2026 01:43
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 3, 2026 01:43
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit c76cdeb Nov 3, 2026
57 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-qe1kwi branch November 3, 2026 02:05
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/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants