Skip to content

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

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-0kalts
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-0kalts

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

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.

Red on main, green, before and after the projection, ablation
red (3bb8bb06: the new pins over main's object files):
  identity-display-title.test.ts -> Tests 16 failed | 25 passed (41)
green (671545ad): 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 5b2ba4ec):
  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 2ff68289865e == HEAD, git status clean
authz suites AFTER (5b2ba4ec, 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 (5b2ba4ec): MEMBER_ROW_COLUMNS without 'role'
  anchor x1 -> x0, replacement x0 -> x1 -> grant-projection: Tests 27 failed | 8 passed (35); restored, blob == HEAD
ablation 2 (5b2ba4ec): 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
The titles a read serves, through the real engine (one-off measurement, not committed)

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.

Suites, typecheck, lint
after the merge of main (2d857e278e, 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 (5b2ba4ec): 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
Gates

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.

Dogfood: not owed (no ! in the title, no (narrowing) arm).


Generated by Claude Code

…okups point at

sys_member, sys_two_factor, sys_scim_projection_grant and
sys_scim_group_member read their record title one hop through their
lookups (ADR-0079 addendum 2026-10-18), as sys_business_unit_member does,
instead of splicing the stored ids.

Claude-Session: https://claude.ai/code/session_01Utp6CZdTcFgv9MUk1tpKUo
Co-authored-by: Claude <noreply@anthropic.com>
…s on

A membership read naming no columns computes every formula field, and
sys_member's record title now reads the person one hop through user_id,
a sys_user read and a round trip in every request's prelude.

Claude-Session: https://claude.ai/code/session_01Utp6CZdTcFgv9MUk1tpKUo
Co-authored-by: Claude <noreply@anthropic.com>
… prelude with a one-hop member title

Claude-Session: https://claude.ai/code/session_01Utp6CZdTcFgv9MUk1tpKUo
Co-authored-by: Claude <noreply@anthropic.com>
…r projects its membership reads

Claude-Session: https://claude.ai/code/session_01Utp6CZdTcFgv9MUk1tpKUo
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/platform-objects, touching 12 documentable anchor(s).

18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json fa9c0aa2a0a8c2752f83b57765f3aae978ae196a.

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

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

Coarse fallback — 40 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json fa9c0aa2a0a8c2752f83b57765f3aae978ae196a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b539479e3f712a496ce410706bc03848e9194f20 — the merge of head 2d857e278e2a2ca27772105eb4e908fd7ca5d47e into base fa9c0aa2a0a8c2752f83b57765f3aae978ae196a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b539479e3f712a496ce410706bc03848e9194f20 && git checkout b539479e3f712a496ce410706bc03848e9194f20
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fa9c0aa2a0a8c2752f83b57765f3aae978ae196a 2d857e278e2a2ca27772105eb4e908fd7ca5d47e && git checkout -B drift-repro fa9c0aa2a0a8c2752f83b57765f3aae978ae196a && git merge --no-ff 2d857e278e2a2ca27772105eb4e908fd7ca5d47e

node scripts/docs-audit/affected-docs.mjs --json fa9c0aa2a0a8c2752f83b57765f3aae978ae196a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs fa9c0aa2a0a8c2752f83b57765f3aae978ae196a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb

维护者速读

  • 改了什么:四类记录的标题原来拼的是原始 id,现在改为经 lookup 读一跳的名字。这四类是组织成员、两步验证、SCIM 组成员和 SCIM 投影授权。人名为空时用邮箱,SCIM 读用户名和组的显示名,写法与业务单元成员、团队成员一致。授权解析器读成员表的三处,同时改为只取决策要用的列。
  • 为什么改:这些记录的页头、列表标题列和被引用的地方都显示原始 id,管理员看不出是谁。成员标题一旦读一跳,授权读如果不投影,每个已登录请求都会多读两次用户表,所以同一个改动里把这三处读收窄。
  • 风险与回滚:只改平台自带对象的声明和一处内部读投影,不存列、不迁移。标题按读者权限去读,读不到时为空,不会露出 id。认证前奏仍是钉住的 8 读 4 往返。授权读按列投影是本 PR 风险最高的一处,删掉 role 列做消融,有 27 条测试转红。回滚只需撤掉本 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。
  • 两分钟验证:运行 pnpm --filter @objectstack/plugin-auth exec vitest run src/auth-prelude-query-count.pin.test.ts,预期 8 条全绿。
  • 需要你做的:无,席位落地。

席位意见:同意落地。席位逐个核对了三处投影列与调用方读到的字段:本人成员读用到 organization_id、role 和有效期窗口;组织成员首读只用 user_id;计数读用的是 user_id 加上前面四列。没有漏列。草稿 PR #30287 也改同一文件,但改的是另一段,谁后合谁 merge main。

席位核对

Head 2d857e278e2a2ca27772105eb4e908fd7ca5d47e · 2026-11-05T14:04Z

  • 形态: the PR matches the order and ruling A. Four one-hop titles are written as in sys_business_unit_member. The three sys_member reads are projected, with MEMBER_ROW_COLUMNS, MEMBER_HOLDER_ROW_COLUMNS and the existing ORG_MEMBER_PAGE_COLUMNS. The pin counts were not raised.
  • 前提实测: before the projection, core src/security had 2 reds and the prelude pin 6 reds. After it, 1213/1213 and 8/8. Two ablations: dropping the role column turns 27 red, and reverting the sys_member formula turns 5 red.
  • 门禁 check-runs: in progress at this head. Auto-merge waits on the required checks. check-governed-merges.mjs --pr 30520 reports NOT governed (+325 / −45).
  • 接受的偏差: the file surface gains resolve-authz-context.grant-projection.test.ts (the shared projection harness, approved by the seat 13:46Z). valid_from / valid_until are listed ahead of sys_member declaring them; an undeclared column is skipped.
  • 范围外去向 + Skills-docs:: Skills-docs: none, with the grep. No new out-of-scope finding.
  • 落地条款: Fixes #30454, non-governed, Clause-②: no, landed by the seat.

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 tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Identity join objects' record titles still splice raw lookup ids (sys_member, sys_two_factor, SCIM)

1 participant