Skip to content

plugin-auth: invite-member refuses an unknown role with no code — the spec docstring and auth docs promise ROLE_NOT_FOUND, so an ADR-0112 client cannot recognise the refusal #24750

Closed

Description

@objectstack-fleet

Filing gate: ① a violation of a declared contract, with a reproduction (pm:queue class).
Reader: the domain:services lane owning packages/plugins/plugin-auth (the organization invite door) — the fix PR there, or a spec/doc PR if the lane decides the docstring is what is wrong.
Dedupe (open AND closed, semantic search on objectstack-ai/objectstack): "invite-member ROLE_NOT_FOUND error code missing" → 10 hits; a vendor-refusal variant of the same query → 11 hits. The nearest, #23945 and #24243 (both closed), cover plugin-auth's own throws, not this vendor refusal. objectstack-ai/objectui → 2 hits, none on point.

reach: measured through the public door in the 18.0 QA wave — identity-auth run #24538 at framework 5b14b7f recorded that an invite naming an unknown role is refused with no code; re-read at framework f7005cb, the code path is unchanged.
Path: identity-auth (invite-member door) · priority of the area's items: P1
QA-source: #24538 · identity-auth · observation recorded in the run's checklist-accuracy section
type: Bug

Filed by the 18.0 QA wave dispatch session session_01CfRDy7SuTfUqaTu2AEH2Hi on the maintainer's instruction there, verbatim: 「立那 2 张漏掉的缺陷卡」 (the run had recorded it as an observation, not a card).

What is wrong

POST /api/v1/auth/organization/invite-member with a role outside the closed vocabulary is refused, but the refusal carries only a message (the text ROLE_NOT_FOUND: followed by the role name) and no machine code. A client written to ADR-0112's rule — branch on error.code, never on prose — can never recognise it.

The contract says it is coded:

  • packages/spec/src/identity/organization.zod.ts — the invitation schema's role docstring: "A name outside it is refused at the door (ROLE_NOT_FOUND) before any invitation row exists."
  • content/docs/permissions/authentication.mdx — the same refusal named as ROLE_NOT_FOUND.
  • ADR-0112 — refusals carry a code from the error-code vocabulary.

Reproduction

  1. Stock showcase boot with --seed-admin; sign in as the admin.
  2. POST /api/v1/auth/organization/invite-member with body {"email":"qa-invitee@example.com","role":"no_such_role"}.
  3. Expected: a 400 whose error carries code: "ROLE_NOT_FOUND". Actual: a 400 whose error carries the message ROLE_NOT_FOUND: no_such_role and no code.

Control: POST /api/v1/auth/organization/update-member-role with an unknown role answers with a code — the same vendor family codes it on that door.

Mechanism

