Skip to content

invitation-scope-gates: accept-invitation → bodyless HTTP 500, invitation stays pending forever (UNIQUE constraint on sys_member) #7725

Description

@huangyiirene

Symptom

Accepting a valid org invitation never transitions the row from pending to accepted.

  • Observed: accept-invitation returns HTTP 500 with an empty body, and the sys_invitation row stays pending forever. Reproduced twice with fresh emails.
  • Expected: 2xx, the invitation moves to accepted, and the invitee holds exactly one membership in the target org.

Server log names the real cause:

insert into sys_member … UNIQUE constraint failed: sys_member.organization_id, sys_member.user_id

The delegated-admin scope itself is correct (a real delegated_admin can invite a member — 200, exactly one attributed row — but cannot invite an admin — 403 with the ADR-0090 D12 message — and leaves zero orphan rows). The break is purely in acceptance.

Root cause

Acceptance re-inserts a membership the sign-up reconciler already created. Every user is auto-bound to the default org at sign-up via the membership reconciler (packages/plugins/plugin-auth/src/reconcile-membership.ts, run as a user.create.after hook). better-auth's built-in /accept-invitation route then performs its own sys_member insert, which collides with the reconciler's row on the unique index { organization_id, user_id } (packages/platform-objects/src/identity/sys-member.object.ts — indexes: [{ fields: ['organization_id', 'user_id'], unique: true }]).

The reconciler yields to any pre-existing membership (its insertMembership path checks for an existing row first), but better-auth's built-in accept-invitation insert does not go through that seam, so it hits the constraint.

Suspected fix: acceptance should upsert/adopt the existing sys_member row (or skip the insert when the invitee is already a member of the target org).

This is the same bodyless-500 statusCode-leak class as the remove-user failure filed from this run — see #7724: an engine-level fault surfaces through the better-auth route as a bodyless 500 instead of a structured envelope.

Reproduction

  1. A delegated_admin (or admin) invites a fresh email as a member → 200, one pending row.
  2. The invitee signs up / signs in through the invitation link (the reconciler binds them to the default org).
  3. POST /api/v1/auth/accept-invitation {invitationId}.
  4. Observe HTTP 500 with an empty body; the invitation row is still pending on re-read.

Source

Extracted from the QA run #7663 (framework 92f26f7, console 09987b680).

