Skip to content

fix(client): organizations.getActiveMember addresses the organisation the caller NAMES, not whichever one the session has active - #16761

Merged
huangyiirene merged 10 commits into
mainfrom
claude/issue-16568-get-active-member-organization-id
Sep 9, 2026
Merged

huangyiirene merged 10 commits into
mainfrom
claude/issue-16568-get-active-member-organization-id

Conversation

@claude

@claude claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #16568
Clause-②: yes

The defect

organizations.getActiveMember(organizationId) built GET /organization/get-active-member?organizationId=…. better-auth 1.7.2's handler for that path (plugins/organization/routes/crud-members.mjs) reads session.session.activeOrganizationId and never looks at ctx.query, so the query string was dead on arrival: a client doing a permission check for organisation B while A was active was told about A, at 200, with no diagnostic. The SDK's own JSDoc promised "the calling user's membership row in the given organisation" — a declared capability the runtime did not deliver.

Zone 1's hard precondition, measured BEFORE any implementation

Triage recommended list-members but said in writing it had not verified the query shape. It was driven first: a real AuthManager (better-auth 1.7.2, organization plugin, teams enabled) over a real SqlDriver (better-sqlite3 :memory:), one user owning two organisations with A active, plus a second member seeded into B so the filter has something to exclude. Transcript, trimmed to the ids that matter:

CREATE-A                                    -> 200 id=aSkH…  (member row role=owner)
CREATE-B                                    -> 200 id=YerN…  (member row role=owner)
SET-ACTIVE A                                -> 200

R1  get-active-member?organizationId=A      -> 200 {organizationId:A, id:Gbr…, role:'owner', user:{…}}
R2  get-active-member?organizationId=B      -> 200 {organizationId:A, id:Gbr…, role:'owner', user:{…}}   # SAME ROW
R8  get-active-member  (no active org)      -> 400 NO_ACTIVE_ORGANIZATION                                # the card's control

R3  list-members?organizationId=B&filterField=userId&filterValue=SELF        -> 200 {members:[{organizationId:B,…}], total:1}
R4  list-members?organizationId=A&filterField=userId&filterValue=SELF        -> 200 {members:[{organizationId:A,…}], total:1}
R5  same as R3 plus &limit=1                                                 -> 200 {members:[{organizationId:B,…}], total:1}
R9  R3 again with NO active organisation                                     -> 200 {members:[{organizationId:B,…}], total:1}
R7  list-members?organizationId=FOREIGN&filterField=userId&filterValue=SELF  -> 403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION

RA  list-members?organizationId=B  (unfiltered, B now has 2 members)         -> 200 {members:[OTHER, SELF], total:2}
RB  list-members?organizationId=B&filterField=userId&filterValue=SELF&limit=1-> 200 {members:[SELF],  total:1}
RC  list-members?organizationId=B&filterField=userId&filterValue=OTHER       -> 200 {members:[OTHER], total:1}

ANON get-session                            -> 200 null
ANON list-members                           -> 401 UNAUTHORIZED
ANON get-active-member                      -> 401 UNAUTHORIZED

RA/RB/RC are the discriminating leg: with two rows in B, the self filter returns exactly one and the other-user filter returns the other, so filterField=userId really narrows rather than merely not breaking. R3/R4 are the addressing leg. The precondition holds, so option 2 was implemented; nothing was improvised and the decision inbox was not needed.

