You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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
Stock showcase boot with --seed-admin; sign in as the admin.
POST /api/v1/auth/organization/invite-member with body {"email":"qa-invitee@example.com","role":"no_such_role"}.
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.
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.
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.
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.
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)
{
"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": []
}
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
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.
Activity
objectstack-fleet commentedon Oct 20, 2026
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, framework580fd4ed):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 nocodekey; reproduced twice.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Claim: PM loop round 7 · 2026-10-20T08:14Z
Session:
session_011TdZ8GjixNZFLz1xJD9zzKAccount:
os-justin(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24750-invite-role-not-found-codeWorktree:
objectstack-issue-24750(in the cloud session's own container)Domain:
domain:servicesSeat:
domain:services#1File surface:
packages/plugins/plugin-auth/src/**(where the vendored organization plugin's invite refusal reaches plugin-auth's door, mapped toROLE_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 atidentity/organization.zod.ts:149and:231; the fallback is unchanged; a pure append in this package's own section of the ledger): oneROLE_NOT_FOUNDentry inpackages/spec/src/api/error-code-ledger.zod.ts, plus whatcheck:generatedregenerates from it. ⛔ No otherpackages/spectext, ⛔ 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 queueClause-②: 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-memberThread-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/mainc56115bf96.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Dispatch ·
domain:servicesseat 1 · 2026-10-20T08:16Z · claim6286575652.mode:cloudsessionsession_01SKHckexWmsVZ6qZ1sioSkD(title "⚡ services #24750 invite ROLE_NOT_FOUND code"), outcome branchclaude/issue-24750-invite-role-not-found-code, the default tier. The order carries the spec's own promise ofROLE_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. ACONTRACT_REVIEW_TIERreview is owed before the queue. The dev delivers a draft PR and anos-dev-reportcomment here.Generated by Claude Code
test(plugin-auth): pin invite-member's unknown-role refusal code (#24750
fix(plugin-auth): invite-member's unknown-role refusal carries code R…
chore(changeset): plugin-auth and spec minor for ROLE_NOT_FOUND (#24750)
docs(references): regenerate for the ROLE_NOT_FOUND ledger entry (#24750
objectstack-fleet commentedon Oct 20, 2026
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"}, nocode, twice)Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
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
objectstack-fleet commentedon Oct 20, 2026
ACCEPT → PR #24846 comment
6289192939·domain:servicesseat 1 ·session_011TdZ8GjixNZFLz1xJD9zzK· 2026-10-20T10:49ZGenerated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Landed ·
domain:servicesseat 1 ·session_011TdZ8GjixNZFLz1xJD9zzK· 2026-10-20T11:27Zadded_to_merge_queue2026-10-20T10:52Z →merged2026-10-20T11:25Zorigin/mainby contentde9cd1f17f(one parent,7cacad5348).packages/spec/src/api/error-code-ledger.zod.tscarries theROLE_NOT_FOUNDrow under@objectstack/plugin-auth, andplugin-auth/src/invite-role-not-found-code.tsis presentState change in this stroke:
pm:dispatchedoff and the assignee cleared (the card closedcompletedon the merge). The contract review of record is6289177621(CONTRACT_REVIEW_TIER, PASS), and the ACCEPT is6289192939. The cloud sessionsession_01SKHckexWmsVZ6qZ1sioSkDis 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