Skip to content

Commit e3948ae

Browse files
fix(platform-objects): name an organization membership, a two-factor enrollment and the SCIM join rows by the people and groups they point at (#30520)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg3V8xjevigoU3sh2gDhRorg)_ domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb ## 维护者速读 - 改了什么:组织成员、两步验证、SCIM 组成员、SCIM 投影授权四类记录的标题,从拼接原始 id 改为经 lookup 读一跳的名字(人名为空时用邮箱;SCIM 用户名与组显示名),写法与业务单元成员、团队成员一致;授权解析器读成员表的三处改为只取决策所需的列。 - 为什么改:这四类记录的页头、列表标题列和被引用处都显示原始 id,管理员看不出是谁;而成员标题一旦读一跳,不投影的授权读会让每个已登录请求多读两次用户表,所以同一改动里把这三处读收窄。 - 风险与回滚:只改平台自带对象的声明和一处内部读投影,不存列、不迁移;标题按读者权限读,读不到时为空而不是 id;认证前奏的读次数和往返次数保持原钉数(8 读 4 往返)。回滚即撤本 PR。 - 先看:`packages/core/src/security/resolve-authz-context.ts` 的 `MEMBER_ROW_COLUMNS` 与三处 `sys_member` 读;`packages/platform-objects/src/identity/sys-member.object.ts` 的 `display_title`;`resolve-authz-context.grant-projection.test.ts` 新增的成员投影 describe。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run src/auth-prelude-query-count.pin.test.ts`,预期 8 条全绿;在只改标题、不收窄读的树上其中 6 条红(sys_user 2 读变 4 读)。 - 需要你做的:无,席位落地。 Fixes #30454 Clause-②: no — no exported member, accepted value or authorable key changes: four shipped platform objects' computed `display_title` now serves names instead of ids, and the authorization resolver's internal `sys_member` reads name their columns Metadata-protocol: none — shipped platform metadata and an internal read, not what a metadata author writes Skills-docs: none — `git grep -e sys_member -e sys_two_factor -e sys_scim_group_member -e sys_scim_projection_grant -- 'skills/**'` hits `skills/objectstack-automation/SKILL.md:168` and `skills/objectstack-automation/evals/flows-triggers-approvals.json:194`, both about a system-context write to `sys_member` being refused, which stays true ## What changes **1. Four record titles read names one hop through their lookups** (ADR-0079 addendum 2026-10-18), in the form `sys_business_unit_member` and `sys_team_member` use. A person is `isBlank(trim(record.user_id.name)) ? record.user_id.email : record.user_id.name`. Each hop is read under the reader's authority; an unreadable or missing target leaves the title null, never an id. Each title keeps its shape, and the fields' labels and descriptions are unchanged, so no translation bundle moves. | Object | Was | Now | |---|---|---| | `sys_member` | `USER_ID (role)` | `Ada Lovelace (admin)`; `Ada Lovelace` with no role | | `sys_two_factor` | `Two-factor for USER_ID` | `Two-factor for Ada Lovelace` | | `sys_scim_projection_grant` | `role → USER_ID` | `billing_admin → Ada Lovelace` | | `sys_scim_group_member` | `SCIM_USER_ID in GROUP_ID` | `ada.lovelace@idp.example in Operations` | For the SCIM join, each hop names its target's own title pointer: `sys_scim_user.nameField` is `user_name` and `sys_scim_group.nameField` is `display_name`. Both are required stored text, so there is no second hop and no null guard. The pins assert this. **2. The authorization resolver's `sys_member` reads name their columns** (`packages/core/src/security/resolve-authz-context.ts`; seat ruling A, Claim amendment 6658119458). With no projection, a read computes every formula field. `sys_member`'s title now reads the person, so each unprojected read added a batched `sys_user` read: two per authenticated request, measured by `auth-prelude-query-count.pin.test.ts` (`sys_user` 2 → 4, 5 of 7 red). Each read now names what its caller reads, as `GRANT_ROW_COLUMNS` already does for `sys_user_permission_set`: - the caller's memberships (`{ user_id }`, Leg 1): `MEMBER_ROW_COLUMNS` = `organization_id`, `role`, `valid_from`, `valid_until`. These are read by `accessible_org_ids` (§3b), `heldPositionNames` and `nextGrantValidityBoundary`; - the active organization's first page (`{ organization_id }`, Leg 1): `ORG_MEMBER_PAGE_COLUMNS` = `id`, `user_id`, the columns its completion pages already ask for. §5 keeps only `user_id`; - `countPositionHolders`: `MEMBER_HOLDER_ROW_COLUMNS` = `user_id` plus the four above. `sys_member` does not declare `valid_from` or `valid_until` today. They are named on purpose. Measured on the real engine (ObjectQL + driver-sql), a projection that names an undeclared column leaves it out with no error: `['organization_id','role','valid_from','valid_until']` returns `{ organization_id, role }`. So nothing changes today, and the window is read as soon as the object declares those columns. That is what §3's comment promises ("correct the moment they do"). Leaving them out would silently drop that promise. Lines 515–660 (draft PR #30287) are untouched. **Changeset:** `@objectstack/platform-objects` and `@objectstack/core`, patch, both declaration lines. ## Tests - `identity-display-title.test.ts` (the shared title harness): the four cases now carry the related rows that the read path preloads, are titled by names, and still equal the text of the retired template. They never contain an id. A new top-level `describe` pins the following for each object: - every path the formula reads, with each lookup read only through its hop; - the email fallback when the name is blank; - a null title for every spelling of an unresolved lookup; - for SCIM, that each hop names its target's `nameField` and that the field is required text. - `resolve-authz-context.grant-projection.test.ts` (the shared projection harness): a new top-level `describe` pins the two prelude membership reads' column sets. It also pins that the envelope resolved over projected rows equals the envelope over full rows. That equality is checked for seven membership states: owner, another organization, external, two roles, `valid_until` past, `valid_from` future, and no active organization. The states resolve at least five distinct envelopes. It further pins that `countPositionHolders` counts the same from projected rows and reads `user_id` beside the four columns. The first case's "no other read names any" is narrowed to exclude `sys_member`. - `auth-prelude-query-count.pin.test.ts`: a new case shows that the shipped `sys_member` title reads `record.user_id.name`, that a full read computes it (`Prelude (owner)`), and that the prelude still makes 8 reads in 4 round trips. The pinned counts are unchanged. ## Acceptance notes - **Related lists (the order's measurement).** No Setup page derives a related list from these four objects' lookups. Both pages that list `sys_member` are authored `kind: 'full'` pages, and they name their own columns: `sys_organization_detail` (Members, `user_id, role, created_at`) and `sys_user_detail` (Organizations, `organization_id, role, created_at`). `sys_user_detail` does not list `sys_two_factor`. No Setup navigation reaches `sys_scim_user` or `sys_scim_group`. So no `relatedListColumns` is added. - **Other `sys_member` readers that name no columns** now pay the title's hop: one batched `sys_user` read per such `find` (measured `[1, 0]`, unprojected vs projected). The prelude read is the per-request one and is fixed here. Readers elsewhere (`git grep "'sys_member'"` lists plugin-auth, plugin-sharing, plugin-approvals, service-messaging and others) were not swept for projections. The plugin-auth, plugin-sharing and plugin-approvals suites are green on this head. Noted, not filed · carrier: seat. - **Reader-dependent titles.** A reader who may read the row but not its target sees an empty title. That is the rule `sys_business_unit_member` follows. `sys_scim_group` and `sys_scim_group_member` both default to the row scope `id == null`, so without an explicit grant a reader sees neither the rows nor their titles. - `sys_scim_subject` (`nameField: 'user_id'`, a bare lookup pointer) is outside this card's four objects and unchanged. ## Verification Readings on head `2d857e278e` (the fix's six commits plus one merge of `main` at `f506b79d85`, which brings in #30452) unless a leg names its own commit. <details><summary>Red on main, green, before and after the projection, ablation</summary> ``` red (3bb8bb0: the new pins over main's object files): identity-display-title.test.ts -> Tests 16 failed | 25 passed (41) green (671545a): same file -> Tests 41 passed (41) authz suites BEFORE the projection (main's resolve-authz-context.ts restored over the tree, core rebuilt, dist preflight --absent MEMBER_HOLDER_ROW_COLUMNS ok; tests at 5b2ba4e): core src/security -> Tests 2 failed | 1211 passed (1213) (the two column-set pins) plugin-auth auth-prelude-query-count.pin -> Tests 6 failed | 2 passed (8) (sys_user 2 -> 4) restored: git checkout HEAD; blob 2ff6828 == HEAD, git status clean authz suites AFTER (5b2ba4e, core rebuilt, preflight present): core src/security -> Test Files 62 passed (62), Tests 1213 passed (1213) plugin-auth auth-prelude-query-count.pin -> Tests 8 passed (8) ablation 1 (5b2ba4e): MEMBER_ROW_COLUMNS without 'role' anchor x1 -> x0, replacement x0 -> x1 -> grant-projection: Tests 27 failed | 8 passed (35); restored, blob == HEAD ablation 2 (5b2ba4e): sys_member's hop expression -> the retired id splice anchor x1 -> x0, replacement x0 -> x1 -> identity-display-title: Tests 5 failed | 36 passed (41); restored, blob == HEAD ``` </details> <details><summary>The titles a read serves, through the real engine (one-off measurement, not committed)</summary> ObjectQL + SqlDriver (better-sqlite3 in memory) over the built `dist/` at `671545ad`, registering `SysUser`, `SysOrganization`, the four objects and their SCIM targets, with a reader-scoping middleware (the setup of `plugin-security/src/business-unit-member-title.test.ts`): ``` PASS sys_member / sys_two_factor / sys_scim_group_member / sys_scim_projection_grant registered nameField: "display_title" PASS sys_member write response title: "Ada Lovelace (admin)" PASS sys_member findOne title: "Ada Lovelace (admin)" PASS sys_member servedRecordTitle: "Ada Lovelace (admin)" PASS sys_member projected find: [{"display_title":"Ada Lovelace (admin)"}] PASS sys_member blank name -> email (role defaults to member on write): "grace@example.com (member)" PASS sys_member follows rename: "Ada King (admin)" PASS sys_member reader may not read user: title null: null PASS sys_member no hidden name leaks: false PASS sys_member reader may read user: "Ada Lovelace (admin)" PASS sys_member missing user: null: null PASS sys_member hop reads of sys_user: unprojected find / stored-column find: [1,0] PASS sys_two_factor write response title: "Two-factor for Ada Lovelace" PASS sys_two_factor blank name -> email: "Two-factor for grace@example.com" PASS sys_scim_group_member write response title: "ada.lovelace@idp.example in Operations" PASS sys_scim_group_member reader may read neither: title null: null PASS sys_scim_group_member reader may read both: "ada.lovelace@idp.example in Operations" PASS sys_scim_projection_grant write response title: "billing_admin → Ada Lovelace" PASS (each of the four) adds no stored column MEASURE: all checks hold ``` Probe on the same engine: `find('sys_member', { fields: ['organization_id','role','valid_from','valid_until'] })` returns `[{"organization_id":"org_default","role":"admin"}]`; an undeclared projected column is left out, not refused. </details> <details><summary>Suites, typecheck, lint</summary> ``` after the merge of main (2d857e2, whole workspace rebuilt: turbo build 119/119): @objectstack/platform-objects Tests 1537 passed (1537) @objectstack/core Tests 3099 passed (3099) @objectstack/plugin-auth Tests 3975 passed | 10 skipped (3985) @objectstack/plugin-security Tests 5357 passed | 45 skipped (5402) @objectstack/plugin-sharing Tests 1405 passed (1405) @objectstack/plugin-approvals Tests 1469 passed (1469) typecheck (5b2ba4e): platform-objects exit 0, core exit 0, plugin-auth exit 0 pnpm exec eslint --no-inline-config on the eight changed .ts files: exit 0 ``` </details> <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `2d857e278e`: 64 commands; each run with its exit code recorded before any pipe. `--ran`: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN. - All 64 exit 0, including `pnpm check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:dts-closure` after a whole-workspace build, `pnpm check:i18n`, `check:authz-resolver` and `check:engine-double-contract`. - `node scripts/check-plugin-teardown-shape.mjs --self-test`: exit 0 after fetching its control commit into this shallow clone. - The four artifact-roster gates the derivation flags as keeping their roster in a directory this diff touches were also run: `check:auth-mount-ledger`, `check:changeset-fixed`, `check:error-code-casing` and `check:filter-alias-parity`, all exit 0. </details> Dogfood: not owed (no `!` in the title, no `(narrowing)` arm). --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZdTcFgv9MUk1tpKUo)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d5f3bc5 commit e3948ae

9 files changed

Lines changed: 325 additions & 45 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
'@objectstack/core': patch
4+
---
5+
6+
fix(platform-objects): an organization membership, a two-factor enrollment and the two SCIM join rows are named by the people and groups they point at, not raw ids
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **The record titles.** Four identity join objects built their title (`display_title`) by splicing stored lookup ids, so a record page's heading, a list's Title column and every reference to the row read ids. Each now reads names one hop through its lookups (ADR-0079 addendum, 2026-10-18), as `sys_business_unit_member` and `sys_team_member` do. A person is their `name`, or their `email` where the name is blank:
12+
- `sys_member`: `Ada Lovelace (admin)`, or `Ada Lovelace` with no role;
13+
- `sys_two_factor`: `Two-factor for Ada Lovelace`;
14+
- `sys_scim_projection_grant`: `billing_admin → Ada Lovelace`;
15+
- `sys_scim_group_member`: the provisioned user's `user_name` and the group's `display_name`, each its object's own title: `ada.lovelace@idp.example in Operations`.
16+
17+
Every hop is read as the reader: a person, provisioned user or group the reader may not read, or one that no longer exists, leaves the title empty rather than showing an id. No column is stored, so a rename shows on the next read.
18+
- **The authorization prelude reads no title.** `@objectstack/core`'s grant resolver reads `sys_member` three times (the caller's memberships, the active organization's first page of members, and the position-holder count). Each read now names the columns it decides on, as the `sys_user_permission_set` grant read already did, so the membership's title, which now reads a `sys_user` row, is never computed there. An authenticated request still pays the same reads and round trips as before.

‎packages/core/src/security/resolve-authz-context.grant-projection.test.ts‎

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3131
import { ADMIN_FULL_ACCESS } from '@objectstack/spec';
3232
import { resetPlatformAdminEmailMemo } from './platform-admin.js';
33-
import { resolveUserAuthzGrants } from './resolve-authz-context.js';
33+
import { countPositionHolders, resolveUserAuthzGrants } from './resolve-authz-context.js';
3434
import { FIXTURES, T0, makeRecordingQl } from './__tests__/resolve-authz-context.batch-equivalence.testkit.js';
3535
import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js';
3636

@@ -173,13 +173,14 @@ async function resolveBoth(
173173
}
174174

175175
describe('the prelude\'s grant read names the columns the resolver decides on', () => {
176-
it('the grant read names exactly permission_set, organization_id, valid_from and valid_until — no other read names any', async () => {
176+
it('the grant read names exactly permission_set, organization_id, valid_from and valid_until — no other read but the memberships names any', async () => {
177177
const { projections } = await resolveBoth('u1', tablesFor(GRANT_FIXTURES.at(-1)!.grants), { tenantId: ORG_A, nowMs: T0 }, definitions);
178178
const grantReads = projections.filter((p) => p.object === 'sys_user_permission_set');
179179
expect(grantReads).toHaveLength(1);
180180
expect([...(grantReads[0]!.fields as string[])].sort()).toEqual(['organization_id', 'permission_set', 'valid_from', 'valid_until']);
181-
// No other read in the prelude changes.
182-
expect(projections.filter((p) => p.object !== 'sys_user_permission_set' && p.fields !== undefined)).toEqual([]);
181+
// No other read in the prelude changes; the membership reads name theirs
182+
// (pinned below).
183+
expect(projections.filter((p) => !['sys_user_permission_set', 'sys_member'].includes(p.object) && p.fields !== undefined)).toEqual([]);
183184
});
184185

185186
describe.each(GRANT_FIXTURES)('$name', ({ grants, tenantId }) => {
@@ -209,3 +210,86 @@ describe('the prelude\'s grant read names the columns the resolver decides on',
209210
});
210211
});
211212
});
213+
214+
/**
215+
* [#30454] The prelude's `sys_member` reads name the columns the resolver
216+
* decides on, for the same reason as the grant read: the membership's record
217+
* title reads the person one hop through `user_id`, which a read naming no
218+
* columns pays for with a `sys_user` read and a round trip in every request.
219+
* The user's own memberships carry the organization, the role and the ADR-0091
220+
* validity window; the active organization's first page carries the one value
221+
* §5 keeps, as its completion pages do. Pinned the same way: the envelope over
222+
* projected rows equals the envelope over full rows, per membership state the
223+
* resolver discriminates, and so does the position-holder count.
224+
*/
225+
describe('[#30454] the prelude\'s membership reads name the columns the resolver decides on', () => {
226+
/** A membership row as stored: every column the object declares, and the title a full read computes. */
227+
const memberRow = (id: string, cols: Record<string, unknown>) => ({
228+
id,
229+
user_id: 'u1',
230+
organization_id: ORG_A,
231+
role: 'member',
232+
created_at: past,
233+
...cols,
234+
display_title: `Ursula (${String(cols.role ?? 'member')})`,
235+
});
236+
237+
const tablesWith = (members: Array<Record<string, unknown>>): Record<string, unknown[]> => ({
238+
...tablesFor([]),
239+
sys_member: [
240+
...members,
241+
// A fellow member of the active organization (§5's `org_user_ids`).
242+
{ id: 'm_peer', user_id: 'u2', organization_id: ORG_A, role: 'admin', created_at: past, display_title: 'Vera (admin)' },
243+
],
244+
});
245+
246+
const MEMBER_FIXTURES: Array<{ name: string; members: Array<Record<string, unknown>>; tenantId?: string }> = [
247+
{ name: 'an owner of the active organization', tenantId: ORG_A, members: [memberRow('m_owner', { role: 'owner' })] },
248+
{ name: 'an admin of another organization', tenantId: ORG_A, members: [memberRow('m_b', { role: 'admin', organization_id: ORG_B })] },
249+
{ name: 'an external member', tenantId: ORG_A, members: [memberRow('m_ext', { role: 'external' })] },
250+
{ name: 'two roles in one membership', tenantId: ORG_A, members: [memberRow('m_two', { role: 'admin,member' })] },
251+
{ name: 'a lapsed membership (valid_until past)', tenantId: ORG_A, members: [memberRow('m_lapsed', { role: 'owner', valid_until: past })] },
252+
{ name: 'a membership not yet valid (valid_from future)', tenantId: ORG_A, members: [memberRow('m_pending', { role: 'owner', valid_from: future })] },
253+
{ name: 'memberships in two organizations, none active', members: [memberRow('m_a', { role: 'admin' }), memberRow('m_b', { role: 'member', organization_id: ORG_B })] },
254+
];
255+
256+
it('the user\'s membership read names organization_id, role, valid_from and valid_until; the organization\'s names id and user_id', async () => {
257+
const { projections } = await resolveBoth('u1', tablesWith([memberRow('m_owner', { role: 'owner' })]), { tenantId: ORG_A, nowMs: T0 }, definitions);
258+
const memberReads = projections.filter((p) => p.object === 'sys_member').map((p) => [...(p.fields as string[])].sort());
259+
expect(memberReads).toHaveLength(2);
260+
expect(memberReads).toEqual(expect.arrayContaining([
261+
['organization_id', 'role', 'valid_from', 'valid_until'],
262+
['id', 'user_id'],
263+
]));
264+
});
265+
266+
describe.each(MEMBER_FIXTURES)('$name', ({ members, tenantId }) => {
267+
it('resolves the identical envelope from the projected rows as from the full rows', async () => {
268+
const opts = { ...(tenantId ? { tenantId } : {}), nowMs: T0 };
269+
const { full, projected } = await resolveBoth('u1', tablesWith(members), opts, definitions);
270+
expect(projected).toEqual(full);
271+
});
272+
});
273+
274+
it('the fixtures discriminate: they resolve distinct envelopes', async () => {
275+
const seen = new Set<string>();
276+
for (const { members, tenantId } of MEMBER_FIXTURES) {
277+
const { projected } = await resolveBoth('u1', tablesWith(members), { ...(tenantId ? { tenantId } : {}), nowMs: T0 }, definitions);
278+
seen.add(JSON.stringify(projected));
279+
}
280+
expect(seen.size).toBeGreaterThanOrEqual(5);
281+
});
282+
283+
it('the position-holder count reads user_id beside those columns, and counts the same from projected rows', async () => {
284+
const tables = tablesWith([memberRow('m_owner', { role: 'owner' }), memberRow('m_lapsed', { user_id: 'u3', role: 'owner', valid_until: past })]);
285+
const positions = ['org_owner', 'org_admin', 'everyone'];
286+
const full = engineOver(tables, false);
287+
const projected = engineOver(tables, true);
288+
const fullCounts = await countPositionHolders(full.ql, ORG_A, positions, { nowMs: T0 });
289+
const projectedCounts = await countPositionHolders(projected.ql, ORG_A, positions, { nowMs: T0 });
290+
expect([...projectedCounts]).toEqual([...fullCounts]);
291+
expect(projectedCounts.get('org_owner')).toBe(1);
292+
const memberRead = projected.projections.find((p) => p.object === 'sys_member');
293+
expect([...(memberRead!.fields as string[])].sort()).toEqual(['organization_id', 'role', 'user_id', 'valid_from', 'valid_until']);
294+
});
295+
});

‎packages/core/src/security/resolve-authz-context.ts‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -954,6 +954,29 @@ function grantAppliesInTenant(organizationId: string | null, tenantId: string |
954954
*/
955955
const GRANT_ROW_COLUMNS = ['permission_set', 'organization_id', 'valid_from', 'valid_until'] as const;
956956

957+
/**
958+
* [#30454] The columns of a principal's own `sys_member` row this resolver
959+
* decides on, and the only ones its membership reads ask for: the
960+
* organization (`accessible_org_ids`, §3b), the role ({@link heldPositionNames})
961+
* and the ADR-0091 validity window (`isGrantActive`,
962+
* `nextGrantValidityBoundary`). The reads run in every request's prelude, and a
963+
* read naming no columns computes every formula field the object declares —
964+
* the membership's record title reads the person one hop through `user_id`, a
965+
* `sys_user` read and a round trip — for values nothing here reads. The two
966+
* bounds are named although `sys_member` declares neither today: a read names
967+
* an undeclared column to no effect, and so the moment the object declares
968+
* them they are read, as §3's comment promises. A helper below that learns to
969+
* read another column of a membership row adds it here.
970+
*/
971+
const MEMBER_ROW_COLUMNS = ['organization_id', 'role', 'valid_from', 'valid_until'] as const;
972+
973+
/**
974+
* [#30454] {@link MEMBER_ROW_COLUMNS} plus the member (`user_id`), for
975+
* {@link countPositionHolders}, which reads the organization's memberships
976+
* and groups them by person.
977+
*/
978+
const MEMBER_HOLDER_ROW_COLUMNS = ['user_id', ...MEMBER_ROW_COLUMNS] as const;
979+
957980
/**
958981
* [#29281] The bound of the first leg's `sys_member` read for the active
959982
* organization — the read the fellow-org peer list (`org_user_ids`, §5) is
@@ -969,7 +992,9 @@ const GRANT_ROW_COLUMNS = ['permission_set', 'organization_id', 'valid_from', 'v
969992
* ordinary member's `sys_user` reads — a people picker's search, a read by id,
970993
* a lookup label — lost them (the ids are random, so which members vanished was
971994
* arbitrary and moved as members joined). Unchanged as a bound, so an
972-
* organization under it issues byte-identically the one read it always did.
995+
* organization under it issues the one read it always did; that read asks for
996+
* the completion pages' columns ({@link ORG_MEMBER_PAGE_COLUMNS}), the one
997+
* value §5 keeps, so it computes no formula field (#30454).
973998
*/
974999
const ORG_MEMBERS_FIRST_READ_ROWS = 1000;
9751000

@@ -1526,7 +1551,7 @@ export async function countPositionHolders(
15261551
const limit = opts.limit ?? 10000;
15271552
const nowMs = opts.nowMs ?? Date.now();
15281553
const [members, userPositionRows] = await Promise.all([
1529-
tryFind(ql, 'sys_member', { organization_id: tenantId }, limit),
1554+
tryFind(ql, 'sys_member', { organization_id: tenantId }, limit, undefined, MEMBER_HOLDER_ROW_COLUMNS),
15301555
tryFind(ql, 'sys_user_position', { organization_id: tenantId }, limit),
15311556
]);
15321557
if (members.length >= limit || userPositionRows.length >= limit) return counts;
@@ -1858,9 +1883,9 @@ export async function resolveUserAuthzGrants(
18581883
!grants.email || !grants.permissions.includes('ai_seat') || platformAdminConfig.emails.length > 0;
18591884
const [, members, userPositionRows, orgMembersLeg, upsRowsAll] = await Promise.all([
18601885
needsUserRow ? getUserRow() : Promise.resolve(undefined),
1861-
tryFind(ql, 'sys_member', { user_id: userId }, 200),
1886+
tryFind(ql, 'sys_member', { user_id: userId }, 200, undefined, MEMBER_ROW_COLUMNS),
18621887
tryFind(ql, 'sys_user_position', { user_id: userId }, 200),
1863-
tenantId ? tryFind(ql, 'sys_member', { organization_id: tenantId }, ORG_MEMBERS_FIRST_READ_ROWS) : Promise.resolve([] as any[]),
1888+
tenantId ? tryFind(ql, 'sys_member', { organization_id: tenantId }, ORG_MEMBERS_FIRST_READ_ROWS, undefined, ORG_MEMBER_PAGE_COLUMNS) : Promise.resolve([] as any[]),
18641889
tryFind(ql, 'sys_user_permission_set', { user_id: userId }, 100, undefined, GRANT_ROW_COLUMNS),
18651890
]);
18661891

0 commit comments

Comments
 (0)