The refusal is thrown by the vendored better-auth organization plugin (its invite route throws a BAD_REQUEST APIError with only a message), and plugin-auth passes it through without mapping it to a code; ROLE_NOT_FOUND is not in the error-code ledger. Expected fix shape: plugin-auth maps this vendor refusal to ROLE_NOT_FOUND (registering it in the ledger), so the door answers what the spec docstring promises — the spec is the baseline when spec and code disagree.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Still reproduces at 580fd4e · QA-source: #24779 · closed-card / open-card regression section (identity-auth observation carried from #24538)

    Admin cookie session on a fresh stock showcase boot (objectstack dev --seed-admin, framework 580fd4ed): POST /api/v1/auth/organization/invite-member {"email":"qa-invitee-1@example.com","role":"no_such_role"} → 400 {"message":"ROLE_NOT_FOUND: no_such_role"} — still no code key; reproduced twice.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 7 · 2026-10-20T08:14Z
    Session: session_011TdZ8GjixNZFLz1xJD9zzK
    Account: os-justin (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24750-invite-role-not-found-code
    Worktree: objectstack-issue-24750 (in the cloud session's own container)
    Domain: domain:services
    Seat: domain:services#1
    File surface: packages/plugins/plugin-auth/src/** (where the vendored organization plugin's invite refusal reaches plugin-auth's door, mapped to ROLE_NOT_FOUND), its tests, and one new .changeset/24750-*.md. Cross-lane, under the spec lane's pre-approved ledger-append class (lanes/spec.md: the code's existence is already declared, here by the spec's own docstring at identity/organization.zod.ts:149 and :231; the fallback is unchanged; a pure append in this package's own section of the ledger): one ROLE_NOT_FOUND entry in packages/spec/src/api/error-code-ledger.zod.ts, plus what check:generated regenerates from it. ⛔ No other packages/spec text, ⛔ no ledger field or mechanism change, ⛔ not the vendored library (stop on breach; explain in the report)
    Container & model: M, mode:cloud, model: default (opus), quoting this dispatch's --tier output "no path-derived mandate: the surface hits none of the 3 declared glob(s) … floor sonnet · default opus · ceiling fable"; cloud because the surface names packages/spec/src/** and another package (build-heavy, one container per card); the review owed is CONTRACT_REVIEW_TIER (the published ledger moves), run by an isolated at-tier reviewer before the queue
    Clause-②: yes
    Responsibility: plugin-auth passes the vendored organization plugin's unknown-role refusal through with no code, while the spec's invitation docstring and the authentication docs promise ROLE_NOT_FOUND | no platform path covers it: plugin-auth's door is the only producer of this refusal | every ADR-0112 client of POST /api/v1/auth/organization/invite-member
    Thread-read: 6284251818
    Serial constraints cleared: PR #24795 (#24739, this seat) landed as 25e16ab00c, which frees plugin-auth's auth-manager.ts and auth-plugin.ts. No open PR touches plugin-auth's auth-manager.ts or auth-plugin.ts, packages/spec/src/api/error-code-ledger.zod.ts, identity/organization.zod.ts or authentication.mdx (all 12 open PRs' files read). The epic's declared territory (#15194) names none of them.

    Readings taken on origin/main c56115bf96.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Dispatch · domain:services seat 1 · 2026-10-20T08:16Z · claim 6286575652. mode:cloud session session_01SKHckexWmsVZ6qZ1sioSkD (title "⚡ services #24750 invite ROLE_NOT_FOUND code"), outcome branch claude/issue-24750-invite-role-not-found-code, the default tier. The order carries the spec's own promise of ROLE_NOT_FOUND (identity/organization.zod.ts:149, :231) as the acceptance, and confines the ledger change to one append in plugin-auth's section, under the spec lane's pre-approved ledger-append class. A CONTRACT_REVIEW_TIER review is owed before the queue. The dev delivers a draft PR and an os-dev-report comment here.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Still reproduces at 307ce4c · QA-source: #24841 · observed beside the clauses (invite-member with role:"no_such_role" → 400 {"message":"ROLE_NOT_FOUND: no_such_role"}, no code, twice)


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24750,
      "status": "done",
      "branch": "claude/issue-24750-invite-role-not-found-code",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/24846",
      "session": "session_01SKHckexWmsVZ6qZ1sioSkD",
      "premise_still_valid": true,
      "summary": "Reproduced through the real door: invite-member with role no_such_role answered 400 with no code, while update-member-role answered 400 ROLE_NOT_FOUND. New plugin-auth module invite-role-not-found-code.ts is called last in the better-auth after hook. It recognises exactly this vendor refusal (invite path, 400 APIError, no code, message prefix 'ROLE_NOT_FOUND: ') and re-throws it as APIError BAD_REQUEST with code ROLE_NOT_FOUND and the vendor's message verbatim. That is byte-identical to update-member-role's body, and the refusal still comes before any invitation row. One ROLE_NOT_FOUND row was added in plugin-auth's ledger section; check:generated --fix regenerated content/docs/references/api/{contract,error-code-ledger}.mdx. The changeset sets plugin-auth and spec to minor. H1 falsified in part: update-member-role's code comes from better-auth itself (crud-members.mjs sets code), not from a plugin-auth mapping, so there was no shared plugin-auth seam to reuse. The seam used is better-auth's own runAfterHooks, which replaces the returned value with an APIError an after hook throws. The vendored library is untouched and no other refusal changed. Card assignee: not touched. Newest Claim 6286575652 names this branch.",
      "tests": "head 65cdcf23c (after merging origin/main 08d201a80): pnpm --filter @objectstack/plugin-auth test → Test Files 168 passed, Tests 3227 passed | 10 skipped; pnpm --filter @objectstack/spec test → Test Files 703 passed, Tests 21360 passed | 1 todo; plugin-auth typecheck + spec typecheck → exit 0 (VERDICT command-exit 0). Repro before the fix (3 new pins in src/accept-invitation-adopt-membership.test.ts, -t 24750): 1 failed | 2 passed, 'expected undefined to be ROLE_NOT_FOUND'. Ablation at 925e35e1 via scripts/ablation-replace.mjs: anchor 'await codeInviteRoleNotFound(ctx);' x1→x0, blob 658b4666de2f→44a4a7103dff; unknown-role pin red with the same assertion, valid-role and update-member-role controls green; restored with git checkout HEAD -- PATH; blob after restore 658b4666de2f == HEAD blob, git diff HEAD empty, ABLATED marker count 0, anchor count 1. No dist leg: the test imports ./auth-manager from source.",
      "gates": "local, head 65cdcf23c: pnpm --filter '@objectstack/plugin-auth^...' build exit 0; pnpm --filter @objectstack/spec build && check:generated exit 0 ('All 14 generated artifacts are up to date'); node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack → 118 commands from 7 paths, each run with exit captured before any pipe, all 118 exit 0; --ran → '118 derived, 118 run, 0 NOT-MEASURED, 0 UNRUN' (exit 0). The first pass at 3810c7f1f had 3 exit-3 prerequisites. Two were cured and re-run green at that head: plugin-teardown-shape --self-test after git fetch of fixture 621a4876, and check:skill-examples after building client/client-react. check:dual-build-cjs-loads exited 0 on the second pass. CI at report time, PR check-runs on 65cdcf23c: 25 runs; 'No other open PR may claim the same issue' success; 18.x upgrade harness and Packed-tarball smoke skipped (opt-in); the rest (Type Check ×4, Lint ×9, Check Changeset, Governed Surface Queue Guard, Spec property liveness, Check PR Size, and others) in_progress. Not waited on, per contract.",
      "mcp_calls": "0",
      "api_writes": "2 relay strokes (objectstack-fleet[bot], fleet-write): (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/24846/assignees; read-back identical, 6421 bytes, assignee os-justin; (2) comment → POST /repos/objectstack-ai/objectstack/issues/24750/comments (this report). git push (not REST) ran 6 times to the one branch: the empty-branch probe, then a WIP push per commit as AGENTS.md requires before each long step. All fast-forward, no force.",
      "open_questions": [
        { "question": "Which seam codes the refusal?", "options": ["A after-hook re-throw of the vendor's own uncoded refusal with code ROLE_NOT_FOUND", "B before-hook re-validation of the role in plugin-auth, throwing a coded refusal before the vendor runs"], "recommendation": "A (taken). B gives plugin-auth a second copy of the vendor's vocabulary check (static plus dynamic organizationRole rows), two owners of one decision that drift. A adds only the missing code on the vendor's own verdict, so the contract tightens with no consumer-side tolerance." },
        { "question": "Changeset level for @objectstack/spec?", "options": ["A minor: the new ledger row widens the published ErrorCode union", "B patch"], "recommendation": "A (taken). Rule: AGENTS.md Post-Task Checklist step 3, 'yes takes at least minor'; the spec face widens too." },
        { "question": "Where do the door pins live?", "options": ["A a new describe block in accept-invitation-adopt-membership.test.ts, reusing its pinned engine double", "B a new test file with its own copied engine double"], "recommendation": "A (taken). B adds a double that moves scripts/engine-double-contract.pinned.json, outside the claimed file surface." }
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  6. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24846 comment 6289192939 · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-20T10:49Z


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-20T11:27Z

    reading result
    PR #24846 timeline added_to_merge_queue 2026-10-20T10:52Z → merged 2026-10-20T11:25Z
    origin/main by content squash de9cd1f17f (one parent, 7cacad5348). packages/spec/src/api/error-code-ledger.zod.ts carries the ROLE_NOT_FOUND row under @objectstack/plugin-auth, and plugin-auth/src/invite-role-not-found-code.ts is present
    closing references the PR body closes #24750 only

    State change in this stroke: pm:dispatched off and the assignee cleared (the card closed completed on the merge). The contract review of record is 6289177621 (CONTRACT_REVIEW_TIER, PASS), and the ACCEPT is 6289192939. The cloud session session_01SKHckexWmsVZ6qZ1sioSkD is archived after this note.

    Noted, not filed: better-auth's other organization codes, which plugin-auth passes through flat (ORGANIZATION_NOT_FOUND, MEMBER_NOT_FOUND, INVITATION_NOT_FOUND …), are not registered in the ledger either. That gap predates this card, sits outside its class, and has no measured consumer.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:identityLogin and identity — sign-up, sessions, organization membership, SSObugSomething isn't workingdomain:servicespriority:p3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions