Skip to content

feat(spec): register the invitation role cap refusal codes under plugin-auth - #24092

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-24082-invite-role-cap-codes
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-24082-invite-role-cap-codes

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24082
Clause-②: yes (widening)

What

Registers three @objectstack/plugin-auth codes in packages/spec/src/api/error-code-ledger.zod.ts, one per refusal the invitation role cap (packages/plugins/plugin-auth/src/invitation-role-cap.ts) can return. Today auth-manager.ts throws all three as a bare APIError('FORBIDDEN', { message }) from beforeCreateInvitation (:3416–:3423), so the Invite User dialog cannot branch on them.

Code Producer path at 8a83cebc15 Status plugin-auth pairs it with
INVITE_ROLE_ABOVE_ISSUER invitation-role-cap.ts:186–192: the requested role grades above the issuer's own 403
INVITE_ISSUER_MEMBER_ONLY invitation-role-cap.ts:194–199: an issuer below admin grade asks for anything but a single plain member 403
INVITE_ISSUER_UNVERIFIED invitation-role-cap.ts:175–181: the issuer's grade is unresolved (resolveMembershipRole answered null) 403, fail-closed

Registration only, ahead of the producer (the FLOW_INPUT_SCHEMA_INVALID split shape the ledger test already records). #23945 stamps these codes in plugin-auth; that card is not closed by this PR.

Measurements

  • The set is exactly three. invitationRoleCapFailure has three non-null returns (lines 175, 186, 194) and no other refusal; its one caller (auth-manager.ts:3416) turns each into the same FORBIDDEN. Path 2 is reached only by an issuer below admin grade: an admin or owner passes grantTableAdmits for every role except one that outranks them, which is path 1.
  • Names under the synonym gate. standardSynonymOf on the built dist returns undefined for all three (INVITE is a token no standard member carries), and ErrorCode.safeParse accepts each. No STANDARD_SYNONYM_WAIVERS entry. Named in the existing INVITE_* family, for the condition rather than the status.
  • Condition 3 is fail-closed, and its comment says so. resolveMembershipRole (auth-manager.ts:7612) answers null for four inputs: missing keys, no engine, no membership row, or a lookup that threw. The cap cannot tell them apart, so the code's meaning is "could not be established" and it pairs with 403, fail-closed. Whether 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 should split the thrown-lookup case onto the standard SERVICE_UNAVAILABLE (as the slug guard's read fault does, auth-manager.ts:1333) is noted below for that card. It is not decided here.

Files

Verification (head 44818e2e6d)

  • pnpm --filter @objectstack/spec build: exit 0. Then exec vitest run src/api/error-code-ledger.test.ts src/api/api-error-code-type.test.ts: 2 files, 24 tests passed. typecheck: exit 0.
  • pnpm --filter @objectstack/spec check:generated: exit 1 before regeneration (check:docs stale), exit 0 after --fix.
  • node scripts/pm/dispatch-gates.mjs --commands: 111 families derived, all run and reconciled with --ran (111 derived famil(ies) accounted for — 109 run, 2 NOT-MEASURED). Among them check:error-code-casing, check:dispatcher-error-vocabulary, check:error-code-provenance, check:api-surface and check:docs all exit 0.
  • NOT MEASURED locally: check:dual-build-cjs-loads and check:lean-entry-closure (exit 3, PREREQUISITE NOT MET: they need a whole-workspace build). Both are left to CI's Lint & Repo Gates.
  • ESLint (--no-inline-config) over both changed .ts files: exit 0.

Acceptance notes


Generated by Claude Code

…der plugin-auth

