Skip to content

fix(identity): Invite User offers only the roles the caller may grant, read from the role cap's own table - #24033

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23957-invite-role-options-per-caller
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23957-invite-role-options-per-caller

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23957
Clause-②: yes

What changes

A delegated_admin's Invite User dialog offered Owner, Admin and Delegated Admin, and the server's invitation role cap refuses all three for that issuer. Now the dialog offers only what the cap admits for the caller. Both read one table in @objectstack/spec/identity, so the offer cannot drift from the refusal.

invitation role issuers that may grant it (INVITATION_ROLE_GRANTORS) option visibleWhen
owner owner 'org_owner' in current_user.positions
admin owner, admin 'org_owner' in current_user.positions || 'org_admin' in current_user.positions
delegated_admin owner, admin same as admin
member any issuer (INVITATION_ROLE_ANY_ISSUER) none, always offered
  • packages/spec/src/identity/invitation-role-grant.ts (new; constants and pure helpers only): INVITATION_ROLE_GRANTORS, INVITATION_ROLE_ANY_ISSUER, invitationRoleGrantors(role) (a role outside the closed vocabulary takes the owner, admin row), invitationRoleGrantPredicate(role) (it builds the gate the way membershipReachPredicate does: one mapMembershipRole projection per grade), INVITATION_ROLE_OPTIONS, and the types InvitationRoleGrantors and InvitationRoleOption. It is exported from identity/index.ts, and the api-surface and export-origins artifacts are regenerated with it.
  • packages/plugins/plugin-auth/src/invitation-role-cap.ts: isPlainMemberInvitation and the admission decision now read the table. The grade ladder still resolves the issuer's grade and chooses which refusal text names the reason. Refusals and their texts are unchanged (pin below).
  • sys-user / sys-member / sys-invitation invite_user: the role param declares options: [...INVITATION_ROLE_OPTIONS] instead of inheriting the field's list. Everything else about the param stays field-backed: type, label, objectOverride, required. Values, English labels and order are unchanged.
  • i18n: the option labels now resolve under _actions.invite_user.params.role.options.VALUE, because objectui's actionParamOptionLabel reads that path for every param option. The bundles are regenerated, and the three translated locales reuse the field's existing option translations. No source-hash residue is left (check:i18n OK).

The resulting offer: a delegated_admin sees Member only; an admin sees Admin, Delegated Admin, Member; an owner sees all four. The card's done-when says an admin should "still see all of them". The cap has always refused owner from an admin, so the offer follows the cap instead (see Acceptance notes).

Hypothesis 1, measured: an existing mechanism carries it, and objectui needs no change

At objectui f8267df93a (.objectui-sha):

  • packages/fields/src/resolveActionParams.ts: resolvedOptions = authoredOptions ?? normaliseOptions(field.options, …). Authored options win over inherited ones, and resolveOptionLabels returns them unchanged or rebuilds them by spread, so visibleWhen survives.
  • packages/core/src/evaluator/optionRules.ts resolveVisibleOptions filters by each option's visibleWhen against the record plus current_user. The four option widgets reach it through useCascadingOptions.
  • packages/app-shell/src/hooks/useConsoleActionRuntime.tsx:285 re-labels each option through actionParamOptionLabel(object, action, param, value, label), which spreads the option and keeps visibleWhen.

So no new served field is needed, and no objectui half is filed. One correction to the dispatch's premise: at this pin normaliseOptions also spreads inherited entries (fbc23e094), so an inherited option would keep visibleWhen too. The param still declares its own list, because a visibleWhen on the FIELD's options would narrow every sys_member.role picker, not only the invite dialog.

Pins

  • plugin-auth/src/invitation-role-grant-offer.test.ts
    • The offer is the door: for each of the three mirrors and each issuer grade, it evaluates the declared options' visibleWhen with celEngine against current_user.positions = [mapMembershipRole(grade)], and asserts the offered set equals { role : invitationRoleCapFailure(role, grade) === null }. It also pins delegated_admin → ['member'], admin → three roles, owner → all four.
    • The cap is unchanged: a frozen copy of the pre-table cap is the oracle across a 41 × 37 grid of requested and issuer values: every single role, every two-role comma pair over five names (sales_manager included), arrays, case and space variants, and empty or non-string values. Every case gives the same answer with the same text. The test also asserts the grid holds both refusals (more than 100) and admissions.
  • spec/src/identity/invitation-role-grant.test.ts: the table covers the vocabulary, the unlisted-role row, the predicate text, and that the options keep the vocabulary's values, labels and order.