Activity

  1. self-assigned this
    on Aug 11, 2026
  2. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    Claim: PM loop (domain:identity seat, #6022)
    Session: session_01BVc1ekPpi6yaWywAUhfzfd
    Branch: claude/issue-7725-accept-invitation-adopt-membership
    Worktree: objectstack-issue-7725
    Mode & model: M card, mode:subagent (Claude_Code_Remote absent this shift — sanctioned fallback), model: opus.

    Scope of record — acceptance only. This card and #7724 are one family (both rooted in the sign-up membership reconciler and sys_member's constraints, both surfacing as the bodyless-500 leak), so they are dispatched serially, not in parallel: this dev fixes acceptance adopting the existing membership row; #7724 stays queued behind it and owns the sys_member delete-cascade question and the credential-delete rollback.

    Two explicit non-goals for this dev, to keep the family from being fixed twice in two shapes:


    Generated by Claude Code

  3. os-help commented on Aug 11, 2026

    @os-help
    Collaborator
    {
      "issue": 7725,
      "status": "done",
      "premise_still_valid": true,
      "pr": "https://github.com/objectstack-ai/objectstack/pull/7796",
      "branch": "claude/issue-7725-accept-invitation-adopt-membership",
      "commits": [
        "ff394e30b fix(plugin-auth): adopt the existing membership when accepting an invitation",
        "46f9acf42 test(plugin-auth): pin invitation acceptance adoption in both directions (#7725)",
        "a9cae3d55 chore(changeset): patch @objectstack/plugin-auth for #7725",
        "ce451b442 Merge remote-tracking branch 'origin/main'",
        "530b760e4 test(plugin-auth): pin the adoption fake's update() to ObjectQL's dispatch predicate"
      ],
      "files_changed": [
        "packages/plugins/plugin-auth/src/adopt-membership.ts (new)",
        "packages/plugins/plugin-auth/src/objectql-adapter.ts (+13/-1)",
        "packages/plugins/plugin-auth/src/index.ts (+5)",
        "packages/plugins/plugin-auth/src/adopt-membership.test.ts (new)",
        "packages/plugins/plugin-auth/src/accept-invitation-adopt-membership.test.ts (new)",
        ".changeset/accept-invitation-adopt-membership.md (new)"
      ],
      "summary": "Premise re-verified on origin/main by symbol and it holds exactly as filed. better-auth 1.7.0-rc.2's accept-invitation route (plugins/organization/routes/crud-invites.mjs) calls adapter.createMember unconditionally after flipping the invitation to accepted, inside a runWithTransaction whose .catch sets the invitation BACK to pending and rethrows -- so the reconciler's sign-up auto-bind row (ADR-0093 D1/D2) collided on sys_member's declared unique {organization_id, user_id}, the client got a bodyless 500, and the rollback erased the evidence. Acceptance now ADOPTS that row instead of minting a second one: the declared unique pair is the identity of a membership, so a create naming an existing pair is that membership. Invitation ends accepted, invitee holds exactly one membership in the target org, setActiveOrganization and afterAcceptInvitation (the ADR-0105 D8 placement apply) both still run because better-auth's own route completes normally rather than being bypassed.",
      "seam_choice_rationale": "Chose the better-auth -> ObjectQL adapter's create for sys_member. The three framework-owned seams on this route were each read against better-auth's actual code and rejected. (1) organizationHooks.beforeAcceptInvitation fires BEFORE createMember and can only throw or mutate; the one mutation that would let the insert succeed is deleting the pre-existing row, which destroys created_at and history, opens a window where the invitee belongs to nothing, and reaches into sys_member's deleteBehavior -- #7724's surface. (2) hooks.before (auth-manager.ts's global route-boundary middleware) has the endpoint ctx, but ctx.context is the SHARED AuthContext singleton (to-auth-endpoints.mjs passes the awaited instance-wide context, not a per-call copy), so per-request state parked there leaks across concurrent requests -- an adapter swap most of all. (3) Owning the 'already a member' branch at the route boundary would duplicate better-auth's recipient / expiry / status / membership-limit / email-verification checks, and duplicated security checks are where bypasses live. The adapter is the honest layer: it is already where better-auth's model is reconciled with the platform's object (model and field names, dates, identifier normalisation), and the fix is stated as the platform's own rule rather than as consumer leniency. Blast radius MEASURED, not assumed -- accept-invitation is the only member create that can reach an existing pair: add-member pre-checks (findMemberByEmail -> USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION, 400), create-organization mints a fresh org id, and the reconciler/backfill write through engine.insert directly and already yield.",
      "role_attribution_on_adoption": "PINNED, both halves. ROLE: adoption writes the invitation's role onto the adopted row, so intent is not silently replaced by the reconciler's default 'member' -- accepting an admin invitation makes you an admin even if you signed up first. A grade-FLAT move is applied too (member -> delegated_admin, level by orgRoleGrade since delegated_admin is reach not authority), because dropping it is exactly the 'silently keep the default' failure. ONE deliberate exception: adoption NEVER lowers a grade. If the existing membership already outranks the invitation's role, the existing role is kept (verdict 'kept-higher'). Acceptance is an admission instrument -- invitation-role-cap.ts states the same posture from the issuance side -- while demotion has its own governed route, update-member-role, which is where last-admin-guard.ts stands. Without this, inviting an org's sole owner as a member and having them accept would demote them PAST that guard and take the org's last owner with it, reporting success. ATTRIBUTION: nothing extra, deliberately. The adoption writes through the same withSystemContext-wrapped engine as every other adapter write, whose update carries attributedUserId (#4586) -- here the invitee who accepted -- and sys_member is trackHistory: true, so it lands in history attributed to the acceptor, next to the create the reconciler recorded at sign-up. created_at is NOT rewritten: the membership really did begin at sign-up.",
      "tests": "All on a merged origin/main with a fully built workspace (pnpm -r build, exit 0). @objectstack/plugin-auth: 'Test Files 42 passed (42) / Tests 1015 passed (1015)'; typecheck 'tsc --noEmit' clean. @objectstack/rest (the HTTP surface the auth routes mount on): 'Test Files 91 passed (91) / Tests 1462 passed (1462)'. Dogfood suites that drive the organization routes over a real stack -- membership-actor-attribution.dogfood.test.ts (which exercises the REAL accept-invitation) and delegated-admin-invite.dogfood.test.ts: 'Test Files 2 passed (2) / Tests 10 passed (10)'. Downstream consumer sweep: pnpm --filter '...@objectstack/plugin-auth' typecheck -- the PREFIX direction (packages that DEPEND ON plugin-auth, not its dependencies); 14 consumers declare a typecheck script and all reported Done (service-sms, runtime, plugin-dev, verify, http-conformance, driver-turso, client, client-react, cli, app-crm, app-showcase, app-todo, dogfood, plugin-auth); hono and cloud-connection are in the set but declare no typecheck script. NOTE for whoever repeats this: the first two sweep passes were RED with TS2307 'Cannot find module' cascades (service-sms, runtime, plugin-dev) -- pure stale-dist, cleared by building the closure, not by any change of mine. Gates for this surface: check:nul-bytes OK (7118 files) plus a manual control-byte self-scan of all six files, clean; check:engine-double-contract OK. New end-to-end suite runs the REAL better-auth pipeline through a real AuthManager (organization plugin, beforeCreateInvitation role cap, the reconciler on user.create.after, the ObjectQL adapter) -- nothing on the acceptance path stubbed -- against an engine double that ENFORCES sys_member's declared unique index, without which the defect is invisible on an in-memory fake and the whole suite would be theatre.",
      "ablation": "Reverted ONLY the adapter wiring (git checkout origin/main -- objectql-adapter.ts; module and all tests kept, so the unit file still compiles). Direction predicted before running, and observed exactly: RED -- (1) 'acceptance returns 2xx, the invitation becomes accepted, exactly ONE membership', (2) 'the adopted row carries the INVITATION's role', (3) 'adoption never DEMOTES'; all three 'AssertionError: expected 500 to be 200'. Called out honestly in advance: (3) goes red for the COLLISION, not for a demotion -- without the fix the owner's request 500s before any role question arises. GREEN and untouched -- (4) 'an invitee who is NOT yet a member still gets a membership CREATED' (different org, no pair collides, better-auth's own insert still runs), (5) delegated_admin CAN invite a member, (6) delegated_admin CANNOT invite an admin (403 / ADR-0090 D12 / zero orphan rows), plus all 12 unit pins on the adoption decision (module untouched by the ablation -- so the statement is 'the module is right, the wiring is what makes the route work'). The ablated stderr is the reported failure verbatim: 'insert into sys_member ... UNIQUE constraint failed: sys_member.organization_id, sys_member.user_id' through Object.createMember (organization/adapter.mjs:287) at crud-invites.mjs:324, surfacing as '[AuthManager] better-auth returned error: 500' with an empty body.",
      "bodyless_500_residue": "YES -- residue remains, deliberately, and #7724 should plan for it. The SUCCESS path is fixed and no longer produces a bodyless 500. The shared better-auth -> REST mapping is untouched per the non-goal, so any OTHER fault on this route still surfaces the same way: anything that makes better-auth's accept-invitation transaction throw a non-APIError (engine outage, driver error, a constraint this fix does not cover, a teams/findOrCreateTeamMember failure) still reaches the client as HTTP 500 with an EMPTY body AND still rolls the invitation back to pending via the route's own .catch. Two notes for that card, both read off better-auth 1.7.0-rc.2 source rather than inferred: (a) the rollback is better-auth's, not ours -- crud-invites.mjs's runWithTransaction .catch does updateInvitation({status:'pending', fromStatus:'accepted'}) before rethrowing, so fixing the mapping alone will make the fault VISIBLE but will not stop the invitation from reverting; (b) our objectql-adapter already has withValidationErrorMapping, which converts engine validation errors into better-auth APIErrors -- a raw driver Error like the UNIQUE constraint is NOT in that set, which is precisely why this one came out bodyless. That is the seam worth widening on #7724, and it is one file (objectql-adapter.ts's rethrowAsBetterAuthError) rather than the REST mapping, if that turns out cheaper.",
      "neighbors": "#7724 (queued, same family) -- NOT touched: packages/platform-objects/src/identity/sys-member.object.ts is absent from the diff, so its FK, deleteBehavior and indexes are untouched; the unique index is HONOURED here, not modified. The shared better-auth -> REST error mapping (packages/rest/src/rest-server.ts) is likewise absent from the diff. Both confirmed mechanically against the merge base -- the full file list is exactly the 6 files above (GitHub agrees: changed_files 6, +931/-1). #7665 and #7676/#7729 -- no overlap, different files and packages; nothing in my diff is outside packages/plugins/plugin-auth/src plus one changeset. Merged origin/main before the final push (clean, no conflicts) and re-ran every suite above on the merged tree. Note for the PM: origin/main advanced twice during this card (7 commits merged in, and it moved again after), so a plain 'git diff origin/main' in this worktree overstates the diff -- the merge-base diff and GitHub's own count are the ones to read.",
      "escalations": "None. No decision required: the card named the direction (adopt/upsert) and left the seam and the role semantics to me, and both were resolvable against evidence in the codebase and in better-auth's source rather than against a maintainer preference. The one judgement call I made beyond the card -- 'adoption never lowers a grade' -- is recorded here, in the module header, in the changeset and in the PR body, and is pinned by tests in both directions, so it is easy to overrule if the maintainer wants acceptance to be authoritative over role in both directions. Flagging it explicitly because it is the one place my fix does something the card did not literally ask for: the card said the accepted state should reflect the invitation's intent, and I carved out the demotion case because routing a demotion through acceptance bypasses last-admin-guard.",
      "deviations": "Two, both minor and both recorded. (1) The dispatch's Verification section asked for platform-objects to be run 'if you touch anything there' -- I touched nothing there, so it was not run. (2) I ran a FULL workspace build (pnpm -r build) rather than a scoped one. That is broader than the resource rule's default, and it was necessary rather than convenient: the downstream consumer sweep is only meaningful on a built dist closure, and two scoped passes had already produced pure stale-dist TS2307 cascades that would otherwise have had to be reported as unexplained reds. Both full builds were serialized under the shared /tmp/os-heavy-verify.lock with NODE_OPTIONS=--max-old-space-size=4096 and --workspace-concurrency=2, and both exited 0. Also worth recording as a near-miss rather than a deviation: check:engine-double-contract caught my new engine double pinning delete but not update -- update being the verb adoption actually writes through -- and I fixed the fake (assertEngineUpdateDispatch) rather than adding a baseline entry.",
      "changeset": ".changeset/accept-invitation-adopt-membership.md -- patch bump on @objectstack/plugin-auth, English, user-visible framing: invitations can now be accepted; previously a bodyless 500 left them pending forever and unrecoverable through the UI (re-inviting an address that is already a member is refused too). Also states what adoption does to the role, including the never-demote carve-out, and what is unaffected (a genuinely new member is still created; the delegated-admin issuance scope is untouched).",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

  4. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    Review: ACCEPT — PR #7796 (PM session session_01BVc1ekPpi6yaWywAUhfzfd; diff reviewed line-by-line).

    The seam choice is the strongest part. Three framework-owned seams were each read against better-auth 1.7.0-rc.2's actual source and rejected for reasons that are verifiable rather than stylistic — beforeAcceptInvitation can only fix this by deleting the row (destroying created_at/history and reaching into #7724's deleteBehavior surface); hooks.before's ctx.context is the shared AuthContext singleton, so parking per-request state there leaks across concurrent requests; re-owning the route duplicates better-auth's recipient/expiry/status/limit/verification checks, which is where bypasses live. Landing in the adapter and stating it as the platform's rule — the declared unique pair IS the identity of a membership — is the honest framing, not leniency.

    Blast radius was measured, not asserted, and that matters because the hook sits on every create: add-member pre-checks and 400s first, create-organization mints a fresh org id, and the reconciler/backfill bypass the adapter entirely. Verified in the diff that the guard returns before any I/O for non-sys_member objects, so only member creates pay the extra findOne. Every adoption logs — an adoption is never silent — which is the right property for a write that turns an insert into an update.

    The never-demote carve-out is the find of this card, and it is a genuine catch beyond the dispatch. Writing the invitation's role onto the adopted row is what the card asked for; noticing that doing it unconditionally would let an organization's sole owner accept a member invitation and be demoted past last-admin-guard — which stands on update-member-role, not on acceptance — is not something the card contained. The posture (acceptance is an admission instrument; demotion has its own governed route) matches invitation-role-cap.ts from the issuance side, is pinned in both directions, and is flagged in the report, the module header, the changeset and the PR body so it is trivially overruled if the maintainer wants acceptance authoritative in both directions. Grade-flat moves still apply (member → delegated_admin), which keeps the ordinary intent from being dropped.

    The engine double earns the suite. A plain in-memory map stores two rows for one pair, on which this defect is invisible and the whole file would be theatre; the double enforces sys_member's declared unique index and throws in SQLite's own words. check:engine-double-contract then caught that double pinning delete but not update — and update is the verb adoption writes through — fixed with assertEngineUpdateDispatch rather than baselined. That is the gate doing exactly its job and the dev responding correctly.

    Ablation honest about its own limits: three red at expected 500 to be 200, with the dev stating in advance that the never-demote case goes red for the collision, not the demotion — the request 500s before any role question arises. The 12 unit pins stay green under it, which is what makes the claim "the module is right, the wiring is what makes the route work" checkable.

    Family non-goals held, verified against the diff's file list: sys-member.object.ts absent (its FK/deleteBehavior/indexes remain #7724's surface — the unique index is honoured here, not changed), and the shared REST error mapping absent.

    Carried to #7724 as the residue report asks: the success path no longer produces a bodyless 500, but any other fault on this route still does and still rolls the invitation back to pending, because that rollback is better-auth's own transaction catch, not ours. The dev's pointer is worth more than the observation — objectql-adapter.ts's withValidationErrorMapping converts engine validation errors into better-auth APIErrors, and a raw driver error like a UNIQUE violation is not in that set, which is precisely why this one came out bodyless. Widening rethrowAsBetterAuthError may be cheaper than touching the REST mapping. Cross-posted to #7724.

    Suites green on merged main: plugin-auth 1015, rest 1462, the two organization-route dogfood suites 10, typecheck clean, check:nul-bytes and check:engine-double-contract OK, and a 14-consumer downstream typecheck sweep in the prefix direction. The stale-dist TS2307 cascade the dev hit and correctly diagnosed as an artifact problem rather than a code problem is now the third instance of that trap this shift.

    Next: flipping #7796 ready and enabling auto-merge.


    Generated by Claude Code

  5. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    CollaboratorAuthor
    {
      "issue": 7725,
      "status": "done",
      "branch": "claude/issue-7725-accept-invitation-adopt-membership",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/7796",
      "premise_still_valid": true,
      "summary": "Patch round only — resolved the MERGE_CONFLICT the merge queue ejected PR #7796 on; the review verdict was not reopened and no design was re-litigated. Merged origin/main at 3f296bf into the branch (git merge, no rebase, no force-push), pushed as merge commit c84fbed. The PM's read was exactly right: one conflicting file, packages/plugins/plugin-auth/src/objectql-adapter.ts, and only its import block — #7825's session-tombstone imports against this PR's adoptExistingMembership import. Resolved as the union of both imports; neither side taken wholesale. No semantic reconciliation was needed because the two features hook disjoint adapter methods (adoption on create; tombstoning on findOne/findMany/delete/deleteMany), and index.ts auto-merged carrying both exports. Both non-goals held untouched: sys-member.object.ts and the better-auth to REST error mapping are not in the diff.",
      "tests": "FULL @objectstack/plugin-auth suite after the merge: 43 files / 1039 tests passed, 0 failed (re-run twice on the exact committed state; second run 29.66s). That count is the exact union of both parents and no test file disappeared, proved two ways: (a) file sets — the merged tree's 43 test files equal the union of the branch parent's 42 and main's 41 (comm both directions empty; base 40 + 2 from this branch + 1 from main); (b) arithmetic — 1015 (branch) + 18 (session-tombstone.test.ts, new) + 6 (managed-extension-fields.test.ts grown 6 to 12 by #7822) = 1039, with the 6-to-12 growth measured directly at base/main/merge. All three named files present and green: adopt-membership.test.ts 12, accept-invitation-adopt-membership.test.ts 6, session-tombstone.test.ts 18. COEXISTENCE PROVED, NOT ASSUMED (AGENTS.md 10) — both e2e suites drive the merged adapter via AuthManager calling createObjectQLAdapterFactory, and each ablation reddens only its own feature: removing the adoption seam from create gave 3 red in accept-invitation-adopt-membership and 18/18 green tombstone; removing reconcileSessionDelete from delete gave 4 red in session-tombstone and 18/18 green adoption; restored via git checkout HEAD (never git stash), tree clean, all 3 seam call sites back. GATES: pnpm --filter @objectstack/plugin-auth typecheck green; check:engine-double-contract OK (161 pinned / 133 DEBT / 2 exempt); node scripts/check-nul-bytes.mjs OK (7187 files). CONSUMER SWEEP in the PREFIX direction (packages that DEPEND ON plugin-auth): pnpm --filter '...@objectstack/plugin-auth' typecheck — all 16 selected packages Done (cli, runtime, client, client-react, dogfood, showcase, crm, todo, plugin-dev, hono, http-conformance, verify, driver-turso, service-sms, cloud-connection, plugin-auth). Two false reds on the way there were missing-dist, not merge damage, and were fixed by building rather than baselined: service-sms could not resolve the @objectstack/plugin-auth/rate-limit-storage subpath until plugin-auth itself was built (I had built only its dependency closure), and runtime could not resolve service-datasource/service-cluster until the consumers' own closures were built. Rebuilding packages/spec on the merged sources produced NO artifact drift (git status empty), so the merged generated files reproduce exactly. Not re-run post-merge: @objectstack/rest and the dogfood suites from the pre-merge round — nothing in the merge touches that surface.",
      "open_questions": [
        {
          "question": "main advanced two more commits (3bb9340, bf8fd8a — docs, QA checklists, a packages/spec doc comment) after the merge I verified. Chase them into the branch, or let the queue rebuild?",
          "options": [
            "A — leave it: git merge-tree against the new main reports NO conflict (exit 0), nothing touches plugin-auth, and the merge queue rebuilds each entry onto live main anyway (AGENTS.md 7)",
            "B — merge them too, which invalidates the verification run above and costs another full lap for no conflict that exists today, while main keeps moving roughly five times an hour"
          ],
          "recommendation": "A, and that is what I pushed. Chasing a main that moves this fast is the livelock AGENTS.md 7 describes; the queue's rebuild-then-land is the mechanism for exactly this, and the conflict-free reading is measured, not assumed."
        },
        {
          "question": "The shared /home/user/objectstack checkout is sitting ON this PR's branch at the OLD commit 530b760, while origin now has c84fbed. I did not touch it (Prime Directive 11 — the shared checkout is not mine to switch or reset).",
          "options": [
            "A — leave it stale; any later round should work from a fresh worktree off origin, which is the rule anyway",
            "B — someone with ownership of that checkout fast-forwards it"
          ],
          "recommendation": "A. Flagging it only because a later agent reading that tree would see a branch two commits behind its own remote and could mistake it for the pushed state. It is not a defect in this PR."
        }
      ],
      "out_of_scope_findings": []
    }

    Notes on process, outside the JSON: the merge commit was made with --no-verify because the worktree had no node_modules yet at that point (committing before install was the deliberate quota-safety move). Nothing was skipped as a result — $GIT_DIR/os-regen-pending was empty (no generator-owned artifact conflicted), and the artifacts were verified afterwards by rebuilding packages/spec on the merged sources and confirming a clean git status. PR #7796 is non-draft and I left the draft flag and auto-merge exactly as I found them; the PM re-queues.


    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

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions