Skip to content

fix(plugin-auth): the invitation role cap's refusals carry their error codes, and a membership read fault answers 503 - #24238

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-23945-invite-role-cap-codes
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-23945-invite-role-cap-codes

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23945

Clause-②: no

What changes

The invitation role cap in beforeCreateInvitation refused with 403 and a message, but no code. better-auth's own owner refusal on the same endpoint (POST /api/v1/auth/organization/invite-member) carries one. Each of the cap's three refusals now carries the code #24082 registered for it under @objectstack/plugin-auth. A membership read that throws now answers as a store outage, not as a refusal.

the cap's branch (invitation-role-cap.ts) status body code
the requested role grades above the issuer's own 403 INVITE_ROLE_ABOVE_ISSUER
an issuer below admin grade asked for a role other than member 403 INVITE_ISSUER_MEMBER_ONLY
the issuer's role was not established (an answered read with no usable row, or no data engine) 403 INVITE_ISSUER_UNVERIFIED
the issuer's sys_member read threw (was 403 with no code, through the branch above) 503 SERVICE_UNAVAILABLE
  • invitationRoleCapFailure() returns { code, message } from each branch instead of a bare string. The three codes are exported constants typed RegisteredErrorCode, so a removed or misspelled ledger row fails tsc (reverse-verified below). The verdicts do not change: the same invitations are admitted and refused.
  • The hook throws new APIError('FORBIDDEN', { message, code }), the body shape remove-member and the vendor's owner refusal already use. better-call passes body through verbatim, so code reaches the wire (measured below).
  • resolveMembershipRole used to end in catch { return null; }, which turned a thrown read into the unverified verdict. It now throws invitationRoleCapReadFaultApiError(err): 503 SERVICE_UNAVAILABLE, the driver's error on cause. This is the slug guard's shape (slugGuardReadFaultApiError), with one difference: this body carries code: 'SERVICE_UNAVAILABLE' too, so every answer from this endpoint has a code. Still fail-closed: the throw ends the endpoint, so no invitation row is written.
  • The ledger comment on INVITE_ISSUER_UNVERIFIED is reworded to what the emitter does now. The phrase "or the lookup did not answer" is gone. The comment now covers the no-engine exit and names the 503 for a read that throws. The three-line block comment above it said "today the cap throws a bare FORBIDDEN". That is false after this PR, so it is reworded too (see Deviations). Both are comment-only, and no code is added, removed or renamed.

The seat's rulings on #24082's four review points (premise measured first)

  1. Split the outage from the refusal. Premise holds. The cap's read path could not tell a thrown read from an empty answer, and the place that merged them is the cap's own read: resolveMembershipRole's catch { return null; } (auth-manager.ts, the last line of the method on 6e43b7bc3f). Its one caller is beforeCreateInvitation (git grep resolveMembershipRole finds the definition and one call). The split is made at that read.
  2. The no-engine exit fails closed. It already returned null, which grades below a plain member, so it answers INVITE_ISSUER_UNVERIFIED (403). This PR keeps it that way and pins it. Reachability: no shipped composition reaches the hook without an engine. AuthPlugin hard-depends on ObjectQL (dependencies, requiresServices: ['data', …]), and ctx.getService('data') throws when the service is absent. The one production construction, auth-plugin.ts new AuthManager(authConfig), always passes dataEngine. A bare AuthManager built without dataEngine (an exported class, documented as usable outside the plugin) runs better-auth on its in-memory adapter and does reach the hook with nothing to read. The ledger comment covers that exit.
  3. Is the "no row" input (:175) reachable through the public door? Measured on the wire, once, with a throwaway dogfood probe that was deleted afterwards (showcase boot at 313560aef):
    • a user with a live session and no membership row, inviting as admin: 400 {"message":"Member not found","code":"MEMBER_NOT_FOUND"}. better-auth's own refusal, so the hook never ran. (The first try answered 401: ending a membership ends its sessions, so the probe signs in again first.)
    • a plain member, inviting as admin: 403 {"message":"You are not allowed to invite users to this organization","code":"YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION"}. Again before the hook.
    • The installed better-auth 1.7.3 source agrees (plugins/organization/routes/crud-invites.mjs). findMemberByOrgId, then MEMBER_NOT_FOUND, then hasPermission(invitation:create), all run before beforeCreateInvitation. The cap then re-reads the same sys_member rows in system context.
    • So INVITE_ISSUER_UNVERIFIED has no input that reaches it through this door. Getting there needs the row to vanish between the two reads. It is pinned at the unit level (auth-manager.test.ts, "an unresolvable issuer membership fails CLOSED", which says why in a comment). The two reachable codes are pinned on the wire in the dogfood file.
  4. All three codes get their emitter here. Each refusal carries its code through better-auth's APIError body. check:error-code-provenance sees the three constant definitions as stamp sites listed under @objectstack/plugin-auth (exit 0, "every registered-code stamp site is listed under its own owner key").