INVITE_ROLE_ABOVE_ISSUER, INVITE_ISSUER_MEMBER_ONLY and INVITE_ISSUER_UNVERIFIED
join the ledger ahead of their producer, so the plugin-auth half can stamp
them on the cap's refusals instead of a bare FORBIDDEN.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 4 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-handling-server.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/kernel/contracts/data-engine.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))

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

  • content/docs/releases/v17/17-0.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/releases/v17/17-1.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/releases/v17/17-4.mdx (via ERROR_CODE_LEDGER (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

Coarse fallback — 142 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 be076dad28a0d35cefc7f76cd06950620cb8b81b → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 be076dad28a0d35cefc7f76cd06950620cb8b81b → 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: 44818e2e6df0671bf0b55140423ea2327e6e3943
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts + packages/plugins/plugin-auth/src/invitation-role-cap.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/spec/src/identity/invitation-role-grant.ts, packages/spec/src/api/errors.zod.ts, packages/spec/src/api/error-code-ledger.test.ts, content/docs/references/api/contract.mdx, content/docs/references/api/error-code-ledger.mdx, .changeset/24082-invite-role-cap-codes.md, .github/workflows/lint.yml, .github/workflows/ci.yml

Inputs: card #24082 (body, claim 6226978922, dev report 6227435484); parent #23945 (body, comments 6217250590, 6226340273, 6226946317); PR #24092 (body, five-file list, net diff 8a83cebc15..44818e2e6d, where 8a83cebc15 is the merge-base with origin/main); the head's check-runs, read at 2026-10-17T01:03Z. Everything was read with git show / git grep at the sha and unauthenticated REST GETs. Nothing was built, run or re-run.

① Derived judgments

Gate verdicts on this head (40 check-runs). 34 are success, 5 are skipped by their path filters or opt-in (Console Pin Gate, Dogfood Verify CLI, the sharded Dogfood Regression Gate matrix, Packed-tarball smoke (opt-in), Temporal Conformance (live PG + MySQL)), and 1 is still in progress: Test Core (3/6), after a bounded wait. Its verdict is NOT read by this record. The other five Test Core shards are success. Tier S still needs every check green before the queue, so the seat reads 3/6 before landing. The gates this change rests on: check:error-code-casing, check:dispatcher-error-vocabulary and check:error-code-provenance (lint.yml Lint · docs & code guards, success); check:docs (Type Check · source gates, success); check:api-surface (Type Check · consumer gates, success); check:dual-build-cjs-loads and check:lean-entry-closure (ci.yml Build Core, success); Check Changeset, Governed Surface Queue Guard, Spec property liveness, Build Docs and Lint · PM dispatch-gates, all success.

The accept-set change is exactly three strings under @objectstack/plugin-auth. The ledger hunk is +6/−0, all inside the '@objectstack/plugin-auth' array: a three-line block comment plus three rows, between INVITE_EMAIL_FAILED and INVALID_REQUEST. ERROR_CODE_LEDGER feeds REGISTERED_ERROR_CODES and then ErrorCode, the union ApiErrorSchema.code validates against. That union gains exactly INVITE_ROLE_ABOVE_ISSUER, INVITE_ISSUER_MEMBER_ONLY and INVITE_ISSUER_UNVERIFIED. None of the three occurs anywhere at the merge-base (git grep: 0 hits), so none is a duplicate or a re-registration. No existing row is edited or moved, and no other owner key is touched. STANDARD_SYNONYM_WAIVERS and PROVENANCE_WAIVERS are unchanged, and so is the standard catalog. The generated contract.mdx enum count moves from +332 more to +335 more: three, exactly.

Each name against its producer. The producer is invitationRoleCapFailure (invitation-role-cap.ts :159–:200). It has exactly three non-null returns, and every other exit returns null: :166 (no role), :167 (plain member) and :183 (the grant table admits the request). Its one caller is beforeCreateInvitation (auth-manager.ts :3415–:3423), which throws every non-null return as new APIError('FORBIDDEN', { message }). So the 403 that all three comments state is the status the producer answers today. It is also the status #23945 keeps when it stamps a code on that same throw.

Code Path Reached when Comment vs code
INVITE_ISSUER_UNVERIFIED :175–:181 orgRoleGrade(issuerRole) === GRADE_UNRESOLVED: the issuer's role value parses to no role matches (condition 3 below)
INVITE_ROLE_ABOVE_ISSUER :186–:192 the grant table does not admit the request, and orgRoleGrade(requested) > issuerGrade matches. Live-measured on #23945: a delegated issuer inviting as admin gets 403
INVITE_ISSUER_MEMBER_ONLY :194–:199 the grant table does not admit the request, and the requested grade is at most the issuer's matches. Live-measured on #23945: a delegated issuer inviting as delegated_admin gets 403

The three are exactly the set: no fourth path, and none unreachable. I read the paths against the grant table (invitation-role-grant.ts :69–:86). owner takes [owner]. admin, delegated_admin and any role the table does not list take [owner, admin]. member takes 'any'. So an admin-grade issuer is refused only when it asks for owner without being an owner itself. That is grade 3 against grade 2, which lands on path :186. Path :194 is therefore reached only by an issuer below admin grade, as the INVITE_ISSUER_MEMBER_ONLY comment says. Its precondition (the requested grade is at most the issuer's, which is member) is the comment's "does not outrank theirs". Path :175 was not triggered live (#23945 says so). It is reachable in the code, though: orgRoleGrade(null) is 0, and resolveMembershipRole returns null from four exits (below). The hook's other refusals are the ADR-0090 D12 placement gate, not the role cap: the placement-runtime FORBIDDEN (:3429–:3436) and assertIssuable (:3439). The card scopes them out by its own text, and they hide no fourth cap path.

Condition 3's wording holds. resolveMembershipRole (auth-manager.ts :7612–:7633) returns null in four cases:

  • missing keys (:7618);
  • no engine (:7620);
  • no usable row (:7629): none was found, or every row's role is empty or not a string (filtered at :7627–:7628);
  • a lookup that threw (:7630–:7631).

The comment's main clause, "the issuer's sys_member role could not be established", covers all four. Its parenthetical, "no row, or the lookup did not answer", summarises them: "no row" is :7629, and "did not answer" is :7620 and :7631. It leaves out :7618, which an invitation that reaches the hook cannot practically produce: better-auth hands the hook a session user and an organization. The parenthetical is accurate for what it names and does not claim to be complete, so I accept it. "fail-closed" and "may confer nothing beyond member" match the file's own header (:40–:43) and path :175.

Synonym gate (#8211). None of the three is an HTTP reason phrase, so the first test in standardSynonymOf cannot match. Its second test needs every word of the code to appear in one standard code's name, but no StandardErrorCode member carries INVITE. (errors.zod.ts has no INVITE, ISSUER or UNVERIFIED.) So all three return undefined, no waiver is owed, and none was added. The names follow the header's registration rule: a domain prefix, inside the existing INVITE_* family, naming the condition and not the status.

#16404, "door or no door". The ledger is the published face, so registering the codes widens it. The rows land AHEAD of their producer: no packages/** source stamps them yet, so the provenance gate passes with nothing to check. The test file already records this split shape, for FLOW_INPUT_SCHEMA_INVALID, whose producer later landed. That makes it admissible; see ③ for the window before the producer lands.

The added test is a real pin. The case at error-code-ledger.test.ts :234–:244 checks three things for each code, by value: ErrorCode.parse accepts it, it is a member of '@objectstack/plugin-auth', and standardSynonymOf returns undefined. It fails on a removal, a rename, a move to another owner, or a later standard code that would shadow one of them. Those cover the accept-set this PR changes, and #23945 will assert against the same strings. It cannot pin what a comment means, and it does not claim to. It is also not the "test pinned to a producerless code" the header warns about, because it pins registration, not emission.

The regenerated docs are generated, with no hand edits. Both pages carry the AUTO-GENERATED — DO NOT EDIT banner. Their hunks are the three bullets, each in sorted position, plus the +335 more count, and nothing else. check:docs (build-docs.ts --check) fails on any byte that differs from the generator's output, and it is success on this head. The docs-drift bot flagged four hand-written pages, but they name ERROR_CODE_LEDGER only. None lists an INVITE_* code, so no docs update is owed.

② Semver level

minor on @objectstack/spec with Clause-②: yes (widening) matches what ships. The PR body and the changeset both carry that line. @objectstack/spec is published (publishConfig.access: public). After this PR, ErrorCode and ApiErrorSchema.code accept three more strings, and nothing is removed or renamed, so the change is a widening. AGENTS.md says a yes takes at least minor. It is not (narrowing), so it is not breaking and needs neither a migration note nor an ADR-0087 marker.

No other package owes a changeset: plugin-auth source is untouched, and the changeset says so ("still throws FORBIDDEN until #23945"). The changeset's three meaning lines agree with the ledger comments. Its condition-3 line says "could not be established" and does not mention a read fault, so the CHANGELOG text stays true whichever way question 1 is decided. Check Changeset is success.

③ Boundary flags

Dev deviations, each answered:

  1. A test case was added beyond the claim's file surface. Accepted. It is one spec-lane file, the report declares it, it follows the file's own registered-ahead precedent, and it pins exactly the accept-set change.
  2. The gate battery was partly measured at 146a9615. Superseded: the check-runs on this head answer every family (see the gate verdicts in ①).
  3. git fetch --unshallow origin main. A local environment act with no effect on the diff. Not a review matter.
  4. check:dual-build-cjs-loads and check:lean-entry-closure were not measured locally. Answered on this head. Both run in ci.yml Build Core (:2643 and :2667), which is success. The report and the PR body say they run in Lint & Repo Gates; that label is wrong, but nothing turns on it.

open_questions[1]: should a thrown lookup answer 503 SERVICE_UNAVAILABLE instead of 403 INVITE_ISSUER_UNVERIFIED?

Ruled: this card is right to register one code now and leave the split to #23945. Covering a read fault in the comment is not a wrong contract sentence that must change before landing. Four reasons:

What this ruling hands to #23945 (escalated, not blocking):

open_questions[2], the names: A, as landed. Each name states its condition, carries the domain prefix the header asks for, sits in the existing INVITE_* family, and clears the synonym gate with no waiver. They are also the exact strings #23945 will assert by value. There is no ground to rename.

Window before the producer lands (escalated to the seat). Until #23945 stamps these codes, the three rows are what the header's "Retiring a code" section describes: "a registered-but-unemittable row stays green forever". No gate fails on that. Admission checks casing, duplication and shadowing, and the provenance gate only checks that a code which is stamped is also registered, not the reverse. This is acceptable as the recorded split shape. When this PR lands, #23945 (pm:blocked on this card) should be released. If #23945 is abandoned, these rows come out.

Implemented-by: claude/issue-24082-invite-role-cap-codes
Reviewed-by: session_01HjR8RUwTPQ3TgbfKihmxp9

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · PR #24092 @ 44818e2e6d (#24082) · domain:spec seat 1 · os-elon-musk · session session_01HjR8RUwTPQ3TgbfKihmxp9 · 2026-10-17T01:10Z

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

  • Shape: Draft, base main. The first line is Fixes #24082, the body's only closing keyword, then Clause-②: yes (widening), matching claim 6226978922. 5 files, +40/−1. Not governed (check-governed-merges.mjs --pr 24092). Assignee os-elon-musk.
  • What it lands: ErrorCode gains exactly three @objectstack/plugin-auth codes, one for each refusal invitationRoleCapFailure can return (invitation-role-cap.ts :175, :186, :194; its one caller throws each as FORBIDDEN at auth-manager.ts about :3422):
    • INVITE_ROLE_ABOVE_ISSUER: the requested role grades above the issuer's own;
    • INVITE_ISSUER_MEMBER_ONLY: an issuer below admin grade may invite only as a plain member;
    • INVITE_ISSUER_UNVERIFIED: fail-closed, the issuer's membership could not be established.
      The PR also adds a value pin in error-code-ledger.test.ts, the two reference pages check:generated --fix regenerated, and a minor changeset. No existing row, owner key, waiver or standard-catalog member changed.
  • Contract review: PASS 6227714971 on this head (CONTRACT_REVIEW_TIER). The three paths are exactly the set. Each comment matches the code, including the 403 pairing and condition 3's four null exits from resolveMembershipRole. standardSynonymOf returns undefined for all three, so no waiver is needed. The .mdx pages are generator output only. minor with Clause-②: yes (widening) is right.
  • The report's open questions, the seat's calls (both confirmed by the review):
  • Deviations: the value pin widened the claimed surface by one test file, following the file's own "registered ahead of its producer" precedent: accepted. The gate battery was partly measured before the docs regeneration, with the affected gates re-run after: accepted. Two whole-workspace families were not measured locally and are green in CI's Build Core: accepted.
  • CI on this head: 41 check-runs, all completed: 36 success, 5 skipped by their filters, 0 failed. Test Core (3/6) and the Test Core aggregate finished after the review was posted.

At landing, this seat tells #23945 its blocker has landed, with the review's points for that card:

  • it settles open question 1 on the record. If it splits, it narrows the INVITE_ISSUER_UNVERIFIED comment in the same landing and declares that packages/spec path;
  • it places the no-engine exit (auth-manager.ts about :7620);
  • it says whether path :175 still has a reachable input;
  • until it stamps the codes, the three rows have no emitter.

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


Generated by Claude Code

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/s tests tooling

Projects

None yet

2 participants