Skip to content

Commit c76cdeb

Browse files
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)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi93GrVeZda4ZCMrastvdgi1C)_ 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 各加一条带日期的指针注记。 - 为什么改:#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 #29360 lists them (the #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 #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 #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 #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 <details><summary>Derived gates on `723e27a6`: 21 of 21 exit 0, --ran reconciled</summary> `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. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (n/a: no schema) - [x] 2. Every name I added uses the casing for its kind. (`OrgMemberIds`, PascalCase like `ReadableIds`) - [x] 3. No formatter ran; `pnpm lint` passes. (no lintable file in the diff) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset. (publishes nothing: `skip-changeset`) - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the maintainer-facing exception the lane prescribes). - [x] 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.com/claude-code) https://claude.ai/code/session_01ADHkmSNasUm3e1dfbV5aaD --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmSNasUm3e1dfbV5aaD)_ <!-- signed: domain:spec#2 · os-steve · session_01ADHkmSduNGC2SZXszyrNut --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 85ea224 commit c76cdeb

4 files changed

Lines changed: 412 additions & 4 deletions

‎docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0057: ERP-Grade Authorization Core — Business-Unit Partitioning, Scope-Depth Grants, and Hierarchy Rollup
22

3-
**Status**: Accepted in part (2026-06-21) — D1–D3/D8 implemented (scope-depth enum + evaluator, `sys_business_unit` tree, hierarchy-resolver seam, conformance rows); **D4–D7 superseded by ADR-0090 D3 / ADR-0095**: the `sys_role`/`sys_user_role`/`role_and_subordinates` vocabulary never shipped — the code realizes them as flat `sys_position`/`sys_user_position` with rollup on the BU tree. This ADR's own implementation-status table predates that supersession; read it with the rename applied. · **Amended** (2026-10-14, [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D3/D13): the flat `sys_position` row object named above is itself retired by #15204 stage 8 (`66949b7729`, 2026-10-13), and its `sys-position.object.ts`, which the path note below names, is deleted; a position is a metadata-registry definition, and the `sys_user_position` assignment stays.
3+
**Status**: Accepted in part (2026-06-21) — D1–D3/D8 implemented (scope-depth enum + evaluator, `sys_business_unit` tree, hierarchy-resolver seam, conformance rows); **D4–D7 superseded by ADR-0090 D3 / ADR-0095**: the `sys_role`/`sys_user_role`/`role_and_subordinates` vocabulary never shipped — the code realizes them as flat `sys_position`/`sys_user_position` with rollup on the BU tree. This ADR's own implementation-status table predates that supersession; read it with the rename applied. · **Amended** (2026-10-14, [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D3/D13): the flat `sys_position` row object named above is itself retired by #15204 stage 8 (`66949b7729`, 2026-10-13), and its `sys-position.object.ts`, which the path note below names, is deleted; a position is a metadata-registry definition, and the `sys_user_position` assignment stays. · **Amended** (2026-11-02, [ADR-0147](./0147-membership-scoped-rls-lowers-to-an-engine-internal-org-member-semi-join-leaf.md), #29360): D3's "mirroring the `org_user_ids` cap" carries a dated note; that cap no longer exists, and D3's own bound requirement is unchanged.
44
**Shares its number with**: [`0057-system-data-lifecycle-and-retention`](./0057-system-data-lifecycle-and-retention.md) (System data has a lifecycle — declarative retention, rotation, and reclamation) — an unrelated decision under the same number. Numbers are never reassigned (AGENTS.md Prime Directive 13); cite this record unambiguously as `ADR-0057-erp-authorization-core-business-units-and-scope-depth`.
55
**Deciders**: ObjectStack Protocol Architects
66
**Path note (2026-09), paths only — no decision changes**: this record cites the **pre-rename**
@@ -160,6 +160,14 @@ subordinate_user_ids -> users below me on the sys_user.manager_id chain (manag
160160
- Sourced by `BusinessUnitGraphService` (D2) and the existing `managerOf` chain
161161
(`packages/plugins/plugin-sharing/src/team-graph.ts#managerOf`). Bounded (hard cap + cache, mirroring the
162162
`org_user_ids` cap) and **org-scoped**.
163+
164+
> **Amendment note (2026-11-02, [ADR-0147](./0147-membership-scoped-rls-lowers-to-an-engine-internal-org-member-semi-join-leaf.md) — #29360).**
165+
> The precedent this bullet cites no longer exists as a cap. Since PR #29356 (`f72bc9e5`, #29281) `org_user_ids`
166+
> holds every member of the active organization plus the caller: the first `sys_member` read is completed by keyset
167+
> pages (`packages/core/src/security/resolve-authz-context.ts#readOrganizationMembersToCompletion`). What bounds it
168+
> now is the backend's bind-parameter ceiling, which ADR-0147 (Proposed) proposes to retire on the SQL family with an
169+
> engine-internal semi-join leaf. This D3's own requirement, that the rollup sets are bounded (hard cap + cache) and
170+
> org-scoped, is unchanged by this note. It stands on its own reason, not on the precedent.
163171
- Rollup is **additive only** (widens, never restricts) and respects tenant isolation. It powers both
164172
D1's `unit*` scopes and sharing-rule recipients (D6). Because it is pure pre-resolution, the choice
165173
of *which* tree drives visibility is an authoring/modelling decision, not a runtime-mechanism one.

‎docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ follow from that position, and they are the whole reason for it:
135135
The leaf is legal on `find`, `findOne`, `count` and `aggregate` (`where` only). On a write verb, in a `having`, or at
136136
any position but a `$in` comparand, the engine refuses it loudly. ⛔ No second leaf, no `EXISTS`, no correlated form.
137137

138+
> **Amendment note (2026-11-02, [ADR-0147](./0147-membership-scoped-rls-lowers-to-an-engine-internal-org-member-semi-join-leaf.md) — #29360).**
139+
> ADR-0147 (Proposed) revisits "No second leaf" for **one** leaf and nothing else: the organization-member set that
140+
> `FIELD in current_user.org_user_ids` names, under its own brand, never as a mode of `ReadableIds`. "No `EXISTS`, no
141+
> correlated form" stands. So does everything this D1 says about `ReadableIds`.
142+
138143
### D2 — ObjectQL composes the scope, once per parent object per request, fail closed
139144

140145
At the engine's executor boundary — on the final AST, after ingress parsing, the comparand doors, the middleware chain
@@ -178,6 +183,12 @@ restriction".
178183
⛔ not `INVALID_FILTER`. A consumer does not catch it into its deny-all sentinel: the read fails and says why
179184
(prefer failing to falling back). ⛔ No `DriverCapabilities` bit returns: the 17.0.0 rationale stands — a bit the
180185
engine does not consult is inert — and here the driver answers by compiling or refusing, so there is nothing to read.
186+
187+
> **Amendment note (2026-11-02, [ADR-0147](./0147-membership-scoped-rls-lowers-to-an-engine-internal-org-member-semi-join-leaf.md) — #29360).**
188+
> For `ReadableIds` this bullet stands as written. ADR-0147 (Proposed) revisits it for its own leaf only. There a
189+
> refusal is excluded, because it would fail every `sys_user` read on a driver that does not compile it. So the
190+
> engine consults a driver declaration to choose between the compiled leaf and the exact-array floor before the call.
191+
> A declaration the engine consults is not the inert bit the 17.0.0 rationale removed.
181192
- The leaf joins the per-driver conformance case-sets, so a driver that compiles it is measured and one that refuses
182193
is recorded as refusing.
183194

0 commit comments

Comments
 (0)