PM mechanism hypotheses

  • auth-manager.ts threw new APIError('FORBIDDEN', { message: capFailure }), and invitationRoleCapFailure() returned a bare string from three branches. Holds on 6e43b7bc3f.
  • better-auth serialises APIError's code into the body. Holds, and a custom code travels the same way. better-call 1.4.0 InternalAPIError stores body verbatim. APIError.from (the vendor's owner refusal) only builds { message, code } from its error record. Measured on the wire: INVITE_ROLE_ABOVE_ISSUER and INVITE_ISSUER_MEMBER_ONLY arrive as body.code (dogfood, below).
  • delegated-admin-invite.dogfood.test.ts already drives these refusals. Holds for admin. It had no delegated_admin-by-delegate case, so one is added.

Clause-② measured

no. On the built entry declarations: packages/plugins/plugin-auth/dist/index.d.ts and index.d.mts carry 0 occurrences of invitationRoleCapFailure, InvitationRoleCapRefusal and the three *_CODE constants. The control class AuthManager appears 1 time, and rate-limit-storage.d.ts has 0. invitation-role-cap.ts is not re-exported from src/index.ts. The only declaration text that moved is the doc comment on the private resolveMembershipRole. For @objectstack/spec, check:api-surface answered "public API surface + factory signatures unchanged". The ledger edit is a comment, so ErrorCode accepts the same strings. Nothing widens an accept-set: no invitation is newly admitted, and no code is added.

Tests (at 313560aef)

  • pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 (the whole package): Test Files 158 passed (158), Tests 3096 passed | 10 skipped (3106).
  • pnpm --filter @objectstack/plugin-auth typecheck: exit 0. It includes check:test-typecheck: "test layer compiles … 10 file(s) / 88 error(s) / 23 pinned signature(s) held".
  • pnpm --filter @objectstack/spec exec vitest run src/api/error-code-ledger.test.ts src/api/api-error-code-type.test.ts: Tests 24 passed (24). pnpm --filter @objectstack/spec typecheck: exit 0.
  • pnpm --filter @objectstack/dogfood typecheck: exit 0. tsc --listFiles lists the edited dogfood file once.
  • Dogfood on the wire, after building @objectstack/dogfood^... (66 tasks): test/delegated-admin-invite.dogfood.test.ts, Tests 5 passed, including "the same delegate issuing admin is REFUSED" (body.code INVITE_ROLE_ABOVE_ISSUER) and the new "the same delegate minting another delegate is REFUSED as member-only" (body.code INVITE_ISSUER_MEMBER_ONLY, no sys_invitation row).
  • ESLint --no-inline-config over the seven changed .ts files: exit 0.

Refusal tests assert the envelope (statusCode, status, body.code), not a bare throw. The cap's unit tests now assert codes rather than prose. The parity oracle in invitation-role-grant-offer.test.ts spells the codes as literals, so its grid of over 100 refusals also pins which branch answers which code.

Ablation (one-time, through scripts/ablation-replace.mjs with restore proven by blob hash and an empty git diff HEAD). The unit tests import ./auth-manager from source, so no dist/ rebuild applies:

  • A: drop code: refusal.code from the throw. The run went red with Tests 5 failed | 3 passed: the admin, member-only, unresolvable, no-engine and before-placement cases. The two admit cases and the 503 case stayed green. Restored, blob 698973c2e6c6 equal to HEAD.
  • B: replace the read fault's throw with return null (the old swallow). The run went red with Tests 1 failed | 7 passed: the 503 case failed with "expected 403 to be 503". Restored, blob equal to HEAD.

