Skip to content

fix(platform-objects): sys_organization admin actions follow the membership reach (update/delete rows) - #24399

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-24384-org-admin-actions-reach
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-24384-org-admin-actions-reach

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #24384

#24318 remains open: this PR does not change change_slug. Its shape needs a decision, set out under "#24318" below.

Clause-②: yes (widening) (@objectstack/spec's published reach table gains two rows, while the console offers fewer actions; CONTRACT_REVIEW_TIER record 6255881407, PASS, on the published-schema face)

What changes

  • packages/spec/src/identity/membership-reach.ts: the reach table (MEMBERSHIP_REACH) gains two rows, both read off better-auth's own default role statements:
    • update_organization: /organization/update, statement organization: ['update'], grades owner and admin.
    • delete_organization: /organization/delete, statement organization: ['delete'], grade owner only.
    • plugin-auth's membership-reach-table.test.ts iterates every row, so both new rows are held equal to the installed vendor door with no edit to that test: the endpoint's hasPermission statement, and the grades whose registered statements pass it.
  • packages/platform-objects/src/identity/sys-organization.object.ts:
    • update_organization and rename_organization declare requiresMembershipReach: 'update_organization'.
    • delete_organization declares requiresMembershipReach: 'delete_organization'.
    • multiOrgEnabled stays the last term of each predicate. invite_user is unchanged. leave_organization, set_active_organization, create_organization and change_slug keep no grade term.
  • packages/platform-objects/src/pages/sys-organization.page.ts: the stale header-actions comment now names the header as it is laid out (fold-in note 6249926047). Comment only.
  • Pins:
    • platform-objects.test.ts: the lowering matrix's three rows now carry the grade term.
    • sys-organization-rename-action.test.ts: the rename's gate equals update_organization's.
    • New packages/qa/dogfood/test/org-record-admin-reach.dogfood.test.ts (below).
  • Generated: content/docs/references/ui/action.mdx, through check:generated --fix.
  • Changesets: @objectstack/spec minor, @objectstack/platform-objects minor.

No door changed, no error code was added, and leave_organization stays ungated.

Measured: M1 before and after (booted showcase, multiTenant: 'posture-only')

The new dogfood file boots a multi-organization showcase. An owner creates the organisation through organization/create; an admin and a plain member join it, and each principal activates it. For each principal the file reads the served session (positions), the served /auth/config features (multiOrgEnabled: true) and the served GET /meta/object/sys_organization, then evaluates each served visible with @objectstack/formula's celEngine. It then calls the doors.

Offered per grade (the expected sets are written out in the test):

grade before (tree 1eae21f, dist built from it) after
owner everything everything
admin everything, including Delete Organization create, set active, leave, invite, edit, rename
member create, set active, leave, edit, rename, delete create, set active, leave

change_slug was offered to every grade before and is still offered to every grade (unchanged; see #24318).

The doors' answers. Before this run they were NOT MEASURED; they are measured now, on the same boot:

  • POST /auth/organization/update:

    • member: 403 YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION.
    • admin and owner: 200, and the stored name changed.
  • POST /auth/organization/delete:

    • member and admin: 403 YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION, and the row still exists.
    • owner: 200, and the row is gone.
  • Every type: 'api' target the record serves, called once as the plain member with an unknown organizationId:

    action answer
    create_organization 400 VALIDATION_ERROR
    update_organization 400 VALIDATION_ERROR
    delete_organization 400 USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION
    set_active_organization 403 USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION
    invite_user 400 VALIDATION_ERROR
    leave_organization 400 MEMBER_NOT_FOUND
    rename_organization 400 VALIDATION_ERROR
    change_slug 404 ENDPOINT_NOT_FOUND

Runs:

  • Before (fix not applied): the new file fails 4 of 7. Admin is offered delete; member is offered update, rename and delete; change_slug answers 404. The two door tests pass.
  • After, on dde114aba: 3 files (org-record-admin-reach, org-admin-affordance-reach, organization-rename-door), 23 of 23 pass. The final file holds change_slug out by name, as UNDECIDED, so the day its shape changes, the test changes with it.

Ablations (one per mechanism, scripts/ablation-replace.mjs, dist preflighted)

Mutation landing, restore and dist presence were checked on disk for each.

  1. The gate. Delete requiresMembershipReach: 'delete_organization' from the object (anchor 1 → 0), then rebuild platform-objects.
    • ablation-dist-preflight --absent passes.
    • The delete_organization row of platform-objects.test.ts turns red: 'features.multiOrgEnabled != false' against the composed predicate.
    • The dogfood file turns red, 2 failures: admin and member are now offered delete.
    • Restore: blob equals HEAD and git diff HEAD is empty. The rebuilt dist's preflight finds the marker in 6 files.
    • This ablation ran while change_slug was briefly removed on this branch; the delete gate it mutates is byte-identical on the final head.
  2. The row. Widen delete_organization's grades to OWNER_ADMIN, then rebuild spec.
    • plugin-auth's membership-reach-table.test.ts turns red: delete_organization: the grades equal …, [ 'owner', 'admin' ] against the door's [ 'owner' ], 1 of 27.
    • Restore: blob equals HEAD. After the rebuild, the dist reads ["owner"].
  3. change_slug's 404. The before run is its reading. This PR does not change it.

Verification record (head dde114aba, after merging origin/main 584b4225e)

  • pnpm --filter @objectstack/spec test: 695 files, 21238 passed, 1 todo.
  • pnpm --filter @objectstack/platform-objects test: 76 files, 1176 passed.
  • pnpm --filter @objectstack/plugin-auth exec vitest run src/membership-reach-table.test.ts src/public-feature-registry.test.ts: 32 passed.
  • Dogfood: 3 files, 23 passed, as above.
  • Typecheck exit codes: spec 0, platform-objects 0, dogfood 0.
  • pnpm --filter @objectstack/spec check:generated: all 14 artifacts up to date, after a rebuild on the merged tree.
  • node scripts/pm/dispatch-gates.mjs --commands on this head: 112 commands, each run and each exit captured before any pipe; all 112 exit 0.
    • Two first refused with exit 3, PREREQUISITE NOT MET (unbuilt packages): check:skill-examples and check:dual-build-cjs-loads. After building the named packages, both exit 0.
    • check:slot-lookup was cut off by the batch timeout once; it ran alone and exits 0.
    • --ran reconciliation: 112 derived, 112 run, 0 NOT-MEASURED (a derived zero, since every line carries its exit code).
  • Local-only families: check:dev-prereqs 0, check:skill-frame-freshness 0.

#24318: why change_slug is not changed here

Measured:

  • No package in this repository serves /api/v1/cloud/organizations/{id}/change-slug. On this composition it answers 404 ENDPOINT_NOT_FOUND.
  • No existing public signal tells a composition with the orchestrator apart from one without it. PUBLIC_AUTH_FEATURES carries 11 keys, none of them about cloud, and nothing else in /auth/config does either.
  • Measured probe: the showcase booted with one extra top-level action, objectName: 'sys_organization', locations: ['record_header']. The served GET /meta/object/sys_organization did not carry it; it listed the platform object's own actions only. mergeActionsIntoObjects (stack.zod.ts) attaches a bound action only to objects in the same stack. So the "cloud declares its own action" shape has no working route today: removing change_slug from the platform object would take the button off cloud's page, and cloud would have no declared way to put it back. objectExtensions refuses actions by design.

The three shapes on the card, by the four axes:

  • (A) A new PUBLIC_AUTH_FEATURES key that only the orchestrator's composition sets. Business need: the one consumer is cloud. Long term: it puts a cloud-only key into the open-source contract, and it needs a plugin-auth producer and a drift-guard row as well as the spec row. AI safety: it is loud only if cloud remembers to set it. Startup scope: it is a new gate, which defaults to no unless the maintainer names it.
  • (B) The composition that serves the door declares the action, and the platform object stops declaring it. It matches "one route, one owner" and adds no key. But it needs a platform mechanism that attaches a bound action to an object another package owns, which does not exist (the probe above). Without that, cloud loses the button.
  • (C) The open-source composition serves the route. That would widen scope into the orchestrator's sys_domain and reservation semantics. Startup scope: no.

Recommendation: (B), with the attach mechanism as its prerequisite. The maintainer picks, because (A) adds a gate and (B) adds a platform mechanism. Until then change_slug keeps multiOrgEnabled alone. It gets no reach term either: its door is cloud's org-admin, outside better-auth, so no reach row describes it.

Acceptance notes

  • The two reach rows are a cross-domain exception, carried under the claim: packages/spec is domain:spec's surface, declared to it on [PM seat] domain:spec — 🟢 os-warren #6017 by the seat.
  • set_active_organization, leave_organization and create_organization carry no grade term. Every grade may switch, leave or create; the doors were measured answering their own refusals, not a 404.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01JkPx97R4MbdoSQcC5HgGdb

…grade and their doors

Claude-Session: https://claude.ai/code/session_01JkPx97R4MbdoSQcC5HgGdb
Co-authored-by: Claude <noreply@anthropic.com>
…ership reach; change_slug leaves the platform object

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

A top-level action bound to sys_organization from another stack is not
attached to the served object, so moving change_slug to the composition
that serves its door has no working path today.

Claude-Session: https://claude.ai/code/session_01JkPx97R4MbdoSQcC5HgGdb
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/spec, touching 5 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/platform-objects/src/pages/sys-organization.page.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/releases/v17/17-7.mdx (via MEMBERSHIP_REACH (symbol, a top-level const object))

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
  • 1 changed file(s) yielded no anchor (packages/platform-objects/src/pages/sys-organization.page.ts) — pages documenting those are invisible to this run
  • 1 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 — 143 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 d8e11b58e784bb7c2208e3533ab4710921012b1e → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 d8e11b58e784bb7c2208e3533ab4710921012b1e → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: dde114aba6539f8456d6bce0a6f8b988e96aff62
Local-runs: none
Scope: packages/spec/src/identity/membership-reach.ts + packages/spec/src/ui/action.zod.ts (:31, :1508, :2174), packages/spec/src/identity/membership-role.ts, packages/spec/src/identity/eval-user.zod.ts (mapMembershipRole), packages/plugins/plugin-auth/src/membership-reach-table.test.ts, better-auth 1.7.3 (the version packages/plugins/plugin-auth/package.json pins exactly; this checkout carries no node_modules, so the vendor was read at tag v1.7.3: plugins/organization/access/statement.ts, routes/crud-org.ts, permission.ts), packages/core/src/security/resolve-authz-context.ts (:1414–:1430, the positions projection), packages/platform-objects/src/identity/sys-organization.object.ts, packages/platform-objects/src/platform-objects.test.ts, packages/platform-objects/src/identity/sys-organization-rename-action.test.ts, packages/qa/dogfood/test/org-record-admin-reach.dogfood.test.ts, content/docs/references/ui/action.mdx, both .changeset/24384-*.md, packages/platform-objects/src/pages/sys-organization.page.ts (comment only), and objectui 2ec9e2a expressionUser.ts / ExpressionProvider.tsx / ActionEngine.ts (the console's current_user binding). Base: merge-base 584b4225e with origin/main; net diff 9 files, +288/−11. Check-runs on the head at the last read: 40, of which 34 success, 5 skipped, 1 in progress (Test Core (3/6)), none failed.

① Derived judgments

The two reach rows — right. better-auth 1.7.3 access/statement.ts: ownerAc carries organization: ["update", "delete"], adminAc carries organization: ["update"], memberAc carries organization: []; plugin-auth's delegated_admin is a member's statements plus invitation: ['create'] (auth-manager :3415), so it carries no organization:*. routes/crud-org.ts: /organization/update checks hasPermission({ permissions: { organization: ["update"] }, role: member.role }) and refuses with YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION; /organization/delete checks organization: ["delete"] and refuses with YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION. So update_organization = endpoint /organization/update, statement organization:update, grades OWNER_ADMIN; delete_organization = /organization/delete, organization:delete, [owner] — each endpoint, statement and grade set is exactly what the door enforces. Neither route passes allowCreatorAllPermissions, so the owner is admitted by statement, as the table reads it. plugin-auth's membership-reach-table.test.ts iterates Object.entries(MEMBERSHIP_REACH) with no edit: for each row it requires a pathed vendor endpoint whose slice checks exactly the row's statement, and recomputes the admitted grades through the vendor's own authorize; 3 fixed tests + 12 rows × 2 = the 27 the dev reports. The dogfood door probes agree (member update 403, admin and owner 200 with the name changed; member and admin delete 403 with the row still there, owner 200 with the row gone). A comma-joined role is handled as the existing rows handle it: resolve-authz-context.ts:1425 splits m.role on , and maps each part, and better-auth's hasPermissionFn splits on , and admits if any part authorizes, so admin,member is both offered and admitted. A role outside the closed vocabulary (ADR-0108 D1) passes through mapMembershipRole unchanged, so it is neither org_owner nor org_admin and the button is hidden; the vendor looks it up in acRoles, finds nothing, and refuses — hidden and refused agree.

The enum — derived, not hand-listed. MEMBERSHIP_REACH_NAMES = Object.keys(MEMBERSHIP_REACH) (membership-reach.ts:165) feeds z.enum(MEMBERSHIP_REACH_NAMES) on ActionSchema (action.zod.ts:1508). The only hand-written copies of the name set are pins by design (the platform-objects.test.ts matrix, the dogfood EXPECTED sets) and CHANGELOG prose; the metadata-forms translations carry label and help text only. The generated action.mdx is consistent: the row shows the first eight names and +4 more (12 = 10 + 2), and the enumerated list appends update_organization and delete_organization in Object.keys order.

The lowered predicates — right, within the ruled mechanism. ActionSchema lowers lowerRequiresFeature(lowerRequiresMembershipReach(data, ctx), ctx) (action.zod.ts:2174), so the grade term leads and multiOrgEnabled (default-on, != false) is appended as (GRADE-TERM) && features.multiOrgEnabled != false. None of the three actions has an authored visible, so the loud-error arms do not fire. Served bytes: update_organization and rename_organization → ('org_owner' in current_user.positions || 'org_admin' in current_user.positions) && features.multiOrgEnabled != false; delete_organization → ('org_owner' in current_user.positions) && features.multiOrgEnabled != false. The matrix rows (ADMINS && MULTI_ORG, (OWNER) && MULTI_ORG) and the rename pin ((membershipReachPredicate('update_organization')) && featureGatePredicate('multiOrgEnabled'), equal for rename and update) spell exactly those bytes. Placement does not change the verdict: objectui's ActionEngine.getActionsForLocation evaluates the same visible against the same runner context for list_item and record_header, the predicate reads no record.*, and ExpressionProvider binds one subject as current_user with positions: user.positions ?? [], so a session face with no positions entry evaluates to false (hidden) rather than faulting. An owner with no active organisation gets every membership's grade projected (resolve-authz-context.ts:1414–:1418), so org_owner is present and the buttons are offered. One boundary, inherited, named under ③: positions are projected from the ACTIVE organisation's membership alone (:1414, by design: "a role held in one organization must not grant its capabilities while the caller operates in another"), while both doors look the member up in the request's organizationId — the record's row (crud-org.ts update :50–:61, delete :40–:50). For a principal whose grade differs between the active organisation and the record's, the button and the door can disagree in either direction. Within the active organisation — the case the card describes and the dogfood measures — no grade the door accepts is denied the button and no grade it refuses is offered it.

Unchanged actions — truly unchanged. The hunks touch update_organization (:103), delete_organization (:124) and rename_organization (:250) with one key each plus comments, and a comment line on leave_organization. change_slug, invite_user, set_active_organization and create_organization have no hunk; the matrix keeps MULTI_ORG alone for create, set-active, leave and change_slug, and the dogfood holds create, set_active, leave in EVERY_GRADE and asserts the served set is ALL + UNDECIDED for every grade.

The dogfood pins — real controls. The expected sets are written out per grade (EXPECTED.owner = ALL, admin = EVERY_GRADE + INVITE + UPDATE, member = EVERY_GRADE); the per-grade assertion compares the offered subset of ALL to the expected subset, so both halves (offered and not offered) are asserted; the served visible is required to be identical across grades before evaluation; the doors are called with effect checks on the row; change_slug is held out by name (UNDECIDED) from the graded set and from the door probe, and its presence in the served set is still asserted. The "before" (4 of 7 red on a dist built from 1eae21f) is the dev's run, not in the tree; the in-tree control is ablation 1 (gate deleted → the matrix row and two dogfood cases red), read here as the report states it.

② Semver level

@objectstack/spec minor, Clause-②: yes (widening) — exact: the requiresMembershipReach accept set widens by two names and the exported table gains two rows; no row, name or lowering changes. @objectstack/platform-objects minor, Clause-②: yes (widening) — over-declared, not wrong: the diff widens no accept set and enlarges no public face of that package; it narrows the offered set to the door, which is a fix, and the identical shape (#21795's gates on sys_member, sys_invitation, sys_team) shipped as a 17.7.0 patch (packages/platform-objects/CHANGELOG.md :1256/:1527, entry 607463d) while the spec rows shipped as a 17.7.0 minor. The dev mirrored the claim's single PR-level line into both changesets, as os-dev :280 instructs; a minor where a patch would do breaks no consumer and Check Changeset is green. Not blocking; the seat may leave it. The PR body's line opens Clause-②: yes (widening) — the token is first after the colon and the arm is from the closed pair, so clause2-line.mjs reads it as a widening and ignores the tail by design (#13914's control shape). The tail's "and possibly a feature key" is now false: packages/spec/src/kernel/public-auth-features.ts is not in the net diff (touched in 2eaee0cc, restored in dd87e1cb). A stale parenthetical the reader does not read does not block; the seat should trim it to "the published reach table gains two rows, while the console offers fewer actions" on the next body edit, which moves no head.

③ Boundary flags

Implemented-by: claude/issue-24384-org-admin-actions-reach
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-18T22:23Z · ACCEPT

Head: dde114aba6 (the gates and pins, a B-now attempt and its revert, then a merge of main). Card #24384 (p2, area:identity), claim 6254530128, dispatch 6254568951. Dev report: 6255528203.

Item Reading
Shape Draft PR → main. The body opens Fixes #24384 and says #24318 remains open. 9 files, +288 / −11. Not governed (check-governed-merges.mjs --pr 24399: 0 of 9 paths hit the register; 299 changed lines).
Scope packages/spec/src/identity/membership-reach.ts: two reach rows, update_organization (/organization/update, organization:update, owner and admin) and delete_organization (/organization/delete, organization:delete, owner), declared to domain:spec (6254547379). sys-organization.object.ts: update_organization and rename_organization declare requiresMembershipReach: 'update_organization', and delete_organization declares 'delete_organization'. The page's header comment is corrected (comment only). Pins in platform-objects, and a new dogfood file declared to domain:cli (6255721514, 6255725489). The generated action.mdx, declared to domain:devx (6255732064, 6255735381). One changeset each for @objectstack/spec and @objectstack/platform-objects. change_slug, invite_user, leave_organization, set_active_organization and create_organization have no hunk.
Seat's own read At the head: both rows read as above. MEMBERSHIP_REACH_NAMES is Object.keys(MEMBERSHIP_REACH) and feeds ActionSchema's z.enum (action.zod.ts:1508), so the accept set follows the table. ActionSchema lowers the grade term first and multiOrgEnabled second (:2174). positions come from the active organisation's membership (resolve-authz-context.ts:1414).
Measured On a booted multi-organization showcase, before the fix: a plain member was offered Edit, Rename and Delete, and an admin was offered Delete; every click answered 403 from better-auth. After the fix: an owner is offered everything; an admin is offered create, set active, leave, invite, edit and rename; a member is offered create, set active and leave. The doors were measured on the same boot. Update: member 403, admin and owner 200. Delete: member and admin 403 with the row kept, owner 200 with the row gone. change_slug still answers 404 and is held out by name.
Review face The published schema. CONTRACT_REVIEW_TIER record 6255881407 on this head is PASS. The seat verified its central claims at the head (the row above).
Declaration Clause-②: yes (widening) on the claim, the PR body and both changesets. The PR body's stale "possibly a feature key" was trimmed after the record (a body edit, no new head). Record ② finds the spec minor exact. It finds the platform-objects minor over-declared, since 17.7.0 shipped #21795's same shape as a patch, but harmless; it stays as is.
Pins and ablation The lowering matrix's three rows carry the grade term. Rename and update pin the same gate. plugin-auth's reach-table test iterates every row against better-auth's door unedited (27 cases). The dogfood file writes out each grade's expected set and calls both doors. Ablation 1 (the delete gate removed) turned the matrix row and 2 dogfood cases red. Ablation 2 (delete widened to owner and admin) turned the vendor-equality pin red. Each restore read back as the HEAD blob.
Gates Dev at dde114aba6: 112 derived, 112 run, 0 NOT MEASURED. Typecheck exit 0 for spec, platform-objects and the dogfood package. check:generated: all 14 artifacts up to date.
CI 41 check-runs on dde114aba6: 36 success, 5 skipped, 0 failed. The skips are the rostered path and opt-in skips. Test Core 1/6–6/6, every Type Check lane, Lint & Repo Gates, Check Changeset and Governed Surface Queue Guard are green.

Disposition:

Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #24384 then closes the card; #24318 stays pm:blocked.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 18, 2026 22:24
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 18, 2026 22:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit f573976 Oct 18, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24384-org-admin-actions-reach branch October 18, 2026 22:58
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

Projects

None yet

2 participants