Skip to content

Commit 607463d

Browse files
feat(spec,platform-objects): org-admin actions follow the membership grade through one declared reach table (#21883)
Fixes #21795 Clause-②: yes (widening) A plain member was offered "Invite User", "Remove Member", "Create Team" and the other org-admin affordances on the organization's member, invitation and team lists, and the server then refused each with 403. The server was right; the buttons had no declared way to ask the same question. This lands ruling A on the card: a declared membership-grade gate on actions, lowered at parse time from one reach table the server door is pinned against. ## What changed - **The reach table** — `MEMBERSHIP_REACH` in `packages/spec/src/identity/membership-reach.ts`, beside the closed name list in `membership-role.ts`. It says which membership grades reach which better-auth organization endpoint. It is a fourth fact beside ADR-0108 D4's three (what names exist, which names mean administrative authority, how a name projects into an identity) and is merged into none of them. Exported from `@objectstack/spec/identity` only: `MEMBERSHIP_REACH`, `MEMBERSHIP_REACH_NAMES`, `membershipReachPredicate`, `lowerRequiresMembershipReach`, and the `MembershipReachEntry` / `MembershipReachName` / `MembershipReachStatement` types. | row | endpoint | statement the door checks | grades | |---|---|---|---| | `invite_member` | `/organization/invite-member` | `invitation:create` | owner, admin, delegated_admin | | `cancel_invitation` | `/organization/cancel-invitation` | `invitation:cancel` | owner, admin | | `update_member_role` | `/organization/update-member-role` | `member:update` | owner, admin | | `transfer_ownership` | `/organization/update-member-role`, setting the creator role | `member:update` plus better-auth's creator-role rule | owner | | `remove_member` | `/organization/remove-member` | `member:delete` | owner, admin | | `create_team` | `/organization/create-team` | `team:create` | owner, admin | | `update_team` | `/organization/update-team` | `team:update` | owner, admin | | `remove_team` | `/organization/remove-team` | `team:delete` | owner, admin | | `add_team_member` | `/organization/add-team-member` | `member:update` | owner, admin | | `remove_team_member` | `/organization/remove-team-member` | `member:delete` | owner, admin | - **The sugar** — `requiresMembershipReach` on `ActionSchema` (`packages/spec/src/ui/action.zod.ts`), in the family of `requiresFeature`. It is enum-checked against the table's row names. At parse time it becomes one `'NAME' in current_user.positions` term per grade, in the names `mapMembershipRole` projects the grades to (`org_owner`, `org_admin`, `delegated_admin`, `eval-user.zod.ts`). It is AND-composed with an explicit `visible` and stripped from the parsed output. `lowerRequiresMembershipReach` mirrors `lowerRequiresFeature` branch for branch: `visible: true` gives the gate alone; `visible: false`, a non-CEL or AST-only `visible`, and a blank `source` are loud parse errors at the key. It runs inside the same `.transform()` as `requiresFeature`, ahead of it, so `features.*` stays the last term. One stage rather than two: a second pipe stage moved twelve `dropped-refinements.baseline.json` entries one level deeper (`in` became `in.in`), measured on the first build. - **The declarations** — `packages/platform-objects/src/identity/*.object.ts`. Re-counted at base `9f9510f25e`: 14 sites carry `requiresFeature: 'organization'`, the seat's count. The ruling counted 12 at `088428fb4`. Thirteen take the key; one takes none: | site | endpoint | key | |---|---|---| | `sys_user.invite_user` | invite-member | `invite_member` | | `sys_member.invite_user` | invite-member | `invite_member` | | `sys_member.add_member` | ObjectStack's platform-admin mount over the vendor's server-only `addMember` (ADR-0068) | none, not grade-gated | | `sys_member.update_member_role` | update-member-role | `update_member_role` | | `sys_member.remove_member` | remove-member | `remove_member` | | `sys_member.transfer_ownership` | update-member-role, role `owner` | `transfer_ownership` (the record predicate is kept and the sugar composes onto it) | | `sys_invitation.invite_user` | invite-member | `invite_member` | | `sys_invitation.cancel_invitation` | cancel-invitation | `cancel_invitation` | | `sys_invitation.resend_invitation` | invite-member with `resend: true` | `invite_member` | | `sys_team.create_team` / `update_team` / `remove_team` | create-team / update-team / remove-team | same names | | `sys_team_member.add_team_member` / `remove_team_member` | add-team-member / remove-team-member | same names | - **The equality test** — `packages/plugins/plugin-auth/src/membership-reach-table.test.ts`, the one new file in plugin-auth, with no source line. For every row it recomputes the grades from the roles map plugin-auth actually hands better-auth: the organization plugin's real constructor options, which are `defaultRoles` plus the `delegated_admin` registration, asked through the vendor's own `authorize`. It also reads, from the installed vendor route source, the statement each endpoint's `hasPermission` checks and the creator-role default. `auth-manager.ts` is untouched. - **The proof** — `packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts`. It boots the showcase with an owner, an `admin`, a `delegated_admin` and a plain `member` in one organization. It reads each principal's served session (`/auth/get-session`), the served flags (`/auth/config`) and the served action metadata of `sys_member`, `sys_invitation`, `sys_team`, `sys_team_member` and `sys_user`. It evaluates the served predicates with `celEngine`, the console's engine, and spells out the expected sets rather than computing them from the table. Door probes show that hidden means refused and shown means admitted. - **The surfaces a new authorable key wakes**, each decided as `requiresFeature` decided it: an `action.json` liveness row (`live`, evidence symbol-anchored to the lowering, no ADR-0054 proof binding, as `requiresFeature` has none); the action metadata form offers the key in its Placement section beside `requiresFeature`; the platform-objects metadata-form catalog gets the key in all four locales (zh-CN / ja-JP / es-ES translated by hand); the regenerated JSON schema / `authorable-surface/ui.json`; `api-surface/identity.json` and `export-origins/identity.json`; the reference docs (`content/docs/references/{ui/action,data/object,kernel/metadata-plugin}.mdx`); `liveness/state-counts/action.md`. `gen:skill-refs` also rewrote two generated skill indexes (see below). - **Pins the declarations move** — `platform-objects.test.ts` (the feature-gate lowering matrix's org rows now pin the composed predicate; the never-survives check covers the new key), `invite-entry-toolbar.test.ts` (the three invite mirrors agree on the composed gate), `action-predicate-sparse-face.test.ts` (the principal binding carries `positions`, as every EvalUser does, so the sweep still reaches the record half), `object-lifecycle-panel-echo-decisions.test.ts` (the translated row-label catalog is 661). - **Changesets** — `@objectstack/spec` `minor` (carries the Clause-② line), `@objectstack/platform-objects` `patch`. The platform-objects dist moved, measured with `npm pack`: `requiresMembershipReach` is in 14 shipped `dist/` files, against a positive control of `requiresFeature` in 14. plugin-auth ships `dist` only, so its new test file publishes nothing. ## Premises (ruling 5993018584 §Premises), verified first 1. **Holds.** Booted the showcase at base (`objectstack dev --fresh --seed-admin`, private port). As the owner I invited a `member`, an `admin` and a `delegated_admin`; each signed up and accepted, and I read `GET /auth/get-session`. `positions`: member `['org_member','everyone']`, admin `['org_admin','everyone']`, delegated_admin `['delegated_admin','everyone']`, owner (seeded admin) `['platform_admin','org_owner',…]`. At base the plain member's served `sys_member.invite_user.visible` was `features.organization != false` (flag `true`), and `POST /auth/organization/invite-member` answered 403 `YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION`. That is the card's reproduction. 2. **Holds.** objectui at the pin `0abd4f9f87`: `RelatedToolbarButton` in `packages/plugin-detail/src/RelatedList.tsx` evaluates each toolbar action's `visible` through `useCondition` (fail-closed). Row actions go through the data-table's `DataTableRowActionItem`. `current_user` is bound to `buildExpressionUser(user)`, which forwards `positions` (`packages/app-shell/src/providers/expressionUser.ts`). 3. **Holds.** `git grep -n defaultRoles origin/main -- packages/plugins/plugin-auth/src/auth-manager.ts` hits at lines 1328, 3041, 3042, 3049 and 3050. better-auth 1.7.3 exports `defaultRoles`, `defaultAc`, `memberAc` and `defaultStatements` from `better-auth/plugins/organization/access`. ## Where the measurement differs from the ruling's sketch - **`resend_invitation` is reached by `delegated_admin`.** A resend is a call to `/organization/invite-member` with `resend: true`, and that door checks `invitation:create`, which `delegated_admin` holds. Measured on the base boot: the delegate's resend answered 200 and its cancel answered 403. So the table row is `invite_member`, and a delegated admin is offered invite and resend, never cancel or any member/team affordance. The ruling's "invite only" is read as "the invite-member endpoint", which covers invite and resend. - **`transfer_ownership` is owner-only**, as the ruling says, through better-auth's creator-role rule rather than a statement: an admin setting `owner` answered 403 `YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER`, and the same admin's role change to `member` answered 200. - **`add_member` takes no key.** Its door is platform-admin standing, so it is no row of the table, and its served predicate carries no grade term (pinned in the dogfood test). It is therefore still offered to a plain member; see Acceptance notes. ## Tests All at head `0a45d2f6e4` unless marked, run through `scripts/pm/os-verify-lock.sh`. Patch round 1 moved one test file, and its readings at the current head `7948aaa454` follow the list. - `@objectstack/spec`: `vitest run --project local` gave 617 files and 18411 tests passed (1 todo); `--project repo` gave 53 files and 902 tests passed. These ran at the head before the liveness-wording and changeset commits, which touch no spec source or test. - `@objectstack/platform-objects`: `vitest run` gave 59 files and 949 tests passed. - `@objectstack/plugin-auth`: `vitest run` gave 121 files and 2538 tests passed (10 skipped). The new file alone has 23 tests. - `@objectstack/dogfood`: `vitest run --project isolated test/org-admin-affordance-reach.dogfood.test.ts` gave 12 tests passed. - `typecheck` for spec, platform-objects, plugin-auth and dogfood all exited 0. - **Ablation**, predicted in writing before the run. I removed the lowering from ActionSchema's transform through `node scripts/ablation-replace.mjs`: anchor 1 then 0, blob `780d2b7a0de4` then `f7c01c42efc0`. The prediction was 4 red / 3 green in the wiring file and 0 red in the lowering file. Observed: `action-requires-membership-reach.test.ts` went 4 red (the key pin, the requiresFeature-composition test, the record-predicate composition test, the `visible: false` refusal), and the remaining 20 of 24 stayed green. Direction: red, as predicted. The restore was proven by blob equals HEAD (`780d2b7a0de4`) and an empty `git diff HEAD`. There is no dist leg: the test imports `./action.zod` from `src`. - Gates: derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (119 commands at `0a45d2f6e4`), run with exits recorded before any pipe, and reconciled with `--ran`: "119 derived, 119 run, 0 NOT-MEASURED, 0 UNRUN". The first pass gave 117 exit 0 and 2 exit 3 PREREQUISITE NOT MET (`check:skill-examples` and `check:dual-build-cjs-loads` read the dist of packages outside this closure). I built those packages, all turbo cache hits, and re-ran both: exit 0. The derivation warned that five gate files changed on main after the merge base (`check-durability-degradation-log-level`, `check-error-code-casing`, `check-type-check-coverage`, `engine-double-contract.pinned.json`, `measure-durability-swallow-family`), so those families ran their merge-base copies. This diff adds no error code, engine double or catch, and CI runs main's copies on the merge ref. - Governed predicate: `node scripts/pm/check-governed-merges.mjs --branch claude/issue-21795-membership-grade-action-gate` gave "0 of 34 path(s) hit the register after 2 generated-artifact lift(s)" and "NOT governed" at `7948aaa454` (33 paths at `0a45d2f6e4`). Both `skills/**` index files are certified PURE REGENERATIONS, byte-equal to `gen:skill-refs` recomputed on this tree. - Patch round 1, at `7948aaa454`: - `metadata-protocol`'s served-`action` key-count pin moves 49 → 50, and its named sample gains `requiresMembershipReach` (`protocol.meta-types-degenerate-derivation.test.ts`, the one file this round touched). - The `@objectstack/metadata-protocol` (214 files / 27745 tests), `@objectstack/rest` (265 / 5075) and `@objectstack/client` (51 / 652) suites are green. - `dispatch-gates --ran` reads "120 derived, 120 run, 0 NOT-MEASURED, 0 UNRUN". - `check:skill-refs` is the gate that demands the two index files. It runs in the required `TypeScript Type Check` aggregate (lane `Type Check · source gates`, no paths filter). With the edits it reports "9 generated files in sync with packages/spec", exit 0. With the two files restored to their merge-base bytes it reports both files "(out of date)" and asks for `pnpm --filter @objectstack/spec gen:skill-refs`, exit 1. Both files were restored afterwards: blob equals HEAD and `git diff HEAD` is empty. ## Acceptance notes - **QA re-run, at the API-composite level.** The screenshot oracle is NOT MEASURED, because the container has no console bundle (`packages/console/dist` is absent and `objectui:build` was not run). - `identity-auth.invitation-scope-gates` A5 ("the UI shows invite affordances only to entitled personas"): the delegated admin is offered `invite_user` on the Members and Invitations lists, and `resend_invitation`. The plain member is offered none. - `identity-auth.org-membership-team-management` A7 ("the gate holds both ways"): the plain member is offered none of the 13 grade-gated affordances. Forged calls are refused: invite 403, create-team 403. The UI half is measured by the dogfood test. - **`sys_user.invite_user` is withheld from the delegated admin by the metadata-plane field mask (ADR-0106), not by this gate.** Its `role` param (`objectOverride: 'sys_member'`) is read as a reference to `sys_user.role`, which that grade cannot read, so `/meta/object/sys_user` drops the whole action, while the door admits the delegate's invite. This predates the change, and the delegate still has the Members and Invitations entries. The dogfood test asserts it is the only unserved site and keeps it out of the gate's verdict. - **`add_member`** (platform-admin door) is still offered to every org member and refused unless the caller is a platform admin. That is the same symptom with a different door, and it is out of this table by the ruling. - **`sys_organization.update_organization` / `delete_organization`** (gated on `multiOrgEnabled`) target grade-gated endpoints (`organization:update` for owner and admin, `organization:delete` for owner) and are outside the ruling's declaration scope. - **`delegated_admin` is not a reserved identity name**, so a tenant-authored `sys_position` of that name would satisfy the invite term client-side. The server still refuses, so this is UI courtesy only. - **Hand-written docs** (`content/docs/ui/actions.mdx`, `content/docs/protocol/objectui/actions.mdx`) describe `requiresFeature` and not yet this key. They are outside this PR's file surface. - **Generated `skills/**` indexes.** `gen:skill-refs` rewrote `skills/objectstack-data/references/_index.md` (70 to 71 lines) and `skills/objectstack-ui/references/_index.md` (60 to 65), because `action.zod.ts` now reaches `identity/eval-user.zod.ts` and, through its type import, `security/permission.zod.ts`. These are generator-owned outputs under the register's `spec-skill-refs` exception. All `SKILL.md` content: 4411 to 4411 lines. `skills/**` in total: 13360 to 13366. - **origin/main re-fetched before opening this PR** (`5e0b489bca`). None of the files this PR changes moved on main, so there was no merge. #21813 (now on main) touches `auth-manager.ts`, but not the organization roles registration this PR's test pins. ## 维护者速读(草稿) - **改了什么**:组织成员页、邀请页、团队页上的管理按钮(邀请、改角色、移除成员、取消邀请、建团队等),现在只对服务端真正允许的成员等级显示。普通成员不再看到这些点了就报 403 的按钮。 - **为什么改**:裁决 A。按钮和服务端共用一张"哪个等级能调哪个组织接口"的表。这张表放在 spec 里,并由测试钉住与服务端完全一致。 - **风险与代价(含回滚)**:新增一个可选的元数据键,是纯加宽,已有元数据的解析结果不变。代价是多一张需要与 better-auth 同步的表,由测试自动报警。回滚就是回退本 PR,按钮恢复为"只看组织功能开关"。 - **席位意见**: - **你要做的**:无。两个 skills 索引文件由生成器随 spec 变化重新生成,`check-governed-merges` 已核验为纯再生成(生成器豁免),本 PR 不受治理面约束;合约级复核(Clause-②)后由席位按流程落地。 --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 255a777 commit 607463d

34 files changed

Lines changed: 1202 additions & 22 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
The organization's member, invitation and team actions are offered only to the membership grades the server admits. A plain member no longer sees "Invite User", "Change Role", "Remove Member", "Cancel Invitation", "Create Team" and the rest, each of which the server refused with 403.
6+
7+
- `invite_user` (on the Users, Members and Invitations lists) and `resend_invitation`: owner, admin and delegated_admin.
8+
- `update_member_role`, `remove_member`, `cancel_invitation`, `create_team`, `update_team`, `remove_team`, `add_team_member` and `remove_team_member`: owner and admin.
9+
- `transfer_ownership`: the owner alone, on a non-owner row.
10+
- `add_member` is unchanged. Its door is platform-admin standing, not a membership grade.
11+
12+
Each action declares `requiresMembershipReach` from `@objectstack/spec`, which is lowered into its `visible` predicate.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
An action can declare which organization membership grades it is offered to: `requiresMembershipReach` names a row of the new `MEMBERSHIP_REACH` table and is lowered at parse time into `visible`, the way `requiresFeature` is.
6+
7+
Clause-②: yes (widening)
8+
9+
- **`MEMBERSHIP_REACH`** (`@objectstack/spec/identity`) says which membership grades reach which better-auth organization endpoint. `invite_member` is reached by owner, admin and delegated_admin. `cancel_invitation`, `update_member_role`, `remove_member`, `create_team`, `update_team`, `remove_team`, `add_team_member` and `remove_team_member` are reached by owner and admin. `transfer_ownership` (setting the creator role on a member) is reached by the owner alone. The rows are read off better-auth's own access-control statements plus the `delegated_admin` registration, and plugin-auth pins them equal to the door. It is reach, not authority (ADR-0108 D1): a fourth fact beside the membership names, the administrative-grade rule and the identity projection, and merged into none of them. Also exported: `MEMBERSHIP_REACH_NAMES`, `membershipReachPredicate`, `lowerRequiresMembershipReach`, and the `MembershipReachEntry`, `MembershipReachName` and `MembershipReachStatement` types.
10+
- **`ActionSchema.requiresMembershipReach`** is optional and enum-checked against the table's row names. At parse time it becomes one `'<name>' in current_user.positions` term per grade, in the names `mapMembershipRole` projects them to (`org_owner`, `org_admin`, `delegated_admin`). The terms are AND-composed with an explicit `visible`, and the key is stripped from the parsed output. It composes ahead of `requiresFeature`, so a feature gate stays the last term. `visible: false`, a non-CEL or AST-only `visible`, and a blank `source` are refused at parse time, at the key.
11+
- It is UI courtesy, not authorization: the endpoint's own door stays the authority. The capability channel (`current_user.can`) is unchanged and carries no grade (ADR-0108).
12+
13+
Nothing that parsed before is refused now, and an action without the key lowers exactly as before.

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,7 @@ const result = ApiMethod.parse(data);
468468
| **resultDialog** | `{ title?: string \| Record<string, string>; description?: string \| Record<string, string>; acknowledge?: string \| Record<string, string>; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
469469
| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. |
470470
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
471+
| **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. |
471472
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. |
472473
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. |
473474
| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |

‎content/docs/references/kernel/metadata-plugin.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ const result = MetadataBulkResultSchema.parse(data);
338338
| **resultDialog** | `{ title?: string \| Record<string, string>; description?: string \| Record<string, string>; acknowledge?: string \| Record<string, string>; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
339339
| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. |
340340
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
341+
| **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. |
341342
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. |
342343
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. |
343344
| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |

‎content/docs/references/ui/action.mdx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const result = ActionSchema.parse(data);
5555
| **resultDialog** | `{ title?: string \| Record<string, string>; description?: string \| Record<string, string>; acknowledge?: string \| Record<string, string>; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
5656
| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. |
5757
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| 'phoneNumberOtp'>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
58+
| **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| 'transfer_ownership' \| 'remove_member' \| 'create_team' \| 'update_team' \| 'remove_team' \| … +2 more>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. |
5859
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. |
5960
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. |
6061
| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
@@ -78,6 +79,19 @@ const result = ActionSchema.parse(data);
7879
| **_packageVersion** | `string` | optional | Owning package version. |
7980
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
8081

82+
### Allowed Values: `Action.requiresMembershipReach`
83+
84+
* `invite_member`
85+
* `cancel_invitation`
86+
* `update_member_role`
87+
* `transfer_ownership`
88+
* `remove_member`
89+
* `create_team`
90+
* `update_team`
91+
* `remove_team`
92+
* `add_team_member`
93+
* `remove_team_member`
94+
8195
### Nested Shape: `Action.body[language='expression']`
8296

8397
L1 expression body — pure formula, no IO

‎packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,10 +247,11 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no
247247
// [#21095] 48 → 49: `outcomeMessages` joined the accepted set, and it
248248
// is named in the sample below so the served schema is held to
249249
// carrying it, not merely to having one more key than before.
250-
expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(49);
250+
// [#21795] 49 → 50: `requiresMembershipReach` joined the accepted set.
251+
expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(50);
251252
// A sample an author would actually address, and the one #17500's
252253
// repeater titles need a node to sit on.
253-
for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations', 'outcomeMessages']) {
254+
for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations', 'outcomeMessages', 'requiresMembershipReach']) {
254255
expect(Object.keys(properties)).toContain(key);
255256
}
256257
});

‎packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1772,6 +1772,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
17721772
label: "Requires Feature",
17731773
helpText: "Public auth feature flag gating this action. It is lowered into the `visible` predicate at parse time and stripped from the output, so no downstream consumer ever sees the key."
17741774
},
1775+
requiresMembershipReach: {
1776+
label: "Requires Membership Reach",
1777+
helpText: "The organization endpoint this action calls, so it is offered only to members whose grade reaches that endpoint. It is lowered into the `visible` predicate at parse time and stripped from the output, so no downstream consumer ever sees the key."
1778+
},
17751779
requiredPermissions: {
17761780
label: "Required Permissions",
17771781
helpText: "Capabilities (permission-set systemPermissions) a caller must hold — every one listed — to invoke this action (ADR-0066 D4). The platform action route refuses anyone else with 403 (script, flow and modal actions, and the MCP/AI path), and the button is hidden from them. A type api action calls its endpoint directly, so that endpoint must re-check them."

‎packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1772,6 +1772,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
17721772
label: "Requiere función",
17731773
helpText: "Indicador público de función de autenticación que condiciona esta acción. Se traslada al predicado `visible` durante el análisis y se elimina de la salida, así que ningún consumidor posterior llega a ver la clave."
17741774
},
1775+
requiresMembershipReach: {
1776+
label: "Requiere alcance de membresía",
1777+
helpText: "El endpoint de la organización al que llama esta acción, de modo que solo se ofrece a los miembros cuyo grado alcanza ese endpoint. Se traslada al predicado `visible` durante el análisis y se elimina de la salida, así que ningún consumidor posterior llega a ver la clave."
1778+
},
17751779
requiredPermissions: {
17761780
label: "Permisos requeridos",
17771781
helpText: "Capacidades (systemPermissions de conjuntos de permisos) que quien llama debe tener, todas las enumeradas, para invocar esta acción (ADR-0066 D4). La ruta de acciones de la plataforma rechaza a cualquier otro con 403 (acciones script, flow y modal, y la vía MCP/IA), y se le oculta el botón. Una acción de type api llama directamente a su endpoint, así que ese endpoint debe volver a comprobarlas."

‎packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1772,6 +1772,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
17721772
label: "必要な認証機能",
17731773
helpText: "このアクションの表示可否を決める公開認証機能フラグ。解析時に `visible` の述語へ畳み込まれ、出力からは取り除かれるため、下流の利用側がこのキーを見ることはありません。"
17741774
},
1775+
requiresMembershipReach: {
1776+
label: "必要なメンバーシップ到達先",
1777+
helpText: "このアクションが呼び出す組織エンドポイント。メンバーシップのグレードがそのエンドポイントに到達できるメンバーにだけ表示されます。解析時に `visible` の述語へ畳み込まれ、出力からは取り除かれるため、下流の利用側がこのキーを見ることはありません。"
1778+
},
17751779
requiredPermissions: {
17761780
label: "必要な権限",
17771781
helpText: "このアクションを実行するために呼び出し元が保持すべき機能(権限セットの systemPermissions)。列挙したすべてが必要です(ADR-0066 D4)。それ以外の呼び出し元はプラットフォームのアクションルートで 403 として拒否され(script、flow、modal アクションと MCP/AI 経路)、ボタンも表示されません。type が api のアクションはエンドポイントを直接呼び出すため、そのエンドポイントで改めてチェックする必要があります。"

‎packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1169,7 +1169,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
11691169
// taking its label — authored in all three locales — out of the catalog.
11701170
// 660 since #21765: the object form offers `imageField` beside
11711171
// `nameField` — one new row label, authored in all three locales.
1172-
expect(translated.length, `${locale} positive control`).toBe(660);
1172+
// 661 since the action form offers `requiresMembershipReach` beside
1173+
// `requiresFeature` — one new row label, authored in all three locales.
1174+
expect(translated.length, `${locale} positive control`).toBe(661);
11731175
}
11741176
// ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the
11751177
// all-three predicate returns 0 while the per-locale one returns 1, so the

0 commit comments

Comments
 (0)