Reverse verification of the RegisteredErrorCode annotation: misspelling the first constant as INVITE_ROLE_ABOVE_ISSUER_TYPO turns tsc --noEmit red with TS2820 … Did you mean '"INVITE_ROLE_ABOVE_ISSUER"'?. Restored.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on this diff derives the same 91 commands as the seat's list. All 91 were run locally at 313560aef, each exit captured before any pipe. 90 exited 0. pnpm check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: it needs a whole-workspace build), so it is NOT MEASURED locally and left to CI's Build Core. --ran: "✓ dispatch-gates --ran: 91 derived famil(ies) accounted for — 90 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)." The PR's check-runs had not reported when this was written.

Acceptance notes

  • The organization slug guard's 503 body carries no code (slugGuardReadFaultApiError). That is the same gap this PR addresses for the cap's read fault, on update-organization. It is reachable only through a store fault, and no public door produces one on demand, so it is noted here and not filed. Carrier: none.
  • packages/spec/src/api/error-code-ledger.test.ts still describes the cap's refusals as "a bare FORBIDDEN today" and names the case "registered ahead of their plugin-auth producer". That is history once this lands. It is not on this PR's surface, so it is left alone. Carrier: none.
  • docs/qa/platform-checklist/areas/identity-auth.json identity-auth.invitation-scope-gates scores the admin-role refusal as "non-2xx". It could now name INVITE_ROLE_ABOVE_ISSUER. Carrier: none.

Deviations


Generated by Claude Code

…r codes; a membership read fault answers 503

invitationRoleCapFailure() now returns { code, message } from each of its
three refusal branches (INVITE_ROLE_ABOVE_ISSUER, INVITE_ISSUER_MEMBER_ONLY,
INVITE_ISSUER_UNVERIFIED), and beforeCreateInvitation throws the code beside
the message on better-auth's APIError, the shape the vendor's own owner
refusal on the same endpoint uses.

resolveMembershipRole no longer swallows a thrown sys_member read into the
unverified verdict: a read fault throws 503 SERVICE_UNAVAILABLE (code in the
body, driver failure on cause). No engine and an answered-empty read stay
fail-closed as INVITE_ISSUER_UNVERIFIED. The ledger comment for that code is
reworded to what the emitter now does.

Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH
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/plugin-auth, @objectstack/spec, touching 16 documentable anchor(s).

17 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 7715d049e4438f3b656c8d77a873871d38cf130e.

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

What this run could not see
  • 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 — 147 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 7715d049e4438f3b656c8d77a873871d38cf130e → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 7715d049e4438f3b656c8d77a873871d38cf130e

⚠️ 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 7715d049e4438f3b656c8d77a873871d38cf130e → 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: 313560aefa4483bddbd243e0092b937d16d4e5fc
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/plugins/plugin-auth/src/auth-manager.test.ts, packages/plugins/plugin-auth/src/invitation-role-grant-offer.test.ts, packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts, packages/spec/src/api/errors.zod.ts, packages/spec/src/api/error-code-ledger.test.ts, packages/spec/scripts/check-error-code-provenance.ts, .changeset/23945-invite-role-cap-codes.md, .github/workflows/lint.yml, .github/workflows/ci.yml

Inputs: card #23945 (body; comments 6217250590, 6226340273, 6226946317, 6228142342, the claim 6243550870, the dev report 6244087940); #24082 (body; comments 6226978922, 6227435484, 6227800739, 6228134436) and its contract review 6227714971; PR #24238 (body, eight-file list, net diff 6e43b7bc3f..313560aef, where 6e43b7bc3f is the merge-base with origin/main); the head's check-runs, read at 2026-10-18T01:44Z. Everything was read with git show / git grep at the sha and unauthenticated REST GETs. Nothing was built, run or re-run; the two scripts/pm/ tools this record is rendered and posted with are not gates. better-call is not installed in the read tree, so its body pass-through was not re-read (bound declared in ①).

① Derived judgments

