Skip to content

feat(spec,platform-objects): org-admin actions follow the membership grade through one declared reach table - #21883

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-21795-membership-grade-action-gate
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-21795-membership-grade-action-gate

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

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. fix(plugin-auth): settle membership under the auto policy at user creation (ADR-0093 D7) #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

claude added 8 commits October 5, 2026 12:12
…s, lowered from one reach table

Adds MEMBERSHIP_REACH (which membership grades reach which better-auth
organization endpoint) beside the closed membership-role vocabulary, and the
`requiresMembershipReach` action sugar lowered at parse time into `visible`
over `current_user.positions`, ahead of the `requiresFeature` lowering. The
org-admin actions of the identity platform objects declare it.

Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ
Co-authored-by: Claude <noreply@anthropic.com>
…; fold both lowerings into one transform

One transform stage rather than two keeps every refinement on the action
chain at its current depth in the emitted schema graph.

Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ
Co-authored-by: Claude <noreply@anthropic.com>
… roles map and vendor doors

Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ
Co-authored-by: Claude <noreply@anthropic.com>
…hout a proof binding

Same disposition as the requiresFeature row: a live row with a symbol-anchored
evidence pointer, no ADR-0054 high-risk proof binding.

Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ
Co-authored-by: Claude <noreply@anthropic.com>
…ts the new action form row

Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/spec, touching 35 documentable anchor(s). ⚠️ 6 changed file(s) yielded no anchor (packages/spec/api-surface/identity.json, packages/spec/authorable-surface/ui.json, packages/spec/export-origins/identity.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/troubleshooting.mdx (via ActionSchema (symbol, a top-level const))
  • content/docs/protocol/objectui/actions.mdx (via ActionSchema (symbol, a top-level const))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via ActionSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-3.mdx (via ActionSchema (symbol, a top-level const))
  • content/docs/releases/v17/index.mdx (via ActionSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 6 changed file(s) yielded no anchor (packages/spec/api-surface/identity.json, packages/spec/authorable-surface/ui.json, packages/spec/export-origins/identity.json, …) — pages documenting those are invisible to this run
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 138 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 e864db56dffc2dec3290e5f0700f9d1606a1c830 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ae930da99bdc8487e93d792ccedcc23e5347598f — the merge of head 7948aaa4545b90db46521df3191d9c9de52a4869 into base e864db56dffc2dec3290e5f0700f9d1606a1c830, 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 ae930da99bdc8487e93d792ccedcc23e5347598f && git checkout ae930da99bdc8487e93d792ccedcc23e5347598f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e864db56dffc2dec3290e5f0700f9d1606a1c830 7948aaa4545b90db46521df3191d9c9de52a4869 && git checkout -B drift-repro e864db56dffc2dec3290e5f0700f9d1606a1c830 && git merge --no-ff 7948aaa4545b90db46521df3191d9c9de52a4869

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

⚠️ 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 e864db56dffc2dec3290e5f0700f9d1606a1c830 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…equiresMembershipReach

Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 15:41
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 15:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 607463d Oct 5, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21795-membership-grade-action-gate branch October 5, 2026 16:19
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 protocol:ui size/xl tests tooling

Projects

None yet

2 participants