Repository navigation
feat(spec,platform-objects): org-admin actions follow the membership grade through one declared reach table - #21883
Conversation
…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>
…mbershipReach 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>
… real boot 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>
…tion gates 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>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…ershipReach Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…equiresMembershipReach Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
… against the object it names (objectstack-ai#21904) Fixes objectstack-ai#21884 Clause-②: yes (widening) ## What a user saw A `delegated_admin` may invite members: the invite door answers 200 for that principal. But `GET /meta/object/sys_user` served that principal no `invite_user` action, so the console withheld an action the server admits. The action's `role` param is `{ field: 'role', objectOverride: 'sys_member' }`, so it names `sys_member.role`. The ADR-0106 mask read every param's `field` as a field of the served object. A caller denied `sys_user.role` therefore lost the whole action. The mechanism, measured at `607463d736`: `presentationEntry` in `packages/metadata-core/src/object-schema-fls-references.ts` tested every non-list key of an action with `mentionsDenied({ [key]: inner }, denied, 'skip', 'classified')` (line 383), and `denied` is the served object's denied set. A unit repro against the base build (`role` denied on `sys_user`) served `['other']` and dropped `invite_user`. The base census below shows the same thing on a real showcase boot. ## What changed **The rule (`@objectstack/metadata-core`, `object-schema-fls-references.ts`).** An action's `params` are now read one param at a time (`actionParamReadsDenied`). A param whose `objectOverride` names another object reads that object's field. Its `field` is judged against the caller's readable set on that object, and it is not a reference to the served object's fields. The action is still dropped when the field is not readable there. It is also dropped when that object's readable set cannot be determined. The override's value is an object name, so it is no longer tested as a field token. Everything else on the param is still read against the served object. There is no special case for `invite_user`: the rule covers every authored param with `objectOverride`. **Where the other object's readable set comes from (H3).** The posture is still decided once per caller and object, before the fetch (ADR-0106 D3). Only the fetched document says which other objects its params name, so the related half runs after the fetch: - `resolveObjectSchemaMaskPosture` now puts `relate` on a `project` posture. `relate` asks the posture's own question (same caller, same security service, same D7 preference for `getMetadataReadableFields`) about another object. - `relateObjectSchemaMaskPosture(posture, ...documents)` fills `related` for the objects those documents' params name. It does nothing for any other posture or for a document with no such param. It asks each object once, and it never throws. - `applyObjectSchemaMask` passes `related` into the reference mask. Every related read it withholds goes into the fingerprint as `object.field`. Two callers denied the same fields on the served object but different fields on the other object therefore never share a validator (D3's 304 cohorts). An unrestricted caller's ETag is byte-identical to before. I chose this over a second posture argument at every exit for one reason: the posture already reaches every projection site, and the masker closure that resolved it does not. With `relate` on the posture, each exit adds one awaited call between its fetch and its projection. No exit had to add a port or a request field. **Every exit relates its posture (`@objectstack/rest`, `@objectstack/runtime`).** The issue placed the fix at `presentationEntry`, with the runtime dispatcher's `maskObjectSchema` as the possible exit. Measured, the projections that serve actions live in two packages. In `@objectstack/rest` they are the shared item chain, layered chain and list chain (`meta-item-read-gate.ts`) and `RestServer`'s cached read and published read (`rest-server.ts`). The runtime dispatcher reaches the shared chains through `projectMetaObjectSchema` plus its own `maskObjectSchema`. ADR-0106 D5 requires all of them to mask alike. So each one now relates its posture right after the fetch, which is the narrowest correct form: `packages/rest` is the real home of most exits. The `/meta` diff route masks only `{ fields }` and has no actions, so it needs no relate step. **The shared contract (`@objectstack/metadata-core/testing`).** `FLS_CONTRACT_OBJECT` gains two actions whose params read `contact` fields through `objectOverride`, and the retention facts require the readable one to be served. An exit that skips the relate step withholds it (fail closed) and fails the contract by exit name. This was measured: see reverse verification below. ## Decisions - **H4: the param's `name`.** Under `objectOverride`, a `name` that repeats `field` is read as that field, so it is judged on the other object. An explicit `name` that differs from `field` is a request-body key whose owner nothing here can verify. It keeps the existing reading, as a reference to the served object, which can over-mask but never leak. With `defaultFromRow`, the param also seeds `field` from the served object's row (the spec's "key = the resolved field name"). That is a second read of the served object, so `field` is judged there too. All three cases are pinned. - **H5: fail closed.** If the security service has no answer for the other object, throws for it, or the object does not exist, the action is dropped. A `project` posture that nobody related (hand-built, or an exit that skipped the step) relates nothing, so its `objectOverride` actions are dropped too. A related throw withholds only the actions that read that object, with a `warn` naming it. The served object's own D6 tiers are unchanged. Exempt callers (platform admin, `isSystem`) get passthrough and the service is never asked about the related object. That is pinned. ## Census (H2), measured on a real showcase boot I added a scratch probe under `packages/qa/dogfood/test/` (deleted afterwards, not committed). It read every object schema the showcase serves (78 objects) through the by-name read and the list read, as five principals in one organization: the seeded platform admin, an `owner` who is not a platform admin, an `admin`, a `delegated_admin` and a `member`. I ran it once on head, and once with all six touched source files restored to the base blobs and those packages rebuilt. The restore was proven by blob equality with HEAD and an empty `git diff HEAD`. The workspace has two authored params with `objectOverride`: `sys_user.invite_user`'s `role` (on `sys_member`) and `sys_member.invite_user`'s `email` (on `sys_invitation`). `sys-member.object.ts` has two hits, but the other one is a comment. The only other hit is the `packages/lint` test fixture, which this mask never reads. | principal | `sys_user.invite_user`, base to head | `sys_member.invite_user`, base to head | |:--|:--|:--| | platform admin | served, served | served, served | | owner | served, served | served, served | | admin | served, served | served, served | | delegated_admin | **dropped, served** | served, served | | member | **dropped, served** | served, served | The by-name read and the list read agree in every cell. Across all 78 objects × 5 principals × 2 reads, the only served/dropped verdicts that moved are the two in bold. No read answered anything but 200 at base or head. On head, the `delegated_admin` and the `member` are both not served `sys_user.role`, and both are served `sys_member.role`. ## Why the member is still not offered it The member is **not** denied `sys_member.role`, so it is now served `sys_user.invite_user` in the metadata. It is not offered the action because of the reach gate from objectstack-ai#21883: `requiresMembershipReach: 'invite_member'` lowers to a `visible` predicate over `current_user.positions`, and that predicate excludes the member grade. The dogfood case says this in as many words. It asserts that both grades are denied `sys_user.role`, served `sys_member.role` and served the action, that the delegated_admin is offered it, and that the member's served predicate evaluates false. ## Exported surface (measured on the built declarations) I diffed `packages/metadata-core/dist/index.d.ts` (and `index.d.cts`) built at base `607463d736` against head: - added: `relateObjectSchemaMaskPosture(posture, ...documents)`; - added: two optional members on the `project` member of `ObjectSchemaMaskPosture`, `related` (a map from object name to its readable field set, or undefined) and `relate` (a function from object name to a promise of that set); - `dist/testing.d.ts`: the `FLS_CONTRACT_OBJECT` literal type gains the two actions; - nothing removed, renamed or narrowed. The rest of the diff is docblock text. So the claim's `Clause-②: no` becomes `yes (widening)`, and `@objectstack/metadata-core` takes a `minor` changeset. `@objectstack/rest` and `@objectstack/runtime` take `patch`: their exported signatures are unchanged (`projectMetaObjectSchema` keeps its signature). ## Tests Final head `e5e2792cf1`, which merges `origin/main` at `1e18a0735c`: - `pnpm --filter @objectstack/metadata-core test`: 18 files, 397 passed. - dogfood, `--project isolated`: `org-admin-affordance-reach.dogfood.test.ts` and `delegated-admin-invite.dogfood.test.ts`, 2 files, 17 passed. - The ADR-0106 contract suites at every exit: `packages/rest/src/meta-object-fls.test.ts` plus the two capability-gate suites that read the fixture, 3 files, 123 passed; `packages/runtime/src/domains/meta-object-fls.test.ts`, 85 passed. - `typecheck` for metadata-core and dogfood: exit 0. At `0f9ce970cd`, the previous merge of `origin/main`: - full `@objectstack/rest` (both projects): 265 files, 5075 passed, 327 skipped; - full `@objectstack/runtime` (both projects): 330 files, 5390 passed, 19 skipped; - `typecheck` for rest and runtime: exit 0. Between those two heads, this branch changed two test titles, and `origin/main` brought a `platform-objects` action retirement and spec test-title text. Neither touches rest or runtime, so the full suites were not rerun (AGENTS.md, Multi-agent discipline §10). CI runs them. New unit pins, in `object-schema-fls-references.test.ts` under "an action param under `objectOverride` is judged against the object it names": - triage's three: - a delegated_admin-shaped caller is served `invite_user`; - an action whose param names a denied field of the served object is still dropped; - an `objectOverride` param on a field denied on the other object still drops the action, even when nothing of the served object is denied; - fail closed: undetermined, throwing, unknown object, and an unrelated posture; - exempt callers are untouched; - the `name` and `defaultFromRow` readings; - fingerprint cohorts; - relate asks each object once. The dogfood: `org-admin-affordance-reach.dogfood.test.ts` pinned the defect itself as `MASKED_BELOW_TENANT_ADMIN = ['sys_user.invite_user']`. That pin is now "every site is served to every grade". The new case, "a delegated_admin is offered Invite User on sys_user; a plain member is not — by the reach gate, not the field mask", is the one dogfood test for this card. I edited the existing file rather than adding a second showcase boot. ## Reverse verification (one-off, on committed HEAD `af06da75ac`) - **The rule.** Through `scripts/ablation-replace.mjs`, I set `presentationEntry`'s key reading back to the base reading (`const read = readsAsThisObject(key);`, which reads `params` as the base did). Anchor 1 to 0, blob `d7455eadbd55` to `5b00498c2f07`. Result: 4 failed, 73 passed. Red: the delegated_admin pin; the other-object-denied pin (through its served-object-whole half, where the base serves the action); fail-closed; and the `name`/`defaultFromRow` pin. Green, as expected: the "denied field of THIS object" pin, the exempt pin, the fingerprint pin and the relate pin. The restore was proven by `blob == HEAD (d7455ea)` and an empty `git diff HEAD`. - **An exit that forgets to relate.** I removed the relate step from the shared item chain (`createMetaItemAnswer`) and ran `packages/rest/src/meta-object-fls.test.ts`. Result: 4 failed, 91 passed. The four were `restricted-caller/field-vanishes-whole`, `restricted-caller/required-permissions-cause`, `unrestricted-caller/byte-identical` and `guest-fallback/D7`, each failing under the exit "GET /meta/object/:name — uncached branch" with "the mask over-reached". No other exit failed. The restore was proven by `blob == HEAD (b9e94d4)` and an empty `git diff HEAD`. ## Gates (at `e5e2792cf1`) - **Derived families.** After the second merge, `node scripts/pm/dispatch-gates.mjs --commands` derives the same 68 families. All 68 exit 0, and `--ran` reconciles them: 68 run, 0 NOT MEASURED, 0 unrun, and every family carries a recorded exit code. - **`check:dual-build-cjs-loads`.** At `0f9ce970cd` it first answered `PREREQUISITE NOT MET`, because 8 packages outside the dogfood closure had no `dist/`. That was a run that measured nothing, not a red. After `turbo run build` over `./packages/*` and `./packages/*/*`, it reported 106 entry points across 66 packages load. - **The artifact-roster block.** It is printed outside the derived total, unchanged after the merge, and has 53 commands. 50 exit 0. Three need a PR's context and answered NOT WIRED or NOT MEASURED locally: `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths`. `check-partof-closing-keyword` passes on this body when given it as `PR_BODY`. The other two run against this PR once it exists, and their results are in the dev report. - **The four symbol-anchor sweeps.** `check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and `check:adr-anchors` all exit 0. - **ESLint, narrowed.** `pnpm exec eslint --no-inline-config --format json` over the 9 touched `.ts` files reports 9 files, 0 errors, 0 warnings. All 9 are in the population `eslint.config.mjs` declares (`packages/**/*.{ts,tsx,mts,cts}`). The config never enables type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **Cost.** A masked read of a document with an `objectOverride` param costs one more security-service read per object those params name. Today that means two documents, `sys_user` and `sys_member`, one related object each. It applies to every `project` posture, including a caller who is denied nothing on the served object, because that caller can still be denied the field on the other object. - **What the contract cannot express.** The contract's security double answers the same set for every object, so it cannot express "denied here, readable there", which is this card's own case. The unit pins and the dogfood hold that case. The contract holds that every exit relates. - **Lane.** The edits to `packages/rest/src/meta-item-read-gate.ts` and `packages/rest/src/rest-server.ts` are outside the declared lane. They are where most of the exits are (see above). `sys-user.object.ts` is untouched: the declaration was right and the mask was wrong. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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_REACHinpackages/spec/src/identity/membership-reach.ts, beside the closed name list inmembership-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/identityonly:MEMBERSHIP_REACH,MEMBERSHIP_REACH_NAMES,membershipReachPredicate,lowerRequiresMembershipReach, and theMembershipReachEntry/MembershipReachName/MembershipReachStatementtypes.invite_member/organization/invite-memberinvitation:createcancel_invitation/organization/cancel-invitationinvitation:cancelupdate_member_role/organization/update-member-rolemember:updatetransfer_ownership/organization/update-member-role, setting the creator rolemember:updateplus better-auth's creator-role ruleremove_member/organization/remove-membermember:deletecreate_team/organization/create-teamteam:createupdate_team/organization/update-teamteam:updateremove_team/organization/remove-teamteam:deleteadd_team_member/organization/add-team-membermember:updateremove_team_member/organization/remove-team-membermember:deleteThe sugar —
requiresMembershipReachonActionSchema(packages/spec/src/ui/action.zod.ts), in the family ofrequiresFeature. It is enum-checked against the table's row names. At parse time it becomes one'NAME' in current_user.positionsterm per grade, in the namesmapMembershipRoleprojects the grades to (org_owner,org_admin,delegated_admin,eval-user.zod.ts). It is AND-composed with an explicitvisibleand stripped from the parsed output.lowerRequiresMembershipReachmirrorslowerRequiresFeaturebranch for branch:visible: truegives the gate alone;visible: false, a non-CEL or AST-onlyvisible, and a blanksourceare loud parse errors at the key. It runs inside the same.transform()asrequiresFeature, ahead of it, sofeatures.*stays the last term. One stage rather than two: a second pipe stage moved twelvedropped-refinements.baseline.jsonentries one level deeper (inbecamein.in), measured on the first build.The declarations —
packages/platform-objects/src/identity/*.object.ts. Re-counted at base9f9510f25e: 14 sites carryrequiresFeature: 'organization', the seat's count. The ruling counted 12 at088428fb4. Thirteen take the key; one takes none:sys_user.invite_userinvite_membersys_member.invite_userinvite_membersys_member.add_memberaddMember(ADR-0068)sys_member.update_member_roleupdate_member_rolesys_member.remove_memberremove_membersys_member.transfer_ownershipownertransfer_ownership(the record predicate is kept and the sugar composes onto it)sys_invitation.invite_userinvite_membersys_invitation.cancel_invitationcancel_invitationsys_invitation.resend_invitationresend: trueinvite_membersys_team.create_team/update_team/remove_teamsys_team_member.add_team_member/remove_team_memberThe 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 aredefaultRolesplus thedelegated_adminregistration, asked through the vendor's ownauthorize. It also reads, from the installed vendor route source, the statement each endpoint'shasPermissionchecks and the creator-role default.auth-manager.tsis untouched.The proof —
packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts. It boots the showcase with an owner, anadmin, adelegated_adminand a plainmemberin one organization. It reads each principal's served session (/auth/get-session), the served flags (/auth/config) and the served action metadata ofsys_member,sys_invitation,sys_team,sys_team_memberandsys_user. It evaluates the served predicates withcelEngine, 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
requiresFeaturedecided it: anaction.jsonliveness row (live, evidence symbol-anchored to the lowering, no ADR-0054 proof binding, asrequiresFeaturehas none); the action metadata form offers the key in its Placement section besiderequiresFeature; 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.jsonandexport-origins/identity.json; the reference docs (content/docs/references/{ui/action,data/object,kernel/metadata-plugin}.mdx);liveness/state-counts/action.md.gen:skill-refsalso 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 carriespositions, 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/specminor(carries the Clause-② line),@objectstack/platform-objectspatch. The platform-objects dist moved, measured withnpm pack:requiresMembershipReachis in 14 shippeddist/files, against a positive control ofrequiresFeaturein 14. plugin-auth shipsdistonly, so its new test file publishes nothing.Premises (ruling 5993018584 §Premises), verified first
objectstack dev --fresh --seed-admin, private port). As the owner I invited amember, anadminand adelegated_admin; each signed up and accepted, and I readGET /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 servedsys_member.invite_user.visiblewasfeatures.organization != false(flagtrue), andPOST /auth/organization/invite-memberanswered 403YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION. That is the card's reproduction.0abd4f9f87:RelatedToolbarButtoninpackages/plugin-detail/src/RelatedList.tsxevaluates each toolbar action'svisiblethroughuseCondition(fail-closed). Row actions go through the data-table'sDataTableRowActionItem.current_useris bound tobuildExpressionUser(user), which forwardspositions(packages/app-shell/src/providers/expressionUser.ts).git grep -n defaultRoles origin/main -- packages/plugins/plugin-auth/src/auth-manager.tshits at lines 1328, 3041, 3042, 3049 and 3050. better-auth 1.7.3 exportsdefaultRoles,defaultAc,memberAcanddefaultStatementsfrombetter-auth/plugins/organization/access.Where the measurement differs from the ruling's sketch
resend_invitationis reached bydelegated_admin. A resend is a call to/organization/invite-memberwithresend: true, and that door checksinvitation:create, whichdelegated_adminholds. Measured on the base boot: the delegate's resend answered 200 and its cancel answered 403. So the table row isinvite_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_ownershipis owner-only, as the ruling says, through better-auth's creator-role rule rather than a statement: an admin settingowneranswered 403YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER, and the same admin's role change tomemberanswered 200.add_membertakes 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
0a45d2f6e4unless marked, run throughscripts/pm/os-verify-lock.sh. Patch round 1 moved one test file, and its readings at the current head7948aaa454follow the list.@objectstack/spec:vitest run --project localgave 617 files and 18411 tests passed (1 todo);--project repogave 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 rungave 59 files and 949 tests passed.@objectstack/plugin-auth:vitest rungave 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.tsgave 12 tests passed.typecheckfor spec, platform-objects, plugin-auth and dogfood all exited 0.node scripts/ablation-replace.mjs: anchor 1 then 0, blob780d2b7a0de4thenf7c01c42efc0. The prediction was 4 red / 3 green in the wiring file and 0 red in the lowering file. Observed:action-requires-membership-reach.test.tswent 4 red (the key pin, the requiresFeature-composition test, the record-predicate composition test, thevisible: falserefusal), and the remaining 20 of 24 stayed green. Direction: red, as predicted. The restore was proven by blob equals HEAD (780d2b7a0de4) and an emptygit diff HEAD. There is no dist leg: the test imports./action.zodfromsrc.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(119 commands at0a45d2f6e4), 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-examplesandcheck:dual-build-cjs-loadsread 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.node scripts/pm/check-governed-merges.mjs --branch claude/issue-21795-membership-grade-action-gategave "0 of 34 path(s) hit the register after 2 generated-artifact lift(s)" and "NOT governed" at7948aaa454(33 paths at0a45d2f6e4). Bothskills/**index files are certified PURE REGENERATIONS, byte-equal togen:skill-refsrecomputed on this tree.7948aaa454:metadata-protocol's served-actionkey-count pin moves 49 → 50, and its named sample gainsrequiresMembershipReach(protocol.meta-types-degenerate-derivation.test.ts, the one file this round touched).@objectstack/metadata-protocol(214 files / 27745 tests),@objectstack/rest(265 / 5075) and@objectstack/client(51 / 652) suites are green.dispatch-gates --ranreads "120 derived, 120 run, 0 NOT-MEASURED, 0 UNRUN".check:skill-refsis the gate that demands the two index files. It runs in the requiredTypeScript Type Checkaggregate (laneType 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 forpnpm --filter @objectstack/spec gen:skill-refs, exit 1. Both files were restored afterwards: blob equals HEAD andgit diff HEADis empty.Acceptance notes
packages/console/distis absent andobjectui:buildwas not run).identity-auth.invitation-scope-gatesA5 ("the UI shows invite affordances only to entitled personas"): the delegated admin is offeredinvite_useron the Members and Invitations lists, andresend_invitation. The plain member is offered none.identity-auth.org-membership-team-managementA7 ("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_useris withheld from the delegated admin by the metadata-plane field mask (ADR-0106), not by this gate. Itsroleparam (objectOverride: 'sys_member') is read as a reference tosys_user.role, which that grade cannot read, so/meta/object/sys_userdrops 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 onmultiOrgEnabled) target grade-gated endpoints (organization:updatefor owner and admin,organization:deletefor owner) and are outside the ruling's declaration scope.delegated_adminis not a reserved identity name, so a tenant-authoredsys_positionof that name would satisfy the invite term client-side. The server still refuses, so this is UI courtesy only.content/docs/ui/actions.mdx,content/docs/protocol/objectui/actions.mdx) describerequiresFeatureand not yet this key. They are outside this PR's file surface.skills/**indexes.gen:skill-refsrewroteskills/objectstack-data/references/_index.md(70 to 71 lines) andskills/objectstack-ui/references/_index.md(60 to 65), becauseaction.zod.tsnow reachesidentity/eval-user.zod.tsand, through its type import,security/permission.zod.ts. These are generator-owned outputs under the register'sspec-skill-refsexception. AllSKILL.mdcontent: 4411 to 4411 lines.skills/**in total: 13360 to 13366.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) touchesauth-manager.ts, but not the organization roles registration this PR's test pins.维护者速读(草稿)
check-governed-merges已核验为纯再生成(生成器豁免),本 PR 不受治理面约束;合约级复核(Clause-②)后由席位按流程落地。Generated by Claude Code