Gate verdicts on this head (41 check-runs, all completed). 35 are success, 6 are skipped by their path filters or opt-in (Build Docs, Console Pin Gate, Dogfood Verify CLI, the sharded Dogfood Regression Gate matrix, Packed-tarball smoke (opt-in), Temporal Conformance (live PG + MySQL)), none failed, and none was still in progress after a bounded wait (the six Test Core shards and Type Check · workspace reported last). All five required contexts are success: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard. The gates this change rests on, each success: check:error-code-casing, check:error-status-conformance, check:dispatcher-error-vocabulary and check:error-code-provenance (lint.yml Lint · docs & code guards, :2807, :2824, :2845, :2864); check:docs (Type Check · source gates); check:api-surface (Type Check · consumer gates); the per-package typechecks of plugin-auth, spec and dogfood (Type Check · workspace); the unit pins (Test Core, six shards); check:dual-build-cjs-loads and check:lean-entry-closure (ci.yml Build Core, :2699 and :2723 — the one family the dev could not measure locally); Check Changeset, Spec property liveness, Lint · PM dispatch-gates, Lint · ESLint. One bound on what these verdicts cover — the dogfood wire pins — is declared at the end of this section.

The ledger hunk changes no accept-set, and it is the whole schema face of this PR. The hunk is −4/+3, every line a comment, inside the '@objectstack/plugin-auth' array between INVITE_EMAIL_FAILED and INVITE_REQUIRES_EMAIL. No row is added, removed, renamed or moved; STANDARD_SYNONYM_WAIVERS, PROVENANCE_WAIVERS and the standard catalog are untouched. ERROR_CODE_LEDGER feeds REGISTERED_ERROR_CODES and then ErrorCode, and all three accept exactly the strings they accepted before. The generated reference pages carry bare names only, so no regeneration is owed; check:docs (success) and check:api-surface (success, "public API surface + factory signatures unchanged") agree. reviewFace on the eight paths returns this one file as the schema hit; the other seven are the lane seat's ACCEPT at TIER_DEFAULT, read here only where the ledger comments make a claim about them. Right.

