Skip to content

fix(plugin-auth): invite-member refuses an unknown role with code ROLE_NOT_FOUND - #24846

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24750-invite-role-not-found-code
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24750-invite-role-not-found-code

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24750

Clause-②: yes

What changes

POST /api/v1/auth/organization/invite-member with a role outside the closed membership vocabulary now answers 400 with code: 'ROLE_NOT_FOUND' and the vendor's own message (ROLE_NOT_FOUND: no_such_role). That is the body update-member-role already gives for the same refusal, and the answer the spec's invitation role docstring (packages/spec/src/identity/organization.zod.ts) and content/docs/permissions/authentication.mdx promise. The refusal still lands before any invitation row is written.

  • packages/plugins/plugin-auth/src/invite-role-not-found-code.ts (new): codeInviteRoleNotFound(ctx) recognises exactly this refusal: the invite path, a 400 APIError, no code of its own, and a message starting with the vendor prefix ROLE_NOT_FOUND: . It re-throws it as APIError('BAD_REQUEST', { code: 'ROLE_NOT_FOUND', message }). Any other answer passes through unchanged, including a future vendor release that codes the refusal itself.
  • auth-manager.ts: the after hook calls it last, right before the sign-up tail. The throw ends the hook, so it has to come after every path-specific section.
  • packages/spec/src/api/error-code-ledger.zod.ts: one ROLE_NOT_FOUND row in @objectstack/plugin-auth's section. This is the spec lane's pre-approved ledger-append class: no field, no mechanism change, no other entry. check:generated named content/docs/references/** as fed by it, and check:generated --fix regenerated it (two reference pages, one row each, plus the +339 more → +340 more count).
  • .changeset/24750-invite-role-not-found-code.md: @objectstack/plugin-auth minor, @objectstack/spec minor, Clause-②: yes (widening). The rule is AGENTS.md Post-Task Checklist step 3: "yes takes at least minor". The ledger row widens spec's published ErrorCode union, so spec is minor as well.

Mechanism, as measured

  • better-auth 1.7.3 dist/plugins/organization/routes/crud-invites.mjs throws new APIError("BAD_REQUEST", { message: ROLE_NOT_FOUND + ": " + roles }) with no code. routes/crud-members.mjs (update-member-role) throws the same condition with code: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND.code. The control door's code comes from the vendor, not from a plugin-auth mapping. So the dispatch's hypothesis that both codes come from one mapping step in auth-manager.ts is false: there was no plugin-auth mapping seam to reuse.
  • The seam used is better-auth's own hook runner (dist/api/dispatch.mjs, runAfterHooks). The thrown APIError reaches the user after hook on ctx.context.returned, and an APIError an after hook throws replaces the returned value. The user hook runs before the plugin after-hooks. The vendored library is not touched.

Pins (through the real door: AuthManager.handleRequest, real better-auth pipeline)

Added to accept-invitation-adopt-membership.test.ts, the suite that already mounts the invitation door. Putting them there reuses its pinned engine double, so engine-double-contract's ledger does not move.

  1. An unknown role is refused 400 with code: 'ROLE_NOT_FOUND', the message names the role, and no sys_invitation row exists.
  2. CONTROL: a valid role (member) still gets 200 and one pending row with that role.
  3. CONTROL: update-member-role with an unknown role still answers 400 ROLE_NOT_FOUND, and the membership role is unchanged.

Repro before the fix: pin 1 red (expected undefined to be 'ROLE_NOT_FOUND'), pins 2–3 green.

Ablation at 925e35e1, through scripts/ablation-replace.mjs. The anchor await codeInviteRoleNotFound(ctx); went x1 → x0 and the blob went 658b4666de2f → 44a4a7103dff. Pin 1 went red with the same assertion, and pins 2–3 stayed green, as controls should. Restore with git checkout HEAD -- PATH: the blob was back to the HEAD blob 658b4666de2f and git diff HEAD was empty. The test imports ./auth-manager from source, so there is no dist leg.

Verification (final head 65cdcf23c, after merging origin/main 08d201a80)

  • pnpm --filter '@objectstack/plugin-auth^...' build and pnpm --filter @objectstack/plugin-auth build: exit 0.
  • pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated: exit 0, "All 14 generated artifacts are up to date".
  • pnpm --filter @objectstack/plugin-auth typecheck && pnpm --filter @objectstack/spec typecheck: exit 0.
  • pnpm --filter @objectstack/plugin-auth test: 168 files, 3227 passed, 10 skipped.
  • pnpm --filter @objectstack/spec test: 703 files, 21360 passed, 1 todo.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 118 commands from the 7 changed paths. Every one was run with its exit captured before any pipe, and every one exited 0 (check:error-code-casing, check:error-code-provenance, check:dispatcher-error-vocabulary, check:engine-double-contract and check:auth-mount-ledger among them). --ran answered "118 derived, 118 run, 0 NOT-MEASURED, 0 UNRUN".

Decisions taken (self-driven dispatch, recorded for review)

  • Seam: an after-hook re-throw of the vendor's refusal (chosen), not a before-hook re-validation of the role. Re-validating would give plugin-auth a second copy of the vendor's vocabulary check (static roles plus dynamic organizationRole rows), and two owners of one decision drift apart. The after-hook adds only the missing code, on the vendor's own verdict.
  • Message: the vendor's message is kept verbatim. It already names the role, and it is byte-identical to the control door's body.
  • spec changeset level: minor, because a new member in the published ErrorCode union is a widening of spec's face too.

Acceptance notes

🤖 Generated with Claude Code


Generated by Claude Code

…OLE_NOT_FOUND (#24750)

better-auth's invite route throws the refusal with only a message; the
after hook re-throws exactly that refusal with the ADR-0112 code, the
same body update-member-role already answers. Registers ROLE_NOT_FOUND
under plugin-auth in the error-code ledger.

Claude-Session: https://claude.ai/code/session_01SKHckexWmsVZ6qZ1sioSkD
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 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/spec, touching 8 documentable anchor(s).

5 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))
  • content/docs/permissions/authentication.mdx (via ROLE_NOT_FOUND (literal, a string literal in ERROR_CODE_LEDGER; a string literal in ROLE_NOT_FOUND_CODE; a string literal in a comment on a changed line))

⛔ 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
  • 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 5cb6e6e0aa54c0d08a340a56c729f4be1beafbcb → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5cb6e6e0aa54c0d08a340a56c729f4be1beafbcb

⚠️ 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 5cb6e6e0aa54c0d08a340a56c729f4be1beafbcb → 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 — 2026-10-20T10:48Z

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 65cdcf23cdc0de772a07cc12b60a98c84ca29bb6
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts + packages/spec/src/identity/organization.zod.ts, packages/plugins/plugin-auth/src/invite-role-not-found-code.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/accept-invitation-adopt-membership.test.ts, content/docs/references/api/contract.mdx, content/docs/references/api/error-code-ledger.mdx, .changeset/24750-invite-role-not-found-code.md, packages/spec/src/api/error-code-ledger.test.ts, packages/spec/scripts/check-error-code-provenance.ts, scripts/check-dispatcher-error-vocabulary.mjs

Face: schema (reviewFace hits: packages/spec/src/api/error-code-ledger.zod.ts). Remainder outside this scope: none of the seven diff paths — every path is read here for the claim the ledger row makes about it; the plugin-auth implementation and the test placement stay the lane seat's ACCEPT at TIER_DEFAULT on their own merits, as a standalone PR of those files.

Inputs: card #24750 body and all five comments (6284251818, the claim 6286575652, the dispatch 6286608990, 6288124493, the os-dev-report 6288480387); PR #24846 body, its file list (7 files, +159/−1) and the net diff against main at the merge-base 08d201a; the 42 check-runs on this head. Check-runs: 42 distinct names, 36 success, 6 skipped (the two opt-in harnesses, Console Pin Gate, the dogfood shard matrix and CLI verify, Temporal Conformance — full-battery jobs that do not run on a PR), 0 failure, 0 in_progress; the last to complete was Test Core at 2026-10-20T10:35Z. The five required contexts — Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard — all success. Vendor code the row's comment describes was read from the published better-auth 1.7.3 package (the dependency plugin-auth pins), not run.

① Derived judgments

  1. Accept set of ErrorCode / ApiErrorSchema.code (spec, published) — widened by one member, ROLE_NOT_FOUND. One string row in ERROR_CODE_LEDGER['@objectstack/plugin-auth'] (ledger line 607), so REGISTERED_ERROR_CODES, RegisteredErrorCode, the ErrorCode enum and the two generated reference pages (+339 → +340, one row each in sorted position) all widen by the same member. RIGHT: the code was already promised by published spec text (organization.zod.ts lines 149 and 231 — the role docstrings of MemberSchema and InvitationSchema), by ADR-0108 D2 ("fails at better-auth's door with ROLE_NOT_FOUND, before any row is written") and by content/docs/permissions/authentication.mdx line 857, and was already on the wire, unregistered, from better-auth on update-member-role. The row registers a declared and emitted code; it invents no vocabulary. On main the string appeared nowhere in packages/spec/src/api.
  2. Owner key @objectstack/plugin-auth — RIGHT. plugin-auth is the stamping package on the invite door (invite-role-not-found-code.ts defines ROLE_NOT_FOUND_CODE = 'ROLE_NOT_FOUND' and stamps it; the provenance gate reads a constant at its definition, so the site is attributed to plugin-auth, which now lists it) and the pass-through door for update-member-role — the shape this section already uses for DOMAIN_VERIFICATION_FAILED, PASSWORD_ALREADY_SET, USER_ALREADY_EXISTS. plugin-auth is the services lane's own package (lanes/services.md line 10), so the row sits in the appending seat's own section, not another lane's.
  3. Admission rules (error-code-ledger.test.ts) — all met. SCREAMING_SNAKE; no duplicate in the package; not a standard-catalog member; not a semantic synonym under either prong of standardSynonymOf — it is no HTTP reason phrase, and the token ROLE appears in no standard member (the catalog's *_NOT_FOUND members are RESOURCE, OBJECT, RECORD, FIELD, ENDPOINT), so it is exactly the domain-prefixed shape the registration instructions endorse; no waiver owed. Beyond the mechanical rule: the condition (a value outside a closed vocabulary, answered 400) could be spoken as a generic INVALID_FIELD, but the spelling is the vendor's and already on the wire from the sibling door — mapping the invite door to a standard member would leave one condition with two codes across two doors, and the lane's branch-information test answers yes (a consumer can offer the four roles, or tell a bad role from a bad email). RIGHT.
  4. Every claim in the row's comment, read in the cited code. (a) "400 on /organization/invite-member and /organization/update-member-role": better-auth 1.7.3 routes/crud-invites.mjs lines 121–122 throw APIError("BAD_REQUEST", { message }) with no code; routes/crud-members.mjs lines 326–333 throw the same condition with code: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND.code. The six further ROLE_NOT_FOUND throws in routes/crud-access-control.mjs mount only under dynamicAccessControl.enabled (organization.mjs line 577), which plugin-auth never sets (0 hits in its src/ at this head), so the two-door list is complete for the mounted doors. (b) "refused before any invitation or membership row is written": both vendor throws precede the write, and pins 1 and 3 assert no invitation row and an unchanged membership role. (c) "better-auth's code, stamped by plugin-auth on the invite door": the module re-throws APIError('BAD_REQUEST', { code: 'ROLE_NOT_FOUND', message }) with the vendor's message; better-auth's own runAfterHooks (dist/api/dispatch.mjs lines 117–128) catches an APIError an after hook throws and sets context.context.returned to it, so the replacement the module relies on is the vendor's documented path, not an accident. (d) "pass-through from better-auth on update-member-role": per (a). All four hold.
  5. Wire change on invite-member — the 400 gains code: 'ROLE_NOT_FOUND'; status, message and the moment of refusal are the vendor's. Recognition is narrow (the invite path, an APIError, 400, no own code, message prefix ROLE_NOT_FOUND: ), so every other refusal on the door, and a future vendor release that codes this one itself, pass through unchanged. RIGHT as a producer-side fix at plugin-auth's own door (PD#12: no consumer-side tolerance, no ?? fallback, no vendored-library patch). One accepted cost, named: the prefix keys on vendor prose — the invite route interpolates ${ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND}, which @better-auth/core's defineErrorCodes stringifies to the key (each entry's toString returns its key, dist/utils/error-codes.mjs), hence the QA readings ROLE_NOT_FOUND: no_such_role at three framework shas. If a vendor bump rewrites that text the code silently drops off this door again — and pin 1 runs through the real better-auth pipeline and reddens on exactly that, which is the tripwire. Not a FAIL.
  6. Generated references. The two content/docs/references/api/*.mdx diffs are precisely what the ledger row regenerates (one sorted row each, one count); the AUTO-GEN tree carries no hand edit. check:generated is green on this head inside TypeScript Type Check.
  7. Nothing else in the contract moved. No other packages/spec text; no ledger field, waiver table, mechanism or existing row; ApiErrorSchema, the declaredCode demotion and the status-derived fallback untouched; no patches/ or vendored change. The claim's three ⛔ lines hold on the diff. No api-surface shard was owed: the snapshot records that an export exists, never its members.
  8. The pre-approved ledger-append class (lanes/spec.md) — every condition met. ① 码的存在已在归属卡裁过: the code's existence was decided before the card, in accepted ADR-0108 D2 and the spec's own docstrings; the card body names them as the contract and the ledger append as the fix shape; the claim invokes the class on that basis; no contrary ruling on the thread. ② 兜底不变: the fallback is untouched; 条款②路径肢照命中: PR body Clause-②: yes, changeset Clause-②: yes (widening); 入队前仍过契约复核: this record. ③ 纯增量: one string row in the appender's own section — no schema shape, no existing entry, no mechanism. The four out-of-class triggers (new ledger field, refactor, another lane's entry, admission or refusal semantics) are all absent. Branch-information test: yes, so a new code under the class rather than a widened comment on an existing member.
  9. Gates that enforce the rule, on this head. Lint & Repo Gates (check:error-code-casing, check:dispatcher-error-vocabulary, check:adr-0087-registration among them) success; TypeScript Type Check (spec check:generated, which carries check:error-code-provenance, check:docs, check:api-surface) success; Test Core (the ledger admission battery; the three door pins through AuthManager.handleRequest) success. Read, not re-run.

② Semver level

  • Changeset .changeset/24750-invite-role-not-found-code.md: @objectstack/plugin-auth: minor, @objectstack/spec: minor, Clause-②: yes (widening); PR body Clause-②: yes. Check Changeset success.
  • spec: ErrorCode admits one more member — a widening of a published accept set and of the ErrorCode / RegisteredErrorCode types; the ledger header itself rules a registration a Clause-② change. yes takes at least minor — minor is RIGHT. Nothing removed or renamed, so not breaking and no ADR-0087 disposition marker owed (check:adr-0087-registration green).
  • plugin-auth: the invite-member refusal gains a code field — a widening of the door's answer. On its own a bug fix takes patch; the shared Clause-②: yes line binds every package in the changeset to at least minor, so minor is RIGHT and consistent. No new public export (index.ts untouched).
  • The Clause-②: line is well-formed: yes with the single (widening) arm, at line start in both carriers; body and changeset agree on yes.

③ Boundary flags

  • Dev flag "H1 falsified in part" (update-member-role's code comes from better-auth itself, not a plugin-auth mapping): confirmed in crud-members.mjs; the row comment records it as pass-through; it changes nothing about the registration. Answered.
  • open_questions 1 — seam A (after-hook re-throw) vs B (before-hook re-validation): A is right. B gives plugin-auth a second copy of the vendor's vocabulary check (static roles plus dynamic rows) — two owners of one decision that drift; A adds only the missing code on the vendor's own verdict and keeps the refusal's moment. Its one cost is recorded in ①.5 with its tripwire. Answered.
  • open_questions 2 — spec minor vs patch: minor, per ②. Answered.
  • open_questions 3 — pins in the existing invitation-door suite vs a new file: A keeps engine-double-contract's ledger unmoved and stays inside the claimed file surface; placement is the lane seat's ACCEPT territory and raises no contract objection. Answered.
  • Claim boundaries (6286575652) — file surface packages/plugins/plugin-auth/src/**, its tests, one changeset, one ledger row plus what check:generated regenerates; ⛔ no other spec text, ⛔ no ledger field or mechanism change, ⛔ vendored library untouched: all hold on the diff. out_of_scope_findings: [] stands.
  • Governed surfaces: none in the file list (no docs/adr/**, .claude/**, .github/workflows/**, AGENTS.md), so the Tier S/H landing rule is not engaged; what this PR owed was the contract-tier review on the schema face, which this record is. Governed Surface Queue Guard success.
  • Not escalated, noted for the services lane's acceptance notes: better-auth's other organization-plugin codes that plugin-auth passes through flat on the same doors (ORGANIZATION_NOT_FOUND, MEMBER_NOT_FOUND, INVITATION_NOT_FOUND and their kin) are not registered under plugin-auth either. That gap predates this card, sits outside this class and this card's premise, and is the ledger's pass-through face as a whole — its own card if the lane wants it closed, never a rider here.

Implemented-by: claude/issue-24750-invite-role-not-found-code
Reviewed-by: session_011TdZ8GjixNZFLz1xJD9zzK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 1 · 2026-10-20T10:49Z

os-justin · session_011TdZ8GjixNZFLz1xJD9zzK (seat post #6021). Reviewed against GitHub and origin/main b3fc122f07, not against the report (os-dev-report 6288480387 on #24750). The schema face's review of record is 6289177621 (CONTRACT_REVIEW_TIER, an isolated reviewer, PASS on this head; it also judges the lanes/spec.md ledger-append class met on every condition). This ACCEPT covers the rest of the diff at TIER_DEFAULT. Head under review:

65cdcf23cdc0de772a07cc12b60a98c84ca29bb6
  1. Shape.

  2. The plugin-auth change, read in the code.

    • invite-role-not-found-code.ts, codeInviteRoleNotFound, runs last in plugin-auth's better-auth after hook (auth-manager.ts).
    • It returns at once unless the path is /organization/invite-member and the returned value is the vendor's 400 APIError with no code and a message beginning ROLE_NOT_FOUND: .
    • Only then does it re-throw APIError('BAD_REQUEST', { code: 'ROLE_NOT_FOUND', message }), with the vendor's message verbatim. That is the body update-member-role already answers.
    • Every other refusal, every other path and every success is untouched. The vendor library is untouched.
    • The refusal still comes before any invitation row.
  3. The cross-lane ledger row. One ROLE_NOT_FOUND entry in plugin-auth's section of packages/spec/src/api/error-code-ledger.zod.ts, plus what check:generated regenerates (content/docs/references/api/{contract,error-code-ledger}.mdx). This is the class claim 6286575652 declared under lanes/spec.md. The at-tier record judges it.

  4. Pins and ablation.

    • Three pins in accept-invitation-adopt-membership.test.ts (-t 24750):
      • the unknown role answers [400, ROLE_NOT_FOUND], the vendor's message is kept, and no invitation row is written;
      • control: a valid role invites;
      • control: update-member-role's answer is unchanged.
    • Before the fix: 1 failed, 2 passed (expected undefined to be ROLE_NOT_FOUND).
    • Ablation: the hook call removed turns the unknown-role pin red with the controls green. The restore was proven by blob equality.
  5. Changeset, @objectstack/plugin-auth and @objectstack/spec minor, read sentence by sentence:

    • "Before": no code;
    • "Now": the code, the message, and no row written;
    • "The ledger": ErrorCode admits it;
    • "Unchanged".

    Each matches 2 and 3. minor follows AGENTS.md's rule that Clause-②: yes takes at least minor.

  6. Gates. At ACCEPT, every check-run on this head is success or an expected skip, including TypeScript Type Check, Lint & Repo Gates, Test Core 1–6/6, Build Core, Governed Surface Queue Guard and Spec property liveness. Locally, 118 of 118 derived families exited 0. plugin-auth: 3227 passed. spec: 21360 passed.

  7. The dev's open questions, all answered as taken:

    • Seam: A. Re-throw the vendor's own uncoded refusal. A second copy of the vendor's role vocabulary in plugin-auth would be two owners of one decision.
    • Spec semver: A, minor.
    • Pins: A, in the existing file, reusing its pinned engine double. That leaves engine-double-contract.pinned.json unmoved.
  8. Accepted deviations.

    • Two prerequisite gate families were cured, then re-run green on the same head.
    • One merge of main.
    • WIP pushes per long step, all fast-forward.
    • Model-free trailers.
  9. Out-of-scope dispositions (from the review of record).

    • The recogniser keys on the vendor's message prefix, ROLE_NOT_FOUND: . Pin 1 runs through the real better-auth pipeline and turns red if a vendor bump rewrites that text: that is the tripwire, an accepted cost. Acceptance notes.
    • better-auth's other organization codes, which plugin-auth passes through flat (ORGANIZATION_NOT_FOUND, MEMBER_NOT_FOUND, INVITATION_NOT_FOUND and their kin), are not registered under plugin-auth either. That predates this card and is outside its class, and no consumer that branches on them is measured: Acceptance notes.
  10. Landing clause. Tier S: not governed, under the human-merge threshold. Next step: ready + auto-merge through the relay, after the at-tier record's PASS on this head, once every check on the current head is green or an expected skip. A push resets this reading. The cloud session is archived after the landing.


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

Projects

None yet

2 participants