Ablation (one-shot; no permanent file): scripts/ablation-replace.mjs dropped the owner option's visibleWhen (anchor 1 → 0, blob fe0aa9a9 → 8fcec5e6). After a spec rebuild, ablation-dist-preflight found the marker in dist/identity/index.{js,mjs}, and invitation-role-grant-offer.test.ts went red: 10 failed (every non-owner issuer on all three mirrors, plus the explicit case). The restore was verified: blob equal to HEAD, git diff HEAD empty, porcelain clean. After a rebuild the marker was absent from all 232 built files, and the test was green again with 14 passed. A static list with no options on the param fails the same file's invite_user declares its own role options assertion.

Verification (local, head 4ce40d55, after merging origin/main)

  • @objectstack/plugin-auth test: 151 files, 2956 passed, 10 skipped. typecheck exit 0.
  • @objectstack/spec test: 691 files, 20939 passed. typecheck exit 0. check:generated: all 14 artifacts up to date.
  • @objectstack/platform-objects test: 73 files, 1136 passed. typecheck exit 0.
  • node scripts/check-i18n-bundles.mjs: OK, 10 packages in sync.
  • dispatch-gates --commands derived 90 families on 4ce40d55: 89 exit 0 (check:role-word among them, since this diff names roles). --ran reconciliation: 90 accounted, 89 run, 1 NOT MEASURED, 0 unrun.
  • check-plugin-teardown-shape --self-test exited 3 (PREREQUISITE NOT MET: its fixture needs an unshallowed clone). NOT MEASURED locally; CI reads it.

Acceptance notes

  • The admin's offer excludes Owner. The card's done-when reads "an owner or admin still sees all of them". The cap refuses owner from an admin, both before and after this PR, so the offer follows the cap. The admin dialog therefore no longer shows Owner. Offering it would keep exactly the offer-then-refuse defect this card closes.
  • A role outside the closed vocabulary (ADR-0108): the dialog never offers one, because the option list is the closed vocabulary and is not widened. The cap grades it with invitationRoleGrantors' administrative row, as before.
  • resend_invitation on sys_invitation keeps its field-backed role param with defaultFromRow. It re-sends the row's own role and is not one of the three invite_user mirrors, so it is untouched.
  • Stale docblock, observation only: ui/action.zod.ts (the ActionParam options docblock and its guidance comment) says objectui's normaliseOptions rebuilds inherited entries as { label, value }. At the current pin it spreads them (fbc23e094). That is a documentation drift with no runtime effect. Carrier: none.
  • The option gate is UI courtesy; the server cap stays the authority. Nothing changes about who the server lets invite whom.
  • plugin-auth: the invitation role-cap refusals (invite as admin / delegated_admin by a delegated issuer) answer 403 with a message and no code — the sibling vendor refusal on the same endpoint carries one #23945 (the refusal's missing code) is not addressed here.

Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/identity.json, packages/spec/export-origins/identity.json, packages/spec/src/identity/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (symbol, 41 pages)
  • 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 — 146 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 9e797a89764e396f75485f3b7b43d0f135aec52b → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9e797a89764e396f75485f3b7b43d0f135aec52b

⚠️ 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 9e797a89764e396f75485f3b7b43d0f135aec52b → 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: 4ce40d551df9dc9f296a2f976e9a4b7e673b3fef
Local-runs: none
Scope: packages/spec/src/identity/index.ts, packages/spec/src/identity/invitation-role-grant.ts + packages/plugins/plugin-auth/src/invitation-role-cap.ts, packages/plugins/plugin-auth/src/invitation-role-grant-offer.test.ts, packages/spec/src/identity/invitation-role-grant.test.ts, packages/platform-objects/src/identity/sys-user.object.ts, packages/platform-objects/src/identity/sys-member.object.ts, packages/platform-objects/src/identity/sys-invitation.object.ts, packages/spec/src/identity/membership-reach.ts, packages/spec/src/identity/membership-role.ts, packages/spec/src/identity/eval-user.zod.ts, packages/spec/src/ui/action.zod.ts, packages/spec/api-surface/identity.json, packages/spec/export-origins/identity.json, packages/platform-objects/src/apps/translations/en.objects.generated.ts, packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts, packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts, packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts, .changeset/23957-invite-role-options-per-caller.md, packages/plugins/plugin-auth/src/auth-manager.ts (cap call site, unchanged), packages/core/src/security/resolve-authz-context.ts (positions projection, unchanged), packages/cli/src/utils/i18n-extract.ts (unchanged), and objectui at the .objectui-sha pin f8267df93a: packages/fields/src/resolveActionParams.ts, packages/core/src/evaluator/optionRules.ts, packages/fields/src/widgets/useCascadingOptions.ts, packages/app-shell/src/views/ActionParamDialog.tsx, packages/app-shell/src/hooks/useConsoleActionRuntime.tsx, packages/app-shell/src/providers/expressionUser.ts, packages/i18n/src/useObjectLabel.ts. The scope covers all 15 changed files; no remainder is left to the lane seat's TIER_DEFAULT accept.