The vendor premise was re-confirmed on the same drive: the installed version is exactly better-auth 1.7.2 (pinned by PR #16634), and its getActiveMember handler still reads session state only. The card's premise stands.

What changed

packages/client/src/index.ts, organizations.getActiveMember — the signature and the declared return type are byte-identical; only the addressing moved:

  1. GET /get-session for the caller's own user id (bare { user, session } for a signed-in caller, the literal null for an anonymous one — measured);
  2. GET /organization/list-members?organizationId=…&filterField=userId&filterValue=SELF_USER_ID&limit=1, unwrapping the one-entry page.

list-members rows carry the identical shape — {id, organizationId, userId, role, createdAt, user:{id,name,email,image}} — which is why OrganizationMemberWithUserWire does not move.

The JSDoc is corrected in the same stroke, as triage required. #14314's PR had changed it to say the argument is ignored; that sentence is now false, so it is replaced by what the method does, plus every behaviour an existing caller can observe change.

Does the request-byte change constitute a published behaviour change? Yes — declared, not argued away

Triage asked for this in writing, so here it is, item by item. The request bytes change, and so does the answer:

  • naming a non-active organisation now answers that organisation's row instead of the active one's. This is the defect, and enforcing a declaration the SDK has always made;
  • a non-member of the named organisation is refused 403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION where the old shape produced 400 MEMBER_NOT_FOUND — and about a different organisation at that, since the old shape never asked about the named one. Two published error codes, and the input class that reaches each of them is re-chosen;
  • a caller with no active organisation now gets their row instead of 400 NO_ACTIVE_ORGANIZATION. setActive has stopped being a precondition;
  • an anonymous caller still gets 401 UNAUTHORIZED, thrown by the same session middleware that guarded the old route. Nothing client-side is substituted for the server's refusal;
  • one HTTP request became two.

Clause-②: yes — re-declared from the delivered diff

The dispatch carried a no as triage's reading, marked explicitly as not measured. Re-declared here, and it flips. The machine-read declaration is the standalone line at the top of this body, in the fixed spelling — this heading and the paragraphs under it are the argument, not the declaration.

The mechanical floor is clean: no new exported symbol, no new key on a published payload, no signature change, no type change (check:exported-any-returns is untouched, check:dts-closure and check:type-source-resolution both green). But the floor is not the whole test, and the contract-review rule names this exact case as one that needs judgement rather than a mechanism: "在两个已发布码之间重选输入类". That is precisely what the second bullet above is — the input class that produces each of two published ADR-0112 codes is re-chosen — and the answer to which row an existing caller receives changes with it. Under "claim 拿不准 ⇒ 按 yes" that is a yes twice over.

needs:contract-review is hung on this PR at creation, and on the card, as the double carrier requires.

Reverse verification

The fix was committed first, then the pre-fix packages/client/src/index.ts was restored for one run.

  • on-disk proof, both directions: the anchor `organization/list-members` counted 2 before and 1 after (the surviving one is a pre-existing JSDoc occurrence at line 1358 — the printed "expect 0" label in the ablation script was wrong about that constant, the observation was not), and the blob hash moved ef5fa760… to 7fa9e129…;
  • result: 5 of 7 cases red — ① wrong organisation (expected 'org_alpha' to be 'org_bravo'), ② the request bytes, ④ no-active-organisation, ⑤ the 403 envelope (expected undefined to be 'YOU_ARE_NOT_A_MEMBER…'), ⑥ the anonymous 401 request count. ③ (naming the active organisation) and ⑦ (the guard-the-guard leg that drives the dead route directly) stay green, as predicted — ③ is the one case the old shape got right by coincidence;
  • restore proven, not assumed: git checkout HEAD -- …, then git diff HEAD empty and the on-disk blob hash back to ef5fa760…, byte for byte. The script carried a trap … EXIT INT TERM with absolute paths throughout.

No dist is in the resolution path here: the suite imports ./index relatively, i.e. the source in this checkout, so the ablation could not have been read against a stale build.

Tests

New: packages/client/src/organization-get-active-member-addressing.test.ts, 7 cases. Its fixture is not an approximation — every status, code and row shape in it is a transcript line from the drive above, and it keeps the defect alive on get-active-member (that arm still answers the active organisation whatever the query names), so a regression to the old route fails on the row value rather than on a URL string.

run result
pnpm --filter @objectstack/client test 36 files / 461 tests passed
pnpm --filter @objectstack/client typecheck pass — tsc --noEmit + check:test-typecheck (0 files / 0 errors in the debt ledger)
pnpm --filter @objectstack/plugin-auth test 104 files / 2191 tests passed
pnpm --filter @objectstack/plugin-auth typecheck pass — debt ledger unchanged at 10 files / 94 errors / 23 pinned
pnpm --filter '@objectstack/client-react...' build pass (the dependency closure; also the prerequisite two gates below needed)

Gates

Derived from the delivered diff with node scripts/pm/dispatch-gates.mjs --commands, from a tree actually at origin/main (no STALE TREE banner — origin/main had moved twice during the round and was merged in first), and reconciled:

Run reconciliation — 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED.

All 59 exit 0, each captured before any pipe. Three needed a second lap and none of the three is a NOT MEASURED in the final record:

  • pnpm check:doc-authoring was genuinely red on this diff: the new ledger note carried #16568 in a runtime string, against the maintainer's ruling 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」. The id is stripped; git history keeps the anchor. Green.
  • pnpm --filter @objectstack/spec run check:skill-examples and pnpm check:dual-build-cjs-loads both refused for want of built output (the second by its own exit 3 PREREQUISITE NOT MET). Both green after the client-react closure build — 258 prose examples type-check across 3 surfaces.
  • pnpm check:type-check-debt OOM-ed at --max-old-space-size=4096 and answered exit 3, its own PREREQUISITE-NOT-MET code. Re-run at 8192 (the gate itself runs tsc under a CI-shaped 6144 ceiling, so 4096 could never have held the wrapper): green, 5 ledger entries re-measured, none above its recorded number.

Lint is the full repo-wide union, not a narrowing: eslint . --no-inline-config --format json at 012d430b6347 files, 0 errors, 0 warnings, exit 0.

Declared scope extension: one ledger row outside the dispatched file surface

The dispatch named packages/client/src/index.ts plus a test under packages/client/. This PR also edits one row of packages/plugins/plugin-auth/src/auth-route-ledger.ts, and that is deliberate rather than drift: disposition: 'sdk' means "expressed by the SDK", and after this change no SDK method builds that URL, so leaving the row would ship a false statement in a truth ledger created by this diff. It is rebooked server-only with the rationale the hygiene test demands, client dropped, modelled on the neighbouring organization/add-member row which carries exactly this shape.

The bounded in-place exemption's four conditions, each checked rather than asserted: (i) same defect class as the card — a declared capability the runtime does not deliver; (ii) mechanical, with the target shape already pinned by AuthRouteDisposition and the hygiene case that demands a note on every non-sdk row; (iii) zero holders — scanned per-ref against each open PR's own merge-base, positive control fired; (iv) same gate family, no new validation surface (auth-route-ledger.conformance.test.ts, auth-route-ledger-coverage.test.ts and pnpm check:auth-mount-ledger already read this file, and all three are green).

Nothing published moves with it: the module has zero runtime importers in non-test source, and tsup builds only src/index.ts and src/rate-limit-storage.ts, so it cannot reach dist. Hence one changeset, for @objectstack/client alone.

Serial

packages/client/src/index.ts is the #12104 family's hard-serial hot file. Re-measured at claim time rather than inherited: zero holders across 11 of 11 open PRs, per-ref against each PR's own merge-base, with two positive controls firing (packages/cli/src/commands/validate.ts in #16727, packages/client/package.json in #15334). The same scan found zero holders on auth-route-ledger.ts.

验收备注


Generated by Claude Code

`organizations.getActiveMember(organizationId)` built
`GET /organization/get-active-member?organizationId=...`, and better-auth
1.7.2's handler for that path reads `session.session.activeOrganizationId`
and never looks at `ctx.query`. The query string was dead on arrival: a
permission check for organisation B while A was active answered A's row,
with a 200 and no diagnostic.

The method now asks the question honestly, in two requests: `GET
/get-session` for the caller's own user id, then `GET
/organization/list-members?organizationId=...&filterField=userId&filterValue=<self>&limit=1`,
unwrapping the one-entry page. `list-members` reads `ctx.query.organizationId`
and its rows carry the identical shape, so the signature and the declared
return type are unchanged.

The `get-active-member` ledger row is rebooked `server-only`: no SDK method
builds that URL any more, and `sdk` means "expressed by the SDK".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
check:doc-authoring — a runtime string reaches authors and generated
surfaces, none of whom can resolve `#NNNN`; git history keeps the anchor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/authentication.mdx (via /api/v1/auth/get-session (route, a path literal in AUTH_ROUTE_LEDGER))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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.

Coarse fallback — 23 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 f6b7c53db7b65bbfb019750efb4e545470b0c2b7packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json f6b7c53db7b65bbfb019750efb4e545470b0c2b7

⚠️ 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 f6b7c53db7b65bbfb019750efb4e545470b0c2b7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Check Changeset: a PR declaring clause-② yes may not grade a package it
grew `patch`. The maintainer's ruling of 2026-09-04 (decision batch #35)
holds that a change to a published package's public surface takes at
least `minor`; a commit type may raise a bump, never lower it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8

Copy link
Copy Markdown
Contributor

Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16761 @ eb75819

Verdict: PASS WITH FINDINGS

Ruling implemented: n/a — no ## Ruling recorded exists on card #16568 or on this PR. The directive the PR implements is the triage seat's recommendation (os-zhuang, comment 5576219184, "分诊席" — a seat, not a maintainer ruling), and the PR implements it exactly: option 2 (list-members + filterField=userId self-filter, signature held), the hard precondition measured before implementation, JSDoc corrected in the same stroke, vendor version re-confirmed. The only maintainer ruling cited anywhere (2026-09-04, batch #35 "WHICH LEVEL") governs the changeset level, not this card.

Everything below was verified independently from refs/review/16761 against origin/main (47f751d5d) and the vendor source at the pinned version; nothing was taken from the PR body.

Verification

  1. Card and thread. client SDK organizations.getActiveMember(organizationId) sends an organizationId the server ignores — it answers the session's ACTIVE organization, whatever id the caller names #16568 (6 comments): triage → claim (edited Clause-②: no → yes in place, with a stated reason) → delivery acceptance → two os-dev-report blocks → CI-green note. The claim comment and the PR body now agree on Clause-②: yes.
  2. Diff vs merge-base 7c12e475e — 4 files, +362/−11: .changeset/client-get-active-member-names-the-organisation.md (A), packages/client/src/index.ts (M, +56/−10), packages/client/src/organization-get-active-member-addressing.test.ts (A, 275), packages/plugins/plugin-auth/src/auth-route-ledger.ts (M, 1 row). Governed paths: no — none of docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/** is touched.
  3. The contract. Signature is byte-identical before/after: getActiveMember: async (organizationId: string): Promise<OrganizationMemberWithUserWire>. Wire binding moves from GET /organization/get-active-member?organizationId=… (ledger row now server-only) to GET /get-session + GET /organization/list-members?organizationId=…&filterField=userId&filterValue=<self>&limit=1 (both already ledgered sdk). Server answer when the named org ≠ active org, read from better-auth@1.7.2 plugins/organization/routes/crud-members.mjs (pin confirmed in plugin-auth/package.json and the lockfile): listMembers runs orgSessionMiddleware, resolves organizationId = ctx.query.organizationId || session.activeOrganizationId, then findMemberByOrgId({ userId: session.user.id, organizationId })403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION before any filter is applied. The old getActiveMember handler reads session.session.activeOrganizationId only and never ctx.query — the card's premise stands at this version.
    Security: no authorization widening. The named-org read is authorised by the caller's own membership in the named organisation, not by session state; a non-member cannot read any row. The SDK fixes filterValue to the caller's own id, and even an arbitrary filter would expose only what organizations.listMembers (same route, already sdk) exposes today. Anonymous callers are refused 401 by the same middleware; the SDK substitutes nothing client-side.
  4. Clause-②. PR body line 2 carries the literal Clause-②: yes; the card's governing claim now matches. The yes is correct on its own ground (input classes re-chosen between two published ADR-0112 codes; the row an existing caller receives changes). Ledger conformance: client-url-conformance.test.ts drives every method with a recording fetch, so getActiveMember is pinned to build only ledgered URLs (both hit sdk rows); auth-route-ledger-coverage.test.ts resolves client: names — and no row names organizations.getActiveMember any more, so the method is pinned by URL but no longer by name (see F1). return-type-precision.test.ts:950 pins the return type unchanged.
  5. Changeset. @objectstack/client: minor — correct level per batch [WIP] Add query enhancements and advanced validation features #35 (fix( that moves published behaviour cannot be patch on a clause-② yes; check-changeset-no-major level-axis green). No **BREAKING** banner and no ADR-0087 marker, so the gate is silent; whether one is owed is F3.
  6. Tests. 7 cases, no .skip/.only/.todo. Case ① is the revert-reddening pin (named org_bravo while org_alpha active → asserts organizationId === 'org_bravo', id === 'mem_b_self'); the double keeps the defect alive on get-active-member and ⑦ proves it can serve the wrong row, so ① fails on the value under a revert, not on a URL string. Case ⑤ is the negative control (non-member → 403 YOU_ARE_NOT_A_MEMBER… in the code/httpStatus envelope); ⑥ pins anonymous 401 with two requests on the wire. tsconfig.test.json includes src/**/*, so the new file is under check:test-typecheck (debt ledger 0/0). Note the negative control is against the fixture's model of the vendor gate, not the vendor; the server-side authorisation is pinned here by source reading (item 3), not by an in-repo integration test — acceptable, since packages/client has no plugin-auth edge.
  7. CI on eb75819: 39 check runs — 36 success, 3 skipped, 0 failure, 0 in progress. mergeable_state: clean. Head is 21 commits behind origin/main (5 ahead); none of the 21 touch packages/client/src/index.ts, the ledger, or the new test, and a dry merge-tree reports 0 conflicts.

Findings

F1 — ledger rows left incomplete by the PR's own standard (low, same file already in the diff). The PR rebooks the get-active-member row because "a truth ledger must not ship a false statement". By the same standard two rows are now incomplete: GET /api/v1/auth/get-session carries note: 'auth.me and auth.refreshToken both target it' while organizations.getActiveMember now targets it too, and GET /api/v1/auth/organization/list-members names only organizations.listMembers while getActiveMember now builds it (the invite-member row is the precedent for exactly this, with a note). No gate pins it (hence CI green), which is why it is a finding rather than a red. Expectation: extend both notes so the ledger lists every SDK method that builds each URL; that also restores a by-name anchor for getActiveMember, which item 4 shows is otherwise pinned by URL only.

F2 — empty organizationId silently answers the ACTIVE organisation (low). listMembers resolves ctx.query.organizationId || session.activeOrganizationId, so getActiveMember('') returns the active org's row at 200 — the card's "wrong-but-plausible, silently" class, surviving on one input while the JSDoc now says "the GIVEN organisation". Same behaviour as before the PR, so not a regression. Expectation: refuse a falsy id client-side with a loud error (the method already throws loudly for the empty-page case), or document the fallback in the JSDoc; one pinned case either way.

F3 — breaking-ness carrier: the changeset prescribes a migration for an existing caller class but declares no **BREAKING** and no ADR-0087 disposition (medium; maintainer's call). The changeset says, in its own words, "Callers that relied on passing an arbitrary id to read the ACTIVE organisation's row should pass the active organisation's id" — a FROM → TO prescription for callers who followed the JSDoc as it stands on main today (#14314's "the argument is ignored"). Under the launch-window convention the level cannot carry breaking-ness; the banner + ADR-0087 disposition are the only carriers, and check-adr-0087-registration is by design silent unless the author declares. Against that, AGENTS.md rule 3 defines a breaking changeset as one that "removes or renames anything an author can write", and this removes nothing: signature, type and export are unchanged, and the change restores the contract the method was published with. Both readings are defensible; this seat does not manufacture a ruling. Expectation: the maintainer decides. If breaking: add **BREAKING** with explicit FROM → TO lines (three inputs move: non-active org → that org's row; non-member → 403 YOU_ARE_NOT_A_MEMBER… where it was 400 MEMBER_NOT_FOUND; no active org → success where it was 400 NO_ACTIVE_ORGANIZATION) plus one ADR-0087 marker (the gate prints the category set). If not: no edit, and the ruling on the card closes the question for the next PR of this shape.

F4 — informational. The get-session step types its body inline as { user?: { id?: string } } | null rather than reusing auth.me, which declares the wrong SessionResponse envelope (#16760, filed by this PR). Correct choice given #16760 is open; when #16760 lands, this call should collapse onto auth.me.

Landing note

Draft, needs:contract-review on both carriers, Clause-②: yes, and F3 is a declaration decision the maintainer owns — this is a maintainer-only merge. Nothing here is a defect in the code: the addressing is correct, the authorisation is by membership, and the tests would redden on a revert.


Generated by Claude Code

This was referenced Sep 8, 2026
better-auth resolves `ctx.query.organizationId || session.activeOrganizationId`
on `list-members`, so an empty string fell through to session state and came
back 200 carrying the ACTIVE organisation's row — the same silent substitution
this method was fixed to stop making, surviving on one argument while the
JSDoc says "the GIVEN organisation".

The SDK now refuses it before the wire, in the shape `environment(id)` already
uses. The pinned case asserts nothing reaches the wire at all, and drives
`list-members` with an empty id through the same double to show the fallback
the refusal prevents is real in the fixture, not assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5
… each URL

`get-active-member` was rebooked `server-only` because a truth ledger must not
ship a false statement; by the same standard two rows were left incomplete.
`get-session` named only `auth.me` and `auth.refreshToken`, and `list-members`
named only `organizations.listMembers`, while `organizations.getActiveMember`
now builds both. The `invite-member` row is the precedent for exactly this.

Also restores a by-name anchor for the method: after the rebooking it was
pinned by URL through `client-url-conformance.test.ts` but by no `client:` or
`note:` string anywhere in the ledger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5
The changeset now carries the `**BREAKING**` banner, one before/after pair per
moved input, and an ADR-0087 `not-required (no-migration-prescription)`
disposition. The level stays `minor`: under the launch-window convention the
level cannot carry breaking-ness, so the banner and the disposition are the
carriers.

Four inputs move, each stated as the response it drew before and the response
it draws now: an id other than the active organisation; an organisation the
caller is not a member of; any id on a session with no active organisation;
and an empty id, which this round refuses client-side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5

os-bill commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Director seat adoption record — summon #20, session_01Tep4AYXZvyBA7jsvne5KZV (os-bill), 2026-09-09T06:59Z. The verdict below is adopted verbatim from an isolated contract-review subagent (explicit model = CONTRACT_REVIEW_TIER). Transcript tier check before adoption: every harness-stamped model field in the subagent transcript reads claude-fable-5-1 (87 stamps, no other value). Head re-read at posting time = 4ebf8692d9, unchanged since the review. ⛔ This seat takes no release action on this carrier (no ready flip, no auto-merge, no enqueue, no label write): the owning seat (domain:engine) adopts this verdict verbatim or discards it, and acts per the state machine.


Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16761 @ 4ebf8692d9c5cfb896c6d4d03c50a6af289b2278

Verdict: PASS WITH FINDINGS

Ruling implemented: 5580367898 (card #16568, os-zhuang, 2026-09-08 — "the caller-class migration is breaking; carry the carriers"), made executable by the consolidated seat's note 5594607180 (marker category no-migration-prescription, before/after phrasing, stop-and-report if the gate refuses). Both are applied to the letter on the moved head; the gate did not refuse, so the note's step 3 never triggered. Everything below was re-derived from refs/pr-review/16761 against origin/main (854639b3), the vendor tarball better-auth@1.7.2 pulled from the npm registry, and the two changeset gates run offline by this seat; nothing was taken from the PR body or any seat comment.

Owed items from the prior review

Increment since eb75819 (PR-authored, merge of main excluded): d3ddfa11, b4dc6258, 4ebf8692 — 4 files, the same four paths, no new path. Verified by line:

owed status evidence
F1 — ledger notes on get-session and list-members name every SDK method that builds each URL done packages/plugins/plugin-auth/src/auth-route-ledger.ts:158 (auth.me, auth.refreshToken and organizations.getActiveMember all target it …), :262 (organizations.listMembers and organizations.getActiveMember both build it — the latter with filterField=userId&filterValue=<the caller>&limit=1 …). Tracker ids absent from both strings (the check:doc-authoring ruling).
F2 — refuse a falsy organizationId client-side with a loud error, one pinned case done packages/client/src/index.ts:3483-3485 if (!organizationId) throw new Error('[ObjectStack] organizations.getActiveMember: organizationId is required'), JSDoc @param/@throws at :3472-3476; pinned by case ⑧ organization-get-active-member-addressing.test.ts:276-301, which asserts urls is [] (nothing on the wire) and drives the fallback the guard prevents through the same double (guard-the-guard).
F3 (ruled) — **BREAKING** banner, FROM→TO per moved input, ADR-0087 disposition, level stays minor done .changeset/client-get-active-member-names-the-organisation.md:2 "@objectstack/client": minor; :7 **BREAKING** — …; :22-25 four before/after bullets (non-active id, non-member, no active org, empty id); :33 <!-- adr-0087: not-required (no-migration-prescription) SDK call-site change, no metadata conversion --> — exactly one marker, parses under the gate's readDisposition regex (scripts/check-adr-0087-registration.mjs:1502), category in the closed set.
Consolidated seat's patch-round claim 5594619517 claims hold file surface = exactly the 4 paths in the diff; the extra index.ts hunks at ~:430, ~:1058, ~:1150 visible in eb75819..head came in via the main merge 7723332f, not this PR — origin/main...head touches only the getActiveMember region :3428-3513.

Gates re-run by this seat, read-only, against the review ref: node scripts/check-adr-0087-registration.mjs --base origin/main --head refs/pr-review/16761exit 0 ("1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … not-required (no-migration-prescription)"); node scripts/check-changeset-no-major.mjs --base origin/main --head refs/pr-review/16761 --event <live PR payload>exit 0 ("no major bump"; "LEVEL AXIS: this PR declares clause-② yes, and no package whose packages/**/src/** it moves is graded patch · carrier IS on this PR · declaration line: Clause-②: yes").

Derived judgments

  1. Published client signature — unchanged. getActiveMember: async (organizationId: string): Promise<OrganizationMemberWithUserWire> (index.ts:3479), byte-identical to main; return-type-precision.test.ts:950 still pins the return type; no export added or removed.
  2. Wire behaviour — now two requests, addressing honest. GET {auth}/get-session (with Origin, the same shape auth.me uses at :3989) → GET {auth}/organization/list-members?organizationId=<enc>&filterField=userId&filterValue=<enc self>&limit=1page.members[0], else a loud throw (:3508-3511). The dead route get-active-member is no longer built by any SDK method (git grep at head: only ledger prose, tests and the changeset name it).
  3. Vendor premise — confirmed at the pinned version from source, not from the PR. packages/plugins/plugin-auth/package.json:40 pins "better-auth": "1.7.2"; in that tarball's dist/plugins/organization/routes/crud-members.mjs: getActiveMember reads session.session.activeOrganizationId only and never ctx.query (400 NO_ACTIVE_ORGANIZATION / 400 MEMBER_NOT_FOUND / 200 row); listMembers resolves ctx.query?.organizationId || session.session.activeOrganizationId, then findMemberByOrgId({ userId: session.user.id, organizationId })FORBIDDEN YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION before any filter; limit schema is z.string().or(z.number()).optional() coerced with Number(...), filterField/filterValue are free strings → the query the SDK sends is accepted as-is. get-session answers ctx.json(null) for an anonymous caller (api/routes/session.mjs:157); orgSessionMiddleware wraps sessionMiddleware (call.mjs:11) → 401 on the second request, server-thrown, nothing client-invented. The runtime mount (packages/runtime/src/domains/auth.ts:138) returns the auth service's Response with its body untouched, so the bare { user, session } / null the SDK types inline is what a deployed server serves.
  4. Security — no authorization widening. Read authorised by the caller's own membership in the named organisation, not by session state; filterValue is fixed to the caller's own id; an arbitrary filter would expose no more than organizations.listMembers (same route, already sdk) does today.
  5. Server side — unchanged. packages/rest untouched; plugin-auth moves only auth-route-ledger.ts (three rows). That module has zero non-test importers at head and is not a tsup entry (tsup.config.ts:40: ['src/index.ts', 'src/rate-limit-storage.ts']), so nothing published moves in @objectstack/plugin-auth and no changeset is owed there. Rebooking get-active-member to server-only ("Deliberately not SDK surface", type doc at ledger :60) is the right word — gap would assert the SDK should call a route that cannot answer the question — and the non-sdk note the conformance test demands (auth-route-ledger.conformance.test.ts:164) is present.
  6. Spec contracts — no packages/spec path; OrganizationMemberWithUserWire / OrganizationMembersPage (index.ts:1265, :1368) unchanged.
  7. Tests — 8 cases, no .skip/.only/.todo, under tsconfig.test.json's src/**/*. ① is the revert-reddening value pin, ⑦/⑧ are guard-the-guard legs, ⑤ pins the ADR-0112 envelope (code, httpStatus), ⑥ pins the anonymous 401 with two URLs on the wire. client-url-conformance.test.ts:389-393 catches a throw after the request, so with its placeholder body the method records both URLs and both match sdk rows.
  8. Clause-②: yes — right. Mechanical floor clean (no new symbol, key, signature or type). But per .claude/skills/pm-dispatch/references/contract-review.md:14-15 "在两个已发布码之间重选输入类" is judgement, and here the input classes reaching 400 MEMBER_NOT_FOUND / 403 YOU_ARE_NOT_A_MEMBER… / 400 NO_ACTIVE_ORGANIZATION / 200 are re-chosen, and the empty-id class now goes to a client throw. PR body line 2 is the literal Clause-②: yes; the governing claim 5594619517 says yes; needs:contract-review is on both carriers (PR labels and card labels read at review time).

Semver / changeset

  • @objectstack/client: minor — correct: clause-② yes + packages/client/src/** moved ⇒ ≥ minor (level-axis, [finding] No gate answers whether a changeset's LEVEL fits the surface — Check Changeset is green on patch and on minor for the same diff #16055); patch was the red at 012d430b, fixed at eb75819. Launch-window rule: major is refused, so the level cannot carry breaking-ness.
  • **BREAKING** banner present (:7), required by ruling 5580367898. The gate's only carriers during the window are the banner and the ADR-0087 disposition — both present.
  • ADR-0087 not-required (no-migration-prescription): on the merits, not only on detector silence — ADR-0087 registers metadata conversions (objectstack migrate meta, spec-changes.json), and this diff converts no metadata; runtime-interface-only is closed (dotted member path, and the change is behavioural), type-surface-only does not apply. findMigrationPrescription returns null on the body (gate exit 0 offline and in CI Check Changeset, the job that hosts both steps, success 03:29Z on this head).
  • No @objectstack/plugin-auth entry — correct (item 5).

Boundary flags

  • Governed paths: none. The four files hit none of docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/** (register in scripts/pm/check-governed-merges.mjs; CI "Governed Surface Queue Guard" success). Lands through the queue after the owning seat clears the carriers; not maintainer-by-hand.
  • Dev's latest os-dev-report is 5579200601 (2026-09-08T04:26Z, on eb75819): open_questions: []; deviations: [FOOTER] — a PR-body attribution-footer duplication, corrected by read-back; no contract effect, accepted. The delivery report 5578965259: open_questions: []; deviations SCOPE (ledger row — accepted, verified in item 5), CHANNEL / RESOURCE / BASE (tooling, no contract bearing — noted), MEASUREMENT SITE (probe not shipped — accepted: this seat read the vendor handlers independently, item 3).
  • No os-dev-report exists for the patch round (d3ddfa11..4ebf8692): the dev was killed by a 429 before the gate union and the report (5597048143, dead-claim recovery; card now pm:queue, no assignee). See F1.
  • Sibling objectui calls better-auth's own organization.getActiveMember (packages/auth/src/createAuthClient.ts:850), not the ObjectStack SDK → no downstream consumer in the sibling moves. content/docs names getActiveMember nowhere; the drift check's one row (permissions/authentication.mdx, via get-session) lists the route as a route only — accurate.
  • Head is 34 behind / 9 ahead of origin/main; none of the 34 touch the PR's four files; git merge-tree --write-tree clean. GitHub reported mergeable_state: unknown at read time (not yet recomputed) — a dry merge says clean.

Findings

F1 — non-blocking (process): the patch-round increment has no os-dev-report. d3ddfa11, b4dc6258, 4ebf8692 were pushed, the PR body was updated, and then the dev died (5597048143). CI on the head is green end-to-end and this seat re-ran both changeset gates offline, so the contract surface is covered; the report is still owed by whoever re-claims (card is pm:queue, unassigned). Expectation: the next dispatcher posts it per the resume shape in 5597048143; no code change.

F2 — non-blocking (wording accuracy in shipped CHANGELOG text): the "before" on the non-member bullet is over-stated. .changeset/…names-the-organisation.md:23 and packages/client/src/index.ts:3456-3457 say a non-member of the named organisation was answered 400 MEMBER_NOT_FOUND before. From the vendor handler (item 3), the old route answered about the active organisation: a caller who was a member there got a 200 with the active row — the silent wrong answer — and 400 MEMBER_NOT_FOUND fired only when the caller also had no row in the active organisation. The PR's own ablation agrees (case ⑤ went red as "expected undefined to be 'YOU_ARE_NOT_A_MEMBER…'", i.e. the old shape resolved, it did not throw). The text originates in the prior review's F3 and the ruling that quoted it, so the dev implemented what was ruled; the "after" (403) is right, and bullet :22 already states the true before-state for every non-active id. Expectation: one-line correction in both places ("Before: a 200 carrying the active organisation's row, or 400 MEMBER_NOT_FOUND when the caller had no row there either") — travels with whatever patch posts the F1 report; not a landing blocker.

F3 — non-blocking (informational): the falsy-id refusal is a plain Error. index.ts:3484 throws without code/httpStatus, unlike the server refusals the method surfaces in the ADR-0112 envelope. This matches the review's own F2 expectation and the pre-existing empty-page throw at :3509-3511; a caller branching on err.code sees undefined for this one input. No action in this PR.

F4 — non-blocking (informational): organizations.getActiveMember is pinned by URL and value, no longer by name. No client: field in the ledger names it (the notes do, but auth-route-ledger-coverage.test.ts:57 resolves client: only). The URL conformance sweep and cases ①–⑧ carry it. Same residual the prior review recorded; no action.

F5 — non-blocking (disclosure to the maintainer): the ADR-0087 exemption rests on before/after phrasing. The ruling asked for "FROM→TO lines"; they are delivered as Before/After observations (:22-25) with one hint ("auth.me() is where that id is readable"), per execution note 5594607180, whose veto window was not exercised. The category is right on substance (no metadata conversion), so this is a disclosure, not a defect.

CI at read time

Head 4ebf8692d9c5cfb896c6d4d03c50a6af289b2278: 37 check runs, 33 latest-per-name: 28 success, 5 skipped, 0 failure, 0 in progress. Skipped (all label-gated or opt-in): Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in). Green include Check Changeset (03:29:15Z — hosts the ADR-0087 and no-major steps), Lint & Repo Gates, all four Type Check · lanes + aggregator, Test Core 6/6 + aggregator, Dogfood Regression Gate 3/3, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard, both single-writer/issue-claim guards. PR is draft with needs:contract-review; card carries needs:contract-review, pm:queue, no assignee.

Implemented-by: branch claude/issue-16568-get-active-member-organization-id
Reviewed-by: director seat summon #20 (isolated fable subagent, transcript-verified before adoption)

{"pr":16761,"head":"4ebf8692d9c5cfb896c6d4d03c50a6af289b2278","verdict":"PASS WITH FINDINGS","blocking":[],"clause2":"yes","semver_ok":true,"governed":false,"ci":"33 latest-per-name: 28 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke), 0 failure, 0 in_progress"}


Generated by Claude Code

…he named organisation

The changeset bullet and the `getActiveMember` docblock both said a caller who
was not a member of the NAMED organisation used to draw `400 MEMBER_NOT_FOUND`.
better-auth 1.7.2's `get-active-member` handler reads
`session.session.activeOrganizationId` and never `ctx.query`, so the named
organisation was never consulted at all: such a caller drew a 200 carrying the
ACTIVE organisation's row, and `MEMBER_NOT_FOUND` fired only when the caller
had no row in the active organisation either. The PR's own ablation agrees —
case ⑤ went red as "expected undefined to be 'YOU_ARE_NOT_A_MEMBER…'", i.e.
the old shape resolved rather than throwing.

Both sentences now state that before-state. The `after` (403) was already
right, and the neighbouring bullets already stated it for every other input.

Prose only: the changeset body ships as CHANGELOG text and the docblock is a
comment. No executable line, no test and no behaviour moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Js5kTpTtxieBjPyScgxJ3

Copy link
Copy Markdown
Collaborator

Landing note — the standing PASS WITH FINDINGS (5597568086, head 4ebf8692d9) carries to head f6c694123d7deaf843623d1ccfcefecac651c6c4; carriers come off both faces (director seat, summon #18 segment 3, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-09T09:4xZ)

Delta verified by this seat at CONTRACT_REVIEW_TIER (git diff 4ebf8692d9..f6c694123d): 2 files, +5/−3, text only — the changeset bullet .changeset/client-get-active-member-names-the-organisation.md:23 and the getActiveMember docblock packages/client/src/index.ts:3455-3459. Both now state the true before-state for a non-member of the named organisation: the named organisation was never consulted, the answer was about the active one (a 200 with the active organisation's row, or 400 MEMBER_NOT_FOUND when the caller had no row there either). No executable line, no fixture, no test quotes either sentence (git grep on both fragments → the two corrected sites only). ⇒ Verdict F2 discharged; F1 (the owed os-dev-report) discharged at #16568 5599246164 with the gates re-run on this head (check-changeset-no-major 0 · check-adr-0087-registration 0 not-required (no-migration-prescription) · @objectstack/client test 0, 36 files / 462 tests · typecheck 0 · repo-wide eslint 0). The accept-set findings of the original verdict are unchanged by a text-only delta, so no re-review is owed.

Two deviations the round recorded, both accepted as-is: the commit trailer names the model that actually did the round rather than the one the brief prescribed — correct, a trailer is evidence of who worked; and the PR body's own "about a different organisation at that" sentence carries the same over-statement the changeset had — not shipped text, left alone rather than rewriting an intact body (the changeset and docblock are what ship).

Chain: needs:contract-review off this PR and off #16568 (one write each, read back); ready + auto-merge armed once CI on f6c69412 is green (09:4xZ reading: 16 success / 11 in progress / 3 skipped / 0 failure — queue entry waits for green, per the queue-entry rule). Non-governed code PR; card closes by Fixes #16568 on merge.

Implemented-by: claude/issue-16568-get-active-member-organization-id (tail round under claim 5598822528) · Reviewed-by: 5597568086 (isolated claude-fable-5-1, summon #20) + this seat's delta reading.


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review September 9, 2026 09:34
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Enqueue record — director seat, summon #18 segment 3 (session_017Js5kTpTtxieBjPyScgxJ3, GitHub huangyiirene).


Generated by Claude Code

Merged via the queue into main with commit f904e61 Sep 9, 2026
38 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-16568-get-active-member-organization-id branch September 9, 2026 09:59
os-zhuang pushed a commit that referenced this pull request Sep 11, 2026
`check:vendor-version-stamps` went red on this branch and nowhere else — the
same gate exits 0 on an unmodified `main` checkout, so the ten drifted stamps
are this PR's own doing. CI has not said so yet only because the lint job
fail-fasts on the census check ~900 lines earlier.

⛔ Not a 1.7.2 -> 1.7.3 substitution. The gate's own words: a stamp attests that
a behaviour was MEASURED against the version it names, and changing the number
without redoing the measurement manufactures a claim nobody made. Each site was
judged on its own, and the ten do NOT take the same route.

RE-MEASURED against the installed 1.7.3, then restamped AND dated (route a) —
six sites whose claim is a STATIC reading of the vendor's published files, which
is cheap to take again and worth taking, because the lift is exactly the event
that could have invalidated it. Every one came back unchanged:

  cli/src/commands/init.ts:178 — `@better-auth/scim@1.7.3` still peers
  `@better-auth/utils@0.4.2` exactly (read off the installed manifest).

  cli/src/commands/init.ts:509 — nothing in better-auth 1.7.3's published files
  references better-sqlite3 except its own peer declaration. The sentence is
  also made precise: it used to say "no file … at all", and package.json is a
  file that does reference it.

  plugin-auth/src/auth-schema-config.ts:954 — `SCIMOptions` at 1.7.3 still
  declares no `schema` / `modelName` / `fields`; its six members are unchanged.

  plugin-auth/src/list-user-invitations-verification.ts:11 — 1.7.3's
  `crud-invites.mjs` still asks `shouldRequireVerifiedEmailForInvitationIdAction`
  on the three id-addressed routes and still throws unconditionally in
  `listUserInvitations`. The defect this file repairs is still minted upstream.

  plugin-auth/src/auth-email-locale.test.ts:869 and :1001 — at 1.7.3
  `/sign-in/magic-link` still sends with no user lookup, `/magic-link/verify`
  still creates the user unless `disableSignUp`, `signInMagicLinkBodySchema` is
  still `z.email()` with no case transform, and `findUserByEmail` still matches
  on `email.toLowerCase()`.

  plugin-auth/src/auth-manager.ts:3888 — 1.7.3's `db/adapter-base.mjs` still
  builds `memoryDB` from `Object.keys(tables)` (the schema KEY) while
  `@better-auth/memory-adapter` still throws on a lookup by resolved model name.

ANCHORED, deliberately NOT restamped (route b) — three sites whose reading came
from a DRIVE, not from a file. Restamping them would claim a drive that was
never re-run:

  client/src/index.ts:3656 — measured over a real `AuthManager` + `SqlDriver`;
  anchored to the date and card that took it (2026-09-09, #16761) and scoped to
  "the then-installed 1.7.2", with the non-re-run said out loud.

  plugin-auth/src/scim-connection-service.ts:55 — the reading is an ABLATION of
  a REJECTED design (`enterWith` losing the store). Re-running it would mean
  re-breaking the scope to watch it fail. Anchored to 2026-09-02 / #14624, and
  the sentence now points at `scim-transaction-scope.test.ts`, which pins the
  SHIPPED behaviour at run time against whatever version is installed.

  plugin-auth/src/account-issuer-upgrade-path.test.ts:27 — the version named is
  the PRE-upgrade runtime this fixture models. 1.7.2 is not installed any more
  and cannot be, which is the premise of the whole file, so it is scoped and
  dated rather than re-measured. ⛔ Not "then-installed": 1.7.3 was already
  installed when this file was written; the honest scope is that the reading
  came off the derivation this branch retires.

VERIFIED. `pnpm check:vendor-version-stamps` exits 0 (self-test 64 checks, then
6980 files scanned). Seven of the eight files are provably COMMENT-ONLY: each
was transpiled with `removeComments` at HEAD and at the working copy and the
emitted JS hashes are equal, with a `const`->`let` control proving the
instrument can say no. `init.ts` is the exception BY DESIGN — its stamp lives in
string literals the scaffold writes into a user's project — so its scaffold
tests were run: 3 files / 62 tests passed. Repo-wide `pnpm lint` exits 0;
typecheck green on cli, client and plugin-auth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…ccount.issuer, retire the backfill, lift the family to 1.7.3 (objectstack-ai#17454)

Fixes objectstack-ai#17440

Maintainer ruling 2026-09-10 on objectstack-ai#16629, option 1: adopt better-auth's
account-issuer
rollback. `sys_account.issuer` retires with the backfill that served it,
and the
`@better-auth/*` family lifts to an exact `1.7.3` in one line.

Verified at `e577e0eb4`.

---

## ⭐ The finding that shaped the migration

The card asks for a pre-flight that detects rows sharing `provider_id` +
`account_id`
and differing only in `issuer`. Measuring the premise first changed how
that pre-flight
had to be built:

**`sys_account` has declared `{ fields: ['provider_id', 'account_id'],
unique: true }`
since the object was created.** `git log -S` puts it in the commit that
created the
object; the `(issuer, account_id)` pair arrived much later, with the
1.7.0-rc.2 bump
(objectstack-ai#3632). So the "new" key is not new — it long predates the column being
dropped, and
wherever that index is physically present the collision class is refused
at write time.

That does not make the pre-flight unnecessary. It makes one thing about
it load-bearing:

⚠️ **"Declared" is not "present."** `syncDeclaredIndexes` logs a plain
UNIQUE whose
CREATE fails on existing duplicates onto the durability channel and lets
the boot
continue (objectstack-ai#14902 / objectstack-ai#15479) — deliberately, so one dirty table cannot
take a deployment
down. A database that ever held duplicates therefore carries the
declaration and not the
constraint, and can still hold the class today.

⇒ **On such a database the drop does not blow up. It degrades
silently:** the rows become
indistinguishable, `findAccountByKey` resolves whichever the driver
hands back first, and
a sign-in can land on the wrong user's account. That is strictly worse
than a failed
apply, and it is why the pre-flight reads **rows**, never the index
declaration.

## The ceremony — reused, not invented

ADR-0131 D10 fixes the shape and says in the same breath that it
*"reuses the ADR-0120 D4
migration ceremony where it exists (index and column changes) rather
than inventing a
second one."* A column drop plus an index re-key is exactly ADR-0120
D4's class, and this
repository already ships every leg of it:

| leg | what runs it | new here? |
|:--|:--|:--|
| plan | `os migrate plan` reports the drop as destructive drift | no |
| ⭐ row pre-flight | **`os migrate account-issuer`** — read-only, exits
non-zero | **yes — this was the gap** |
| backup | the operator's act, and the apply step's stated precondition
| no |
| apply | `os migrate apply --allow-destructive`, **which now refuses
this drop while the pre-flight is dirty** | refusal is new |
| post-check | re-run `os migrate account-issuer`; it reads zero | no |
| boot refusal | `runArtifactBootMigrationGate` already fails the boot
on unapplied destructive drift, naming the command; `os serve` never
auto-migrates | no |

So the smallest honest addition was the read-only pre-flight D4 asks for
on a **narrowing**
index change, plus a refusal in front of the drop. An `os migrate
account-issuer --apply`
that dropped the column itself would be the second ceremony D10 forbids,
and it would drop
a column outside the drift reconciler that owns every other column drop.

⛔ No `sys_migration` flag, deliberately — `os migrate summary-nulls`
documents the rule
that a deployment flag nothing reads is a fact nothing reads. The
consumer of this verdict
is the gate in `os migrate apply`, which re-runs the probe against the
live database at the
moment it matters; a row saying "clean on Tuesday" authorises nothing on
Thursday.

### Refusal discipline

Two readings are deliberately **not** reported as clean, because a
pre-flight that cannot
see is not a pre-flight that found nothing:

1. **A read that throws refuses.** The retired
`backfill-account-issuer.ts` wrapped its
reads in `try { … } catch { return [] }` — correct for an idempotent
best-effort pass
that runs again next boot, and exactly wrong for an answer that
authorises an
   irreversible drop.
2. **A truncated walk refuses.** An unenumerated tail is not zero rows.

⛔ Nothing is merged or deleted for the operator: which row survives is
application
knowledge, and two different people can be behind one colliding key.

## ⚠️ The re-pointed provider — answered, and pinned

**A `provider_id` re-pointed at a different IdP must have its account
bindings REBUILT. No
key separates them, and after the column drop nothing can.**

`sys_sso_provider` declares `{ fields: ['provider_id'], unique: true }`,
so within an
environment `provider_id → issuer` is a function and `(provider_id,
account_id)`
determines what `(issuer, account_id)` determined — for as long as that
function holds.
Re-pointing breaks it. If the new IdP mints a `sub` the old one had
already issued to
somebody else, the new key resolves that sign-in onto the **other
person's** account row.

⚠️ Under the old key that shape failed **loudly**: `findAccountByKey`
missed the old row,
better-auth tried to insert, and the long-standing `(provider_id,
account_id)` unique
refused it — the user saw `unable_to_link_account`. Under the new key it
resolves
silently. The narrowing turns a loud refusal into a quiet cross-user
sign-in, which is why
this is answered rather than left to a constraint.

**Enforced at the re-point, because that is the last moment the
distinction exists.** After
the drop no column records which IdP vouched for a row, so no runtime
check can tell an old
binding from a new one. `refuseIssuerRepointWithLiveBindings` sits on
the `sys_sso_provider`
update doors and declines an issuer change while accounts are still
bound to that
`provider_id` (`RESOURCE_CONFLICT` / 409). The operator deletes the
stale bindings; each
user re-links on their next sign-in.

Pinned by five cases, including the one that states the answer directly
— two rows under
one `provider_id` differing only in issuer are **one key**, two issuers,
two people.

## The two flagged items

**`showcase-demo-personas-loginable.dogfood.test.ts`** keeps its file
and its real half.
The issuer assertion is **replaced, not dropped**: its job was "the
account is resolvable
under the key sign-in uses", and the key is now `(provider_id,
account_id)` — so that is
what it asserts, with the admin's own better-auth-minted account as the
same positive
control the issuer case carried, plus a new assertion that the retired
column is **absent**.
The header quotes the old assertion verbatim and records why it went
away, so the trap that
bit four checklist items is not lost with the field that caused it.

**`check:vendor-export-contract` is not loosened.** It still requires an
exact declared
range, agreement with the installed version, and real resolution of
every named symbol.
Its self-test carried the instruction *"if the durable fix landed,
retire this case with
it"* — this is that fix. ⛔ Retiring the **specimen** is not retiring the
case: what it
catches is a collector that has silently stopped reaching publishable
source, which is how
objectstack-ai#16186 passed over nothing for three releases. So it re-anchors on a
live edge
(`better-auth/adapters` → `createAdapterFactory`) and still asserts a
**named** symbol, and
a new case asserts the two deleted names are imported nowhere.

## Out of scope, untouched

⛔ objectstack-ai#11627's hash-shadow-key machinery stays — a generic driver capability
serving five
UNIQUE members of the >768-char class. The one place it was cited as an
illustration
(`platform-keyed-text-bounds.test.ts`) moves to a **measured** surviving
member of that
class, `sys_oauth_access_token.token` (1024), rather than a
plausible-looking name.

---

## Verification

⚠️ **Declared narrowing — verification ran UNLOCKED.**
`scripts/pm/os-verify-lock.sh` could
not take the shared verify lock on this host: no usable `flock`. The
shared verify lock is
declared Linux-only (`flock` is util-linux, and a stock macOS does not
ship it), so the
commands below were run directly, without the lock — a declared
narrowing, not a silent
one. No serialization guarantee held for these runs.

⚠️ **Also declared: `TMPDIR` was pointed at a non-symlinked path** for
the CLI and dogfood
suites. On macOS `/var` is a symlink to `/private/var`, and ten CLI
cases compare a path
the test itself built from `tmpdir()` against the realpath Node returns.
Proven to be the
host and not this diff: the same three files, unchanged on this branch,
pass **41/41** under
`TMPDIR=/private/tmp/…`. CI runs on Linux, where `/tmp` is not
symlinked.

### Acceptance

**① The pre-flight refuses on a fixture containing the collision class —
watched refusing.**

```
✓ objectstack-ai#17440 the preflight REFUSES on the collision class > refuses, naming the rows, when one key is held by two rows differing only in issuer
✓ … > flags a same-user collision WITHOUT the cross-user marker
✓ CONTROL — a clean table passes …          ✓ CONTROL — an empty table is clean …
✓ a read that throws refuses instead of reporting zero rows
✓ a walk stopped by its row cap refuses instead of reporting a partial scan as clean
Test Files 1 passed (1) · Tests 15 passed (15)
```

Every refusal asserts the ADR-0112 envelope (`code` **and** `status`)
and the substance of
the message — never a bare `toThrow()`, which would pass on a fixture
that never reached
the probe.

⭐ **The fixture registers an index-less `sys_account` on purpose, and
the file says why.**
The `PREMISE` case proves the class cannot be inserted where the
declared unique is
physically present, by trying against the **real** object and watching
the driver refuse
(with a control: a different `account_id` inserts fine). So the only
population that can
hold the class is a deployment carrying the declaration without the
constraint — which is
exactly what the fixture models.

**② Fresh install and existing-data upgrade both end with working
sign-in over a real auth
route.**

Fresh — the real showcase boot:

```
✓ each persona holds a credential account resolvable under the SAME key better-auth uses for the admin
✓ each persona SIGNS IN over the real auth route, and the session resolves to that persona
Test Files 1 passed (1) · Tests 4 passed (4)
```

Existing data — two engines over one SQLite file (engine A declares
`issuer` and signs a
user up through the real HTTP route so the hash is better-auth's own;
engine B on the same
file registers today's objects: new code, old table):

```
✓ a 1.7.2-era account still SIGNS IN over the real auth route after the column is undeclared
✓ the undeclared column is NOT silently dropped by schema sync — the drop stays the operator's deliberate act
✓ the pre-flight reads CLEAN on that database, which is what authorises the drop
✓ and sign-in still works once the column is actually GONE — the far side of the ceremony
Test Files 1 passed (1) · Tests 4 passed (4)
```

Both sign-ins are judged by the principal the session resolves to, never
by a status. Both
`PRAGMA` reads carry a control — `not.toContain` passes vacuously on an
empty array, which
is the one reading this must never produce by accident.

**③ The re-pointed-provider answer is stated and pinned** — stated
above, pinned by the five
cases in `account-identity-preflight.test.ts`.

**④ `check:vendor-export-contract` — both directions proven.**

Passing at `1.7.3`:

```
check-vendor-export-contract --self-test OK (1 governed vendor family)
VERDICT: PASS — vendor export contract (installed workspace), 1 edge(s) verified
  ✓ better-auth/adapters @ better-auth@1.7.3 (1 symbol(s): createAdapterFactory)
```

Still failing when pointed at a symbol the pinned version does not
export — an ablation on
the committed tree, mutation confirmed on disk before the measurement
and the restore proven
by hash:

```
HEAD blob hash: 1b1d1b5
--- before: original present=1, injected present=0
--- after:  original present=0, injected present=1
--- MUTATION CONFIRMED ON DISK ---
ABLATED_EXIT=1
VERDICT: FAIL — vendor export contract (installed workspace)
  - better-auth/adapters at better-auth@1.7.3 does not export resolveAccountIssuerForProvider
    — imported statically by @objectstack/plugin-auth. A static ESM named import of a
      missing export is a link-time SyntaxError: the package does not load at all.
post-restore blob hash: 1b1d1b5
--- RESTORE PROVEN: hash matches HEAD blob, git diff HEAD empty ---
```

No rebuild leg is owed: this gate parses publishable **source** and
resolves against
`node_modules`, so no `dist/` sits between the mutation and the verdict.

**⑤ The changeset carries its ADR-0087 disposition and the FROM → TO
mapping.**
The changeset carries the disposition marker naming
`sys-account-issuer-retired` (spelled as the
HTML comment the gate reads; not reproduced here, because this body's
sanitizer eats
angle-bracket fragments). The entry is added under
protocol major 18 and `registry.ts` plus both projections regenerated.

⚠️ **Graded `minor`, not `major`.** The dispatch card asked for a major
arm;
`check-changeset-no-major` refuses a major in this launch window, and
the live convention
carries breaking-ness with a **BREAKING** banner plus the ADR-0087
disposition. Flagged
rather than silently chosen.

### Suites and gates

```
@objectstack/plugin-auth  test        106 files · 2243 tests · all passed
@objectstack/plugin-auth  typecheck   OK (+ check:test-typecheck)
@objectstack/cli          test        234 files · 3040 tests · all passed
@objectstack/dogfood      personas    1 file · 4 tests · all passed
typecheck  cli · client · platform-objects · spec · example-showcase — all Done
```

Gates run locally, all exit 0: `check:nul-bytes` ·
`check:vendor-export-contract` ·
`check:adr-0087-registration` · `check:override-consistency` ·
`check:changeset-gate-self-tests` ·
`check:error-code-casing` · `check:doc-authoring` · `check:i18n` ·
`check:i18n-coverage` ·
`check:i18n-stale-fill` · `check:i18n-walk-parity` ·
`check:cli-command-ids` ·
`check:cli-examples-parity` · `check:test-source-alias` ·
`check:cross-package-test-inputs` ·
`check:engine-double-contract` · `check:dts-closure` ·
`check:published-readme-exports` ·
`check:pm-widening-tells` · `check:single-claim-paths` ·
`check:route-envelope` ·
`check:error-status-conformance` · `check:agent-test-spelling` ·
`check:pm-governed-prose` ·
`check:partof-closing-keyword`, and in `@objectstack/spec`:
`check:migration-registry` ·
`check:spec-changes` · `check:upgrade-guide` · `check:api-surface` ·
`check:export-origins` ·
`check:exported-any` · `check:liveness` · `check:authorable-surface`.

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`
derives **110**
families for this change set and was re-derived after the diff grew (no
new families). The
remainder is the repo-wide farm, which CI runs exactly once — a declared
narrowing, not an
omission.

⚠️ `check:nul-bytes` caught a real defect of mine mid-run: two raw NUL
bytes in the
pre-flight's composite map key, an escape materialised into the byte
while the file was
being written. The key is now `JSON.stringify([providerId, accountId])`
— no delimiter
ambiguity and no control byte at all.

## A neighbouring behaviour change the family lift brought with it

better-auth `1.7.3` added the objectstack-ai#10700 gate one layer above ours:
`/two-factor/enable` throws
`TOTP_ALREADY_ENABLED` when a two-factor row exists with `verified !==
false`. Measured:
that code appears in **0** files in 1.7.2 and **5** in 1.7.3, against a
control code present
in both (5 / 5).

⭐ **Upstream's gate READS `verified` — the exact field objectstack-ai#10700 was about
— so
`two-factor-reenrollment-verified-reset.ts` is what keeps that gate's
input truthful. ⛔ It
is not dead code superseded by the vendor.** The re-enrollment legs now
assert the upstream
refusal envelope plus the property behind it (nothing rotated behind the
refusal); the
inertness assertion moves to the unconfirmed path, which is the one
upstream's gate still
admits; and rotation moves to `disable → enable → confirm`.

---

## Merged with `main` — the conflict that was running zero CI

This PR sat **conflicting, and a conflicting PR runs nothing**:
`mergeable=false`,
`mergeable_state=dirty`, **0 workflow runs** on its head. Its check list
was the
previous head's and said nothing about this one. Two census artefacts
conflicted,
both generated — `content/docs/permissions/tenant-audit-census.mdx` and
`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`. main's
`2a79726ac`
(objectstack-ai#17436, verified here rather than taken on trust) had independently
re-run the
same census, so both sides rewrote the same `Measured on` line and the
same
corpus-scale table.

⭐ **The order is fixed and it is not the obvious one** — regenerating
while the
tree is still in MERGE state rolls a generated anchor back to the
branch's old
fork point, and a rolled-back artefact is still *authentic*, so every
gate passes
while a landed advance is quietly undone. `scripts/pm/os-regen-merge.sh`
mechanises the right order and was used:

1. `bash scripts/pm/os-regen-merge.sh` — fetched, merged, and stopped
exactly
where it should: neither conflicted path is routed to the
`merge=os-regen`
driver, and the script refuses to resolve non-generated files on your
behalf.
⭐ Measured before resolving anything: of the **six** paths changed on
both
sides of this merge, **zero** are os-regen paths — so the driver's
silent-drop
hazard did not apply here and the script's step 2 had no work to do. ⛔
It was
deliberately **not** re-run after the merge commit: its own header says
the
   base must be read BEFORE step 1, and afterwards `git merge-base HEAD
origin/main` is main's own tip, which makes step 2 inert for the wrong
reason.
2. The conflicted block was resolved to main's side and **committed as
the merge**
— a placeholder, and the commit message says so, because a census block
is an
   answer to a tree and the merged tree is neither side's.
3. `node scripts/tenant-audit-census.mjs --write`, on the committed
merge.
4. Every prose figure re-derived from that census.

### Re-derived, never carried forward

⛔ No figure below was copied from a CI log, from the pre-merge branch,
or from
main. A script imported the gate's own `PROSE_COUNTS`, applied the same
`splitPage` plus whitespace normalisation the gate applies — the page is
hard-wrapped at 80 columns, so an un-normalised match is a false NO
MATCH, which
is how six rows first read as missing — and evaluated `expected(census)`
against
the page for all 23 enforced rows:

```
enforced rows: 23, failures: 0
```

Only corpus scale moved: `engine-shaped types recognised` 58 to 59, plus
the
dated marker. `sources scanned` 562, `declared objects` 300 and
`non-engine
calls` 137 arrived with main's re-run and the merged tree reproduces all
three.
The **population held exactly still** — 222 write call sites, 148
decidable, 9
provable-and-tenancy-enabled, 32 unreadable — which is why no enforced
prose
figure needed an edit. That is a measurement, not an assumption.

⚠️ **The claims that ride on a figure without quoting it — the class no
gate can
see — were re-checked against the same census:**

- `44 of 222` reached through an erased receiver is **19.8%**, and a
fifth of 222
is 44.4, so *"just under a fifth"* still holds. main's side of that
sentence
reads *"better than a fifth"* at `45 of 222`: true for main's tree,
false for
the merged one. The auto-merge kept the branch's corrected wording, and
this is
  the reading that confirms it.
- `104 of 222` decidably elevated is **46.9%**, so the quoted `(47%)`
still rounds
true. The gate captures the count and the population out of that
sentence and
  leaves the per cent unread.
- `Across 300 declared objects` is the one UNENFORCED prose figure —
required to
be present, never compared. It came in from main's side and the
regenerated
  scale row agrees with it.

`node scripts/check-tenant-audit-census.mjs` and its `--self-test` both
exit 0 on
the merged tree.

---

## The ten version stamps the 1.7.3 lift falsified

`check:vendor-version-stamps` was red, and it is this PR's own doing —
the same
gate exits 0 on an unmodified `main` checkout. CI had not reported it
because the
lint job fail-fasts on the census check, roughly 900 lines earlier.

⛔ **Not a `1.7.2` to `1.7.3` substitution.** The gate's own reason: a
stamp
attests that a behaviour was MEASURED against the version it names, so
changing
the number without redoing the measurement manufactures a claim nobody
made,
which is worse than a stale one. The ten sites were judged one at a
time, and
they split **7 / 3**.

### Route (a) — re-measured against the installed 1.7.3, then restamped
AND dated

Seven sites whose claim is a **static reading of the vendor's published
files**.
Cheap to take again and worth taking, because a family lift is precisely
the
event that could invalidate one. All seven came back **unchanged**:

| site | what was re-read at 1.7.3 |
|:--|:--|
| `packages/cli/src/commands/init.ts:178` | `@better-auth/scim@1.7.3`
still peers `@better-auth/utils@0.4.2` exactly, off the installed
manifest |
| `packages/cli/src/commands/init.ts:509` | nothing in better-auth
1.7.3's published files names better-sqlite3 except its own peer
declaration |
| `packages/plugins/plugin-auth/src/auth-schema-config.ts:954` |
`SCIMOptions` still declares no `schema` / `modelName` / `fields` — the
same six members |
|
`packages/plugins/plugin-auth/src/list-user-invitations-verification.ts:11`
| `crud-invites.mjs` still asks the helper on the three id-addressed
routes and still throws unconditionally in `listUserInvitations`, so the
defect this file repairs is still minted upstream |
| `packages/plugins/plugin-auth/src/auth-email-locale.test.ts:869` |
`/sign-in/magic-link` still sends with no user lookup;
`/magic-link/verify` still creates the user unless `disableSignUp` |
| `packages/plugins/plugin-auth/src/auth-email-locale.test.ts:1001` |
`signInMagicLinkBodySchema` is still `z.email()` with no case transform;
`findUserByEmail` still matches on `email.toLowerCase()` |
| `packages/plugins/plugin-auth/src/auth-manager.ts:3888` |
`db/adapter-base.mjs` still builds `memoryDB` from `Object.keys(tables)`
— the schema KEY — while `@better-auth/memory-adapter` still resolves by
model name and throws |

⚠️ One of them was also made **more accurate** rather than merely
restamped:
`init.ts:509` said better-sqlite3 is referenced by *"no file in the
published
package at all"*, and `package.json` is a file that references it. It
now reads
*except that peer declaration itself*.

### Route (b) — anchored, deliberately NOT restamped

Three sites whose reading came from a **drive**, not from a file.
Restamping
these would assert a drive nobody re-ran.

| site | why anchoring is the honest route |
|:--|:--|
| `packages/client/src/index.ts:3656` | measured over a real
`AuthManager` plus `SqlDriver`. Anchored to the date and card that took
it (2026-09-09, objectstack-ai#16761), scoped to "the then-installed 1.7.2", and the
sentence now says out loud that the drive has not been re-run against
the lifted family |
| `packages/plugins/plugin-auth/src/scim-connection-service.ts:55` | ⭐
the reading is an **ablation of a REJECTED design** — `enterWith` losing
the store. Re-measuring would mean re-breaking the scope to watch it
fail again. Anchored to 2026-09-02 / objectstack-ai#14624, and the sentence now points
at `scim-transaction-scope.test.ts`, which pins the SHIPPED behaviour at
run time against whatever version is installed |
|
`packages/plugins/plugin-auth/src/account-issuer-upgrade-path.test.ts:27`
| the version named is the **pre-upgrade runtime this fixture models**.
1.7.2 is not installed any more and cannot be — that is the premise of
the whole file — so it is scoped and dated. ⛔ Not "then-installed":
1.7.3 was already installed when this file was written, so the honest
scope is that the reading came off the derivation this branch retires |

### Proof that the repair changed prose and not behaviour

Seven of the eight touched files are provably **comment-only**: each was
transpiled with `removeComments` at HEAD and at the working copy, and
the emitted
JS hashes are equal — with a `const` to `let` control on every file
proving the
instrument can say no, so "identical" is not a vacuous verdict. ⛔ A raw
scanner
is **not** sound for this question (template literals and
regex-versus-division
need parser context); the first attempt using one reported three false
differences before it was replaced with a real parse and emit.

`packages/cli/src/commands/init.ts` is the exception **by design** — its
stamp
lives in string literals the scaffold writes into a user's project, so
it is a
real change to emitted content. Its scaffold tests were therefore run:

```
Test Files  3 passed (3)     Tests  62 passed (62)
  test/init.test.ts · test/scaffold-workspace-consistency.test.ts
  test/better-sqlite3-peer-declaration.pin.test.ts
```

### Gates, at the commit that carries them

Union re-run **after** the final commit, `a760606a6`, all exiting 0:
`check-tenant-audit-census` and its `--self-test` ·
`check:vendor-version-stamps`
(self-test 64 checks, then 6980 files scanned) · `check:nul-bytes` ·
`check:doc-authoring` · `check:corpus-claim-drift` ·
`check:pm-governed-prose` ·
`check:scaffold-emission-policy` · `check:cli-examples-parity` ·
`check:type-check-coverage` · `check:type-check-debt`.

Repo-wide `pnpm lint` exits 0 (24s — not narrowed, so no narrowing needs
declaring). `typecheck` green on `@objectstack/cli`,
`@objectstack/client` and
`@objectstack/plugin-auth`, over freshly built dependency closures.

⚠️ **Declared narrowing, same as the section above: these runs were
UNLOCKED.**
`scripts/pm/os-verify-lock.sh` reports `NO USABLE flock` on this host —
the
shared verify lock is Linux-only — so every heavy command was routed
through the
entry point and ran in its declared unlocked mode, each printing
`VERDICT command-exit 0 · UNLOCKED (declared)`. No serialization
guarantee held.

⚠️ **A correction that cannot be made in place:** the commit message for
the
stamp repairs heads route (a) with "six sites" and then lists seven. The
split is
**7 / 3**, as the tables above show. Pushed history is not rewritten on
this
branch, so the correction lives here.

## Filed, not fixed

objectstack-ai#17453 — three `knownGap` texts in
`docs/qa/platform-checklist/areas/approvals.json` cite
the now-retired `backfill-account-issuer.ts`. Their own convention is
*the gap text stays
because it carries the reason*, so the right rewrite is a judgment call
about historical
record rather than a path substitution. No gate is red on it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---

_Generated by [Claude
Code](https://claude.ai/code/session_6679d191-11f4-465b-b322-0e0409d76793)_

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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

5 participants