fix(client): organizations.getActiveMember addresses the organisation the caller NAMES, not whichever one the session has active - #16761
Conversation
`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
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
…t-active-member-organization-id
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
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
Contract review (
|
…t-active-member-organization-id
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
|
Director seat adoption record — summon #20, Contract review (
|
| 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/16761 → exit 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
- Published client signature — unchanged.
getActiveMember: async (organizationId: string): Promise<OrganizationMemberWithUserWire>(index.ts:3479), byte-identical tomain;return-type-precision.test.ts:950still pins the return type; no export added or removed. - Wire behaviour — now two requests, addressing honest.
GET {auth}/get-session(withOrigin, the same shapeauth.meuses at:3989) →GET {auth}/organization/list-members?organizationId=<enc>&filterField=userId&filterValue=<enc self>&limit=1→page.members[0], else a loud throw (:3508-3511). The dead routeget-active-memberis no longer built by any SDK method (git grepat head: only ledger prose, tests and the changeset name it). - Vendor premise — confirmed at the pinned version from source, not from the PR.
packages/plugins/plugin-auth/package.json:40pins"better-auth": "1.7.2"; in that tarball'sdist/plugins/organization/routes/crud-members.mjs:getActiveMemberreadssession.session.activeOrganizationIdonly and neverctx.query(400NO_ACTIVE_ORGANIZATION/ 400MEMBER_NOT_FOUND/ 200 row);listMembersresolvesctx.query?.organizationId || session.session.activeOrganizationId, thenfindMemberByOrgId({ userId: session.user.id, organizationId })→FORBIDDEN YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATIONbefore any filter;limitschema isz.string().or(z.number()).optional()coerced withNumber(...),filterField/filterValueare free strings → the query the SDK sends is accepted as-is.get-sessionanswersctx.json(null)for an anonymous caller (api/routes/session.mjs:157);orgSessionMiddlewarewrapssessionMiddleware(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'sResponsewith its body untouched, so the bare{ user, session }/nullthe SDK types inline is what a deployed server serves. - Security — no authorization widening. Read authorised by the caller's own membership in the named organisation, not by session state;
filterValueis fixed to the caller's own id; an arbitrary filter would expose no more thanorganizations.listMembers(same route, alreadysdk) does today. - Server side — unchanged.
packages/restuntouched;plugin-authmoves onlyauth-route-ledger.ts(three rows). That module has zero non-test importers at head and is not atsupentry (tsup.config.ts:40:['src/index.ts', 'src/rate-limit-storage.ts']), so nothing published moves in@objectstack/plugin-authand no changeset is owed there. Rebookingget-active-membertoserver-only("Deliberately not SDK surface", type doc at ledger:60) is the right word —gapwould assert the SDK should call a route that cannot answer the question — and the non-sdknotethe conformance test demands (auth-route-ledger.conformance.test.ts:164) is present. - Spec contracts — no
packages/specpath;OrganizationMemberWithUserWire/OrganizationMembersPage(index.ts:1265,:1368) unchanged. - Tests — 8 cases, no
.skip/.only/.todo, undertsconfig.test.json'ssrc/**/*. ① 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-393catches a throw after the request, so with its placeholder body the method records both URLs and both matchsdkrows. - 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 reaching400 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 literalClause-②: yes; the governing claim5594619517saysyes;needs:contract-reviewis 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);patchwas the red at012d430b, fixed ateb75819. Launch-window rule:majoris refused, so the level cannot carry breaking-ness.**BREAKING**banner present (:7), required by ruling5580367898. 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-onlyis closed (dotted member path, and the change is behavioural),type-surface-onlydoes not apply.findMigrationPrescriptionreturns null on the body (gate exit 0 offline and in CICheck Changeset, the job that hosts both steps, success 03:29Z on this head). - No
@objectstack/plugin-authentry — 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 inscripts/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-reportis5579200601(2026-09-08T04:26Z, oneb75819):open_questions: [];deviations: [FOOTER]— a PR-body attribution-footer duplication, corrected by read-back; no contract effect, accepted. The delivery report5578965259: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-reportexists 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 nowpm:queue, no assignee). See F1. - Sibling
objectuicalls better-auth's ownorganization.getActiveMember(packages/auth/src/createAuthClient.ts:850), not the ObjectStack SDK → no downstream consumer in the sibling moves.content/docsnamesgetActiveMembernowhere; the drift check's one row (permissions/authentication.mdx, viaget-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-treeclean. GitHub reportedmergeable_state: unknownat 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
Landing note — the standing PASS WITH FINDINGS (5597568086, head
|
|
Enqueue record — director seat, summon #18 segment 3 (
Generated by Claude Code |
`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>
…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>
Fixes #16568
Clause-②: yes
The defect
organizations.getActiveMember(organizationId)builtGET /organization/get-active-member?organizationId=…. better-auth 1.7.2's handler for that path (plugins/organization/routes/crud-members.mjs) readssession.session.activeOrganizationIdand never looks atctx.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-membersbut said in writing it had not verified the query shape. It was driven first: a realAuthManager(better-auth 1.7.2, organization plugin, teams enabled) over a realSqlDriver(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: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=userIdreally 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 itsgetActiveMemberhandler 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:GET /get-sessionfor the caller's own user id (bare{ user, session }for a signed-in caller, the literalnullfor an anonymous one — measured);GET /organization/list-members?organizationId=…&filterField=userId&filterValue=SELF_USER_ID&limit=1, unwrapping the one-entry page.list-membersrows carry the identical shape —{id, organizationId, userId, role, createdAt, user:{id,name,email,image}}— which is whyOrganizationMemberWithUserWiredoes 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:
403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATIONwhere the old shape produced400 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;400 NO_ACTIVE_ORGANIZATION.setActivehas stopped being a precondition;401 UNAUTHORIZED, thrown by the same session middleware that guarded the old route. Nothing client-side is substituted for the server's refusal;Clause-②: yes — re-declared from the delivered diff
The dispatch carried a
noas 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-returnsis untouched,check:dts-closureandcheck:type-source-resolutionboth 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 ayestwice over.needs:contract-reviewis 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.tswas restored for one run.`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 movedef5fa760…to7fa9e129…;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;git checkout HEAD -- …, thengit diff HEADempty and the on-disk blob hash back toef5fa760…, byte for byte. The script carried atrap … EXIT INT TERMwith absolute paths throughout.No
distis in the resolution path here: the suite imports./indexrelatively, 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 onget-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.pnpm --filter @objectstack/client testpnpm --filter @objectstack/client typechecktsc --noEmit+check:test-typecheck(0 files / 0 errors in the debt ledger)pnpm --filter @objectstack/plugin-auth testpnpm --filter @objectstack/plugin-auth typecheckpnpm --filter '@objectstack/client-react...' buildGates
Derived from the delivered diff with
node scripts/pm/dispatch-gates.mjs --commands, from a tree actually atorigin/main(noSTALE TREEbanner —origin/mainhad moved twice during the round and was merged in first), and reconciled: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-authoringwas genuinely red on this diff: the new ledgernotecarried#16568in 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-examplesandpnpm check:dual-build-cjs-loadsboth refused for want of built output (the second by its ownexit 3PREREQUISITE NOT MET). Both green after theclient-reactclosure build — 258 prose examples type-check across 3 surfaces.pnpm check:type-check-debtOOM-ed at--max-old-space-size=4096and answeredexit 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 jsonat012d430b— 6347 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.tsplus a test underpackages/client/. This PR also edits one row ofpackages/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 rebookedserver-onlywith the rationale the hygiene test demands,clientdropped, modelled on the neighbouringorganization/add-memberrow 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
AuthRouteDispositionand the hygiene case that demands anoteon every non-sdkrow; (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.tsandpnpm check:auth-mount-ledgeralready read this file, and all three are green).Nothing published moves with it: the module has zero runtime importers in non-test source, and
tsupbuilds onlysrc/index.tsandsrc/rate-limit-storage.ts, so it cannot reachdist. Hence one changeset, for@objectstack/clientalone.Serial
packages/client/src/index.tsis 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.tsin #16727,packages/client/package.jsonin #15334). The same scan found zero holders onauth-route-ledger.ts.验收备注
getActiveMemberis now a slightly odd name for a method that takes an organisation id and no longer touches the "active" anything. Renaming is a published-surface decision and pure naming, so it is not filed and not done here — the signature was held fixed by the ruling on purpose, so callers need no edit.get-active-membercannot address an organisation, andget-active-member-roleanswers a role only. The SDK caches no session, so the caller's own id has to be read. Noted, not filed — it is a cost of the honest shape, not a defect.auth.me/auth.refreshTokendeclare the REST{ success, data }envelope for/get-session, which answers the bare{ user, session }— andrefreshTokennever captures a token because of it #16760 (out of scope, class (b), found on this drive):auth.meandauth.refreshTokenboth declareSessionResponse— the REST{ success, data }envelope — for/get-session, which answers the bare{ user, session }(and the literalnullwhen anonymous). It is not a type-only complaint:refreshTokenacts on that annotation, readingdata.data?.token, so it never captures a token and returns successfully anyway. Distinct from client SDKauth.*family: bind the 14return res.json()methods (auth 7 · sessions 3 · twoFactor 3 · accounts.unlink 1) to their better-auth wire shapes — #12104 family card 2 of 3 #14313, whose enumerated set is the methods carrying no annotation; same lane and same hot file, so scheduling them together would be natural.search_issues— a declared channel switch, because the proxy refuses/search/*by design (403,sessions are bound to their configured repositories) and REST list endpoints cannot match body text. It returned four related cards including client SDKauth.*family: bind the 14return res.json()methods (auth 7 · sessions 3 · twoFactor 3 · accounts.unlink 1) to their better-auth wire shapes — #12104 family card 2 of 3 #14313 and client SDKorganizations.*family: bind the 19return res.json()methods (organizations 11 · invitations 3 · teams 5) to their better-auth wire shapes — #12104 family card 3 of 3 #14314's neighbours, so the channel was live and the result is a reading rather than a silent zero.Generated by Claude Code