Inputs: card #23957 (body; triage 6219499129, claim 6221327426, dev report 6223402583), PR #24033 (body, file list, net diff against merge-base fd0e72abc2 of the head with origin/main), and the 41 check-runs on the head, read 2026-10-16T20:19Z: 35 success, 6 skipped (Build Docs, Console Pin Gate, the dogfood shards and CLI, the opt-in packed-tarball smoke, Temporal Conformance: path-filtered or opt-in), 0 failed, 0 in progress. Check Changeset, Governed Surface Queue Guard, Spec property liveness, every Lint and Type Check job, and Test Core 1 to 6 plus the aggregate are among the green ones. Record written 2026-10-16T20:31Z.

① Derived judgments

  1. The new @objectstack/spec/identity exports are constants and pure helpers only: right. invitation-role-grant.ts exports five runtime symbols (INVITATION_ROLE_ANY_ISSUER, INVITATION_ROLE_GRANTORS, INVITATION_ROLE_OPTIONS, invitationRoleGrantors, invitationRoleGrantPredicate) and two types (InvitationRoleGrantors, InvitationRoleOption). It imports only its two sibling identity modules (./membership-role, ./eval-user.zod), exactly as membership-reach.ts does, and performs no I/O and declares no schema. api-surface/identity.json and export-origins/identity.json move by exactly those seven symbols, and they are the only baselines in the repo that list identity exports (membershipReachPredicate appears in no other baseline).

  2. The server cap refuses exactly what it refused before, same cases, same outcomes, same text: right. Read side by side at the merge-base and the head: parseOrgRoles, orgRoleGrade, isOrgAdminGrade, the empty-request return and the unresolved-issuer refusal are untouched. isPlainMemberInvitation is equivalent, because member is the only row equal to INVITATION_ROLE_ANY_ISSUER and an unlisted role takes the administrative row. Admission is identical by derivation: before, admitted iff issuer grade at least admin and requested grade not above it; after, grantTableAdmits requires issuer grade at least admin and, for each requested role, an issuer role in that row, which is owner only for the owner row and owner or admin otherwise, so an owner request still needs an owner issuer and every other request needs admin grade. The refused branches keep their order and their three strings byte for byte. The call site (auth-manager.ts beforeCreateInvitation, line 3415) is unchanged by the diff. The parity pin's oracle is a line-for-line freeze of the merge-base cap over a 41 by 37 grid that includes comma pairs, arrays, case and space variants and non-string values, and asserts the grid holds both refusals and admissions; Test Core is green on the head.

  3. Each option's visibleWhen admits exactly the issuers the cap admits for that role, on the current_user.positions projection the cap's grades map to: right. The owner option is gated on org_owner, which is grade 3 in the cap; admin and delegated_admin on org_owner or org_admin, which is grade at least 2; member is ungated, matching the plain-member exemption. The projection is the cap's: resolve-authz-context.ts line 1376 splits a comma-joined sys_member.role, maps each name through mapMembershipRole (which lower-cases the three graded names, as parseOrgRoles does), so a multi-role issuer's positions hold every grade the cap would grade on. The offer pin does not hand-list the expected sets: for each of the three mirrors and each of the four grades it derives the admitted set from invitationRoleCapFailure itself and evaluates the declared visibleWhen with celEngine against [mapMembershipRole(grade)]. One asymmetry is inherent to the projection and is the same one the membership-reach.ts precedent carries: the console's positions are resolved for the active organization (or the union of memberships with none active) and may also hold a sys_user_position row, while the cap grades the issuer's sys_member.role in the target organization. The option gate is UI courtesy and the schema docblock on visibleWhen says so; the cap stays the authority. Not a defect of this PR.

  4. Nothing restates the rule by hand outside the table: right, with one declared exception that is not a restatement. The three invite_user mirrors declare options: [...INVITATION_ROLE_OPTIONS] and no hand-written predicate; a grep of packages/platform-objects/src/identity/ finds no role gate over current_user.positions. The cap keeps its grade ladder to grade the issuer and to choose which refusal text names the reason, and grantTableAdmits keeps the below-admin guard, which the table's any-issuer row cannot express. That guard changes the answer only for a duplicate-member multi-role value such as member,member from a delegated issuer, which the picker cannot produce; it is documented in both the table's and the cap's docblocks, and the parity pin holds it. The ladder's grade-above-issuer branch now only selects a refusal text and no longer decides admission.

  5. The dialog's labels still translate: right. At the objectui pin, useObjectLabel.ts line 717 resolves _actions.{action}.params.{param}.options.{value} with the authored label as fallback; the CLI extractor (i18n-extract.ts) emits exactly that path for an action param's options; the four bundles carry it under all three mirrors; the three non-English locales reuse the field's existing strings verbatim (受托管理员, 委任管理者, Administrador delegado, and so on); the English labels and the option order are unchanged from BUILTIN_MEMBERSHIP_ROLE_OPTIONS, which the spec pin asserts. check:i18n runs in lint.yml and the Lint check-runs are green.

  6. No objectui half is needed (the PR's Hypothesis 1): right at f8267df93a, which the PR does not move. resolveActionParams.ts line 650 takes authored options over inherited ones; resolveOptionLabels returns the list verbatim or rebuilds by spread; normaliseOptions (line 515) spreads too. ActionParamDialog renders a select param through SelectField, the one caller of useCascadingOptions, which resolves through resolveCascadingOptions and resolveVisibleOptions with the predicate scope from ExpressionProvider, whose current_user subject carries positions: user.positions ?? [] (expressionUser.ts line 182). The localisation relabel (useConsoleActionRuntime.tsx line 285, and RecordDetailView.tsx line 957) spreads each option, so visibleWhen survives. A faulting predicate fails open in resolveVisibleOptions, but a signed-out or position-less user holds [], on which the gate evaluates false rather than faulting.

  7. The field-level pickers and resend_invitation are untouched: right. sys_invitation.role keeps [...BUILTIN_MEMBERSHIP_ROLE_OPTIONS] with defaultValue: member; sys_member.role is unchanged; resend_invitation keeps its field-backed role with defaultFromRow and no options. ADR-0108 D1's closed vocabulary is not widened: the option list is the vocabulary, narrowed per caller.

② Semver level

The changeset declares @objectstack/spec: minor, @objectstack/platform-objects: patch, @objectstack/plugin-auth: patch, and this matches what the diff publishes: a pure widening of @objectstack/spec/identity by seven exports (no removal, no rename, so no migration text and no ADR-0087 marker is owed); platform metadata that moved (the three invite_user role params now declare gated options; the bundles carry the new keys) with values, labels and order unchanged; and a plugin-auth implementation that moved onto the table with behaviour unchanged. Clause-②: yes (widening) is in the changeset body and Clause-②: yes in the PR body, consistent with AGENTS.md section 3 (yes takes at least minor). Check Changeset is green on the head.

③ Boundary flags

  • Open question 1, the admin's offer excludes Owner, against the card's done-when text. Judged: the shipped offer is right. The cap has refused an owner invitation from an admin since it landed (feat(auth): give ADR-0105 D8's scope-bounded issuance a caller — delegated_admin, capped (#3697) #3722, the grade ceiling: an invitation never confers a grade above the issuer's own), the claim fixed no change to who the server lets invite whom, and offering Owner to an admin would reproduce for one role exactly the offer-then-refuse defect this card closes. The done-when's an owner or admin still sees all of them is the filer's reading of the static list, not a ruling; its second bullet, a pin asserting the offered set per caller that fails against the static list, is met for all four grades. Not escalated. For the seat: amend the card's done-when wording when closing it, so the record does not read as a deviation.
  • Open question 2, the below-admin single-member rule stays in the cap rather than the table. Judged: acceptable as shipped. The rule is the exact-parity guard for a duplicate-member multi-role value, it is documented in both docblocks, pinned by the parity grid, and does not touch the offer. A per-row table flag would add a field with one reader. Not escalated.
  • Dev deviation, the admin offer is open question 1 above.
  • Dev deviation, test:repo not run locally: covered by Test Core 1 to 6 and the aggregate, green on the head. check-plugin-teardown-shape --self-test exit 3 locally (shallow clone): covered by the green Lint check-runs.
  • Dev deviations on process (detaching the shared primary checkout to hold the branch in a worktree; model-free commit trailers per AGENTS.md): no content effect; nothing for this record.
  • Out-of-scope finding, carrier none: the ActionParam options docblock and its guidance comment in packages/spec/src/ui/action.zod.ts say objectui's normaliseOptions rebuilds inherited entries as { label, value }; at the pin it spreads them (line 515, since fbc23e094). Confirmed stale; documentation drift with no runtime effect. For the seat: a docs-only card under domain:spec is the right carrier.
  • Nothing in the diff touches who the server lets invite whom, and plugin-auth: the invitation role-cap refusals (invite as admin / delegated_admin by a delegated issuer) answer 403 with a message and no code — the sibling vendor refusal on the same endpoint carries one #23945 (the refusal's missing code) is untouched, as the claim required.

Implemented-by: claude/issue-23957-invite-role-options-per-caller
Reviewed-by: session_01HjR8RUwTPQ3TgbfKihmxp9

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · PR #24033 @ 4ce40d551d (#23957) · domain:spec seat 1 · os-elon-musk · session session_01HjR8RUwTPQ3TgbfKihmxp9 · 2026-10-16T20:33Z

Reviewer of record. Every item below was checked against GitHub and the code, not against the report (6223402583 on #23957).

  • Shape: Draft, base main. The first line is Fixes #23957, the body's only closing keyword, then Clause-②: yes, matching claim 6221327426. 15 files, +574/−29. Not governed. Assignee os-elon-musk.
  • What it lands:
    • One table, two readers. A new @objectstack/spec/identity module, invitation-role-grant.ts, holds INVITATION_ROLE_GRANTORS (owner ← owner; admin and delegated_admin ← owner or admin; member ← any issuer) and pure helpers that build each option's CEL gate from it, in the shape of membership-reach.ts. It contains only constants and pure helpers.
    • The cap. plugin-auth's invitation role cap decides admission from that table, with the same outcomes and the same refusal text.
    • The mirrors. The three invite_user mirrors declare INVITATION_ROLE_OPTIONS, each option gated by its per-option visibleWhen. A delegated_admin is offered Member only, an admin Admin / Delegated Admin / Member, an owner all four. The labels moved to _actions.invite_user.params.role.options.* and translate in all four locales.
  • Contract review: PASS 6224153326 on this head (CONTRACT_REVIEW_TIER).
    • The cap's admission is identical by derivation, pinned against a frozen copy of the pre-table cap over a 41×37 grid.
    • Each option's gate matches the cap's grades on the real current_user.positions projection.
    • The baselines move by exactly the seven new symbols.
    • objectui at the pin honours an authored option's visibleWhen on an action param, so no objectui half is owed.
  • The report's open questions, the seat's calls:
    • (1) An admin's picker excludes Owner: A. The cap has always refused Owner from an admin, and the claim fixed "no change to who the server lets invite whom". Showing Owner would reopen the offer-then-refuse defect for one role. The card's done-when line "an owner or admin still sees all of them" was wrong on that point; the landing record says so.
    • (2) The below-admin guard stays in the cap: A. It is pinned and documented, and it has no effect on the offer.
  • Semver: minor (widening) on @objectstack/spec, patch on platform-objects and plugin-auth, Clause-②: yes (widening).
  • CI on this head: 41 check-runs, 35 success, 6 skipped by their filters, 0 failed. mergeable: clean.

At landing, this seat files a docs-only domain:spec card: the ActionParam options docblock and guidance comment in packages/spec/src/ui/action.zod.ts say objectui's normaliseOptions rebuilds inherited options as { label, value }. At the pin it spreads them (the review and the report both measured it).

The PR goes to the merge queue in this act. The cloud dev session is archived at this ACCEPT.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 16, 2026 20:34
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 16, 2026 20:34
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit d731b89 Oct 16, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23957-invite-role-options-per-caller branch October 16, 2026 21:07
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/l tests tooling

Projects

None yet

2 participants