Each comment against its emitter, read in the cited code. The producer is invitationRoleCapFailure (invitation-role-cap.ts :182–:230). It still has exactly three non-null returns, each now an object with its own constant: :198–:207 (INVITE_ISSUER_UNVERIFIED_CODE, on GRADE_UNRESOLVED), :211–:219 (INVITE_ROLE_ABOVE_ISSUER_CODE, the grant table does not admit and the requested grade outranks the issuer's) and :221–:229 (INVITE_ISSUER_MEMBER_ONLY_CODE, the remainder, reached only by an issuer below admin grade because grantTableAdmits :162 refuses every non-admin). Its one caller is still beforeCreateInvitation (auth-manager.ts :3524–:3531), which throws new APIError('FORBIDDEN', { message: refusal.message, code: refusal.code }) — 403 with the code in the body, the shape remove-member already uses in the same file (:7700–:7706) and the vendor's owner refusal on this door arrives in (the card's table).

Row The comment says The code at this head Verdict
INVITE_ROLE_ABOVE_ISSUER 403: the requested role grades above the issuer's own :211–:219, thrown 403 at :3530 matches; wire-pinned (dogfood, a delegated_admin inviting admin)
INVITE_ISSUER_MEMBER_ONLY 403: an issuer below admin grade may invite only as member :221–:229, thrown 403 at :3530 matches; wire-pinned (dogfood, a delegated_admin inviting delegated_admin, new case)
INVITE_ISSUER_UNVERIFIED 403, fail-closed: the read answered with no usable row, or the composition has no data engine; a read that throws answers SERVICE_UNAVAILABLE (503) instead :198–:207 on GRADE_UNRESOLVED; resolveMembershipRole (:7866–:7888) returns null on missing keys (:7872), no engine (:7874) and no usable row (:7887); its catch (:7880–:7882) now throws invitationRoleCapReadFaultApiError, which is APIError('SERVICE_UNAVAILABLE', { message, code: 'SERVICE_UNAVAILABLE' }) (:1369, :1383–:1394) matches; unit-pinned three ways (auth-manager.test.ts :1275 no row, :1291 no engine, :1303 a thrown read answers 503 and not the refusal's code)

The missing-keys exit (:7872) is still not named by the comment, exactly as the #24082 review accepted: better-auth hands the hook a session user and an organization, so an invitation that reaches the hook cannot practically produce it. The reworded block comment — "each thrown 403 from beforeCreateInvitation with its code (#23945)" — is true at this head and the text it replaces ("today the cap throws a bare FORBIDDEN") would be false after landing; the contract file must not describe a producer that no longer exists. Right.

The row-to-emitter link is now compiler-checked. The three constants are typed RegisteredErrorCode (invitation-role-cap.ts :74–:78, a type-only import from @objectstack/spec/api, a subpath spec exports and plugin-auth already imports in two other modules). A removed or misspelled ledger row therefore fails plugin-auth's tsc; the dev's reverse verification (TS2820) is the expected shape of that failure. This is stronger than the value pin in error-code-ledger.test.ts alone, and it is the right direction: the ledger stays the one source, the emitter cannot drift from it silently. The provenance gate (check-error-code-provenance.ts, the constdef pattern at :134, const NAME: Type = 'X') sees the three definitions as plugin-auth stamp sites, and plugin-auth's owner key lists all three → success. INVITATION_ROLE_CAP_READ_FAULT_CODE = 'SERVICE_UNAVAILABLE' (:1369) is a StandardErrorCode member (errors.zod.ts :113), not a registered row, so it is outside that gate's population and inside the dispatcher vocabulary's; check:dispatcher-error-vocabulary success. Right.

The 503 split (option B of #24082's open question 1) is right, and the card decided it on the record. Four grounds: the #21941 rule in the same file — a read that could not answer is not a verdict, so it must not wear the refusal's words — applied at the one place the two were merged (catch { return null; }, the cap's own read, one caller); the status is the standard member's own (HttpStatusErrorCodeMap 503 → SERVICE_UNAVAILABLE, errors.zod.ts :195), so check:error-status-conformance has nothing new to reconcile and passed; fail-closed holds by construction — the throw leaves the hook before better-auth writes sys_invitation, and the hook wraps the read in no catch (:3524–:3527); and the body carries code, so after this PR every answer the cap gives on POST /api/v1/auth/organization/invite-member, refusal or outage, has one — the card's own "Expected" sentence. The verdicts do not move: the parity grid in invitation-role-grant-offer.test.ts (more than a hundred cells, the oracle's admit/refuse logic unchanged, now toEqual on { code, message }) pins branch → code as well as admit → null.

Public surface of @objectstack/plugin-auth: unchanged. src/index.ts is export * over named modules; invitation-role-cap.ts is not among them, auth-manager.ts carries no export … from line, and the tsup entries are src/index.ts and src/rate-limit-storage.ts only. So INVITE_*_CODE, InvitationRoleCapRefusal and the changed return type are internal, and resolveMembershipRole is private. The other two importers of the cap module (adopt-membership.ts, last-admin-guard.ts) take orgRoleGrade / parseOrgRoles / isOrgAdminGrade, none of which changed. The PR body's zero-occurrence count on the built .d.ts is the dev's reading (dist is not built here); it is consistent with the source.

Bounds declared. (a) The pass-through of code from APIError's body to the wire was not re-read in better-call; it stands on the remove-member precedent in the same file, on the vendor's owner refusal arriving with code through the same class, and on the dev's wire reading at this head (body.code for both reachable codes). (b) The dogfood wire pins did not run on this PR: the dogfood matrix is main's full battery, the filter job skipped the three shards and the Dogfood Regression Gate aggregate passes on a legitimately skipped matrix by design. So those two pins are measured by the dev's local run (Tests 5 passed at this head) and by main's battery after landing, not by these check-runs. The unit pins run under Test Core.

② Semver level

patch on @objectstack/plugin-auth with Clause-②: no and no arm, in both carriers (PR body and changeset) — matches what ships. plugin-auth is published (publishConfig.access: public) and its dist changes, so a changeset is owed and present; a fix in a released package takes patch (Post-Task Checklist #3). Not yes: no accept set widens (the same invitations are admitted — the grid pins it), no public face widens (the three codes were registered at #24082, where the minor (widening) was paid; emitting a registered code widens nothing), and no export is added. Not (narrowing): the 403 → 503 on a thrown read is a status correction on an outage path, not a published accept set made smaller; the standard code it answers with already existed. @objectstack/spec owes nothing — comment-only, ErrorCode unchanged, check:api-surface success. @objectstack/dogfood is private. Check Changeset is success, and the two gates that read the Clause-② line (check-changeset-no-major, check-adr-0087-registration) run in Lint & Repo Gates, success. The changeset's three meaning lines agree with the ledger comments; its INVITE_ISSUER_UNVERIFIED line names the no-row and no-engine inputs and its 503 paragraph names SERVICE_UNAVAILABLE and the slug-guard precedent, so the CHANGELOG text stays true. Its closing "the status of every refusal (403)" is true because the outage is not a refusal, which the sentence before it says.

③ Boundary flags

Dev deviations (6244087940, seven), each answered:

  1. The block comment above the three rows was reworded too. Accepted. Same file, same rows, comment-only; the claimed surface was the INVITE_ISSUER_UNVERIFIED comment, and the error-code ledger: register the invitation role-cap refusal codes plugin-auth throws (grade above the issuer, a non-admin inviting a non-member role, membership unverifiable), so a client can tell them apart #24082 review's point (b) asked for exactly this narrowing in the landing that splits the fault. The old text would have been false. This file is the scope floor of this review, read whole.
  2. invitation-role-grant-offer.test.ts edited. Within the claim's "plugin-auth's own tests beside the cap"; the return type forced it, and the grid now pins more, not less. Accepted.
  3. The 503 body carries code; the slug guard's does not. Accepted as the right direction — every answer on this door now has a code. The slug guard's codeless 503 is the gap, escalated below.
  4. origin/main moved after the branch point and was not merged in. Measured: git diff --stat 6e43b7bc3f origin/main over the eight paths is empty, and over packages/spec/src/api and packages/plugins/plugin-auth whole is empty (seven commits now). The PR's CI ran on the merge ref; the queue re-verifies. Accepted.
  5. check:dual-build-cjs-loads not measured locally. Answered on this head: it runs in ci.yml Build Core (:2699, with check:lean-entry-closure at :2723), success.
  6. A probe file was created and deleted. The eight-file list carries no probe file and the branch is one commit. Accepted.
  7. The attribution trailer. The commit ends Claude-Session: plus Co-authored-by: Claude with no model identifier; the PR body's footer is the session-URL form; no model identifier anywhere in the diff. Accepted.

open_questions: none declared. Right: the four points #24082's review handed over are each decided in the PR body, and this record agrees with the decisions (next).

#24082's four points to this card (6228142342), each read:

  1. Settle the split. Settled B, on the record, with the ledger comment narrowed in the same landing and the spec path declared in the claim as a spec-lane surface. Right.
  2. Place the no-engine exit. Fail-closed as INVITE_ISSUER_UNVERIFIED, pinned (auth-manager.test.ts :1291), named in the ledger comment, and reachable only by a bare AuthManager built without dataEngine (AuthPlugin hard-depends on the data service). "Cannot check" is never "does not apply". Right.
  3. Re-measure path :175 (now :198). Measured on the wire: a non-member with a session gets better-auth's own 400 MEMBER_NOT_FOUND, a plain member its 403 YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION, both before the hook. So the code has no public-door input short of a race or a role-less row. The row stays — it ships in dist and is therefore registered ([Decision] Clause ② on an UNREGISTERED error code carried by a thrown value: #14552 landed no, #15963 lands yes, and they are the same class #16404), and the unit pin at :1275 records the reason in its comment. Accepted. One non-blocking note: the ledger row's own comment names the two inputs but not this reachability reading; a later ledger touch may add the one clause.
  4. The rows now have an emitter. The producerless window closes at landing; nothing is owed. Right.

Escalated to the owning seat, non-blocking (this review has no GitHub write beyond this comment):

  • (a) The organization slug guard's 503 body carries no code (slugGuardReadFaultApiError, :1356–:1366, update-organization). That is the same defect class this card was filed for, on another public door; by Prime Directive chore: version packages #10 and the card's own filing standard it is a card (p3 at most), not an acceptance note — "no public door produces a store fault on demand" describes the repro's cost, not the defect's status. Carrier: the services seat files it.
  • (b) error-code-ledger.test.ts :234–:237 keeps the comment "a bare FORBIDDEN today" and the case name "registered ahead of their plugin-auth producer"; both become history at landing, while the pin itself (value, owner key, synonym gate) still holds. A spec-lane comment-only follow-up, outside this PR's claimed surface. Carrier: the spec seat, on its next ledger touch.
  • (c) docs/qa/platform-checklist/areas/identity-auth.json could score the admin-role refusal by code. Carrier: the checklist's owner.

Landing. No governed surface is touched (Governed Surface Queue Guard success), so the queue needs this PASS record for the schema face plus every required context green. At the read time above every check-run had completed and every required context is success, so the owning seat may ready and arm this PR on this record; the queue re-verifies on its rebuilt generation, and the two dogfood pins are read on main's full battery after landing.

Implemented-by: claude/issue-23945-invite-role-cap-codes
Reviewed-by: session_01Nk17Z26X5DB59iQztDBADH

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-18T01:47Z · head 313560aefa. Contract review PASS 6244382751 at CONTRACT_REVIEW_TIER on this head covers the schema face (error-code-ledger.zod.ts). This ACCEPT covers the rest of the diff at TIER_DEFAULT. It lands through the merge queue once every check on this head is green.

  • Shape: a draft PR to main. Its first line is Fixes #23945, and that is the only closing keyword in the body. Clause-②: no is at a line start in the body and in the changeset (@objectstack/plugin-auth patch). Card and PR assignee: marchtian. Not governed: check-governed-merges reads 0 of 8 paths, and 373 changed lines ≤ 3000.
  • The seat's four rulings, each met as dispatched:
    1. The outage split. resolveMembershipRole's catch { return null; } was the cap's own read, and the seat confirmed its one caller is the invitation hook (auth-manager.ts:3526; the method is private). A thrown read now throws 503 SERVICE_UNAVAILABLE with the code in the body, and no invitation row is written. INVITE_ISSUER_UNVERIFIED keeps "the role was not established".
    2. The no-engine exit fails closed with INVITE_ISSUER_UNVERIFIED. It is unit-pinned, and no shipped composition reaches it, because AuthPlugin hard-depends on the data service.
    3. Reachability, measured on the wire. better-auth refuses a non-member (400 MEMBER_NOT_FOUND) and a plain member (403) before the hook, so INVITE_ISSUER_UNVERIFIED has no public-door input short of a race. It is unit-pinned, with the reason in the test.
    4. All three codes have their emitter. INVITE_ROLE_ABOVE_ISSUER and INVITE_ISSUER_MEMBER_ONLY are pinned on the wire in delegated-admin-invite.dogfood.test.ts. The constants are typed RegisteredErrorCode, so a ledger drift fails tsc.
  • The rest, read by the seat:
    • invitationRoleCapFailure returns { code, message }, and the verdicts are unchanged: the parity grid pins admit → null and branch → code.
    • beforeCreateInvitation throws APIError('FORBIDDEN', { message, code }), the shape remove-member already uses.
    • Ablated: dropping the code turns 5 cases red, and returning null on a thrown read turns 1 red (expected 403 to be 503). Restored with blob equal to HEAD.
  • Prose faces, each sentence checked against the diff: the changeset's meaning lines for the three codes and its 503 paragraph match the emitter. Its "every refusal (403)" is true because the outage is not a refusal.
  • Gates on this head: 35 checks succeeded and 6 were skipped by design (the roster set). There were no failures, and the five required contexts are green. Locally: 91 derived families, 90 exit 0, and check:dual-build-cjs-loads NOT MEASURED locally but green in CI's Build Core.
  • Accepted deviations:
    • the reworded block comment above the three ledger rows (judged in the contract review);
    • the edit to invitation-role-grant-offer.test.ts, forced by the new return type;
    • the 503 body carrying code, so every answer on this door has one;
    • origin/main past the merge base with no shared file (the queue re-verifies).
  • Out-of-scope findings, with their carriers:

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 18, 2026 01:49
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 18, 2026 01:49
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit 03ac4ec Oct 18, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23945-invite-role-cap-codes branch October 18, 2026 02:24
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