Skip to content

fix(client)!: bind the organizations.* family to the wire shapes better-auth sends - #16576

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-14314-organizations-family-wire-shape-binding
Sep 7, 2026
Merged

fix(client)!: bind the organizations.* family to the wire shapes better-auth sends#16576
os-sales merged 3 commits into
mainfrom
claude/issue-14314-organizations-family-wire-shape-binding

Conversation

@claude

@claude claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #14314 — card 3 of 3 of the #12104 family. All nineteen members the card names are bound and all twenty of the family's ledger entries are closed (the twentieth, organizations.invitations.resend, closes by delegation with no edit to its site — proven below), so a merge completes the card.

Clause-② is yes by the maintainer's #12104 ruling (this narrows published return types), so the PR is draft and carries needs:contract-review on both carriers. It waits for an at-tier contract reviewer; auto-merge is not armed and it is not enqueued.

What changed

Nineteen organizations.* methods ended return res.json() with no return annotation, so lib.dom's Response.json(): Promise< any > was their published type. Each now declares the shape its route serves, and packages/client/exported-any-returns.json loses exactly this family's twenty entries (22 before, 2 after — meta.migrateStored and auth.deleteUser remain, neither is this card's).

method before now
organizations.create(req) any OrganizationCreateResult
organizations.update(id, data) any OrganizationEchoWire
organizations.setActive(id) any OrganizationWire or null
organizations.get(id) any OrganizationFullWire or null
organizations.listMembers(id) any OrganizationMembersPage
organizations.invite(req) any OrganizationInvitationWire with status: 'pending'
organizations.leave(id) any OrganizationMemberWithUserWire
organizations.delete(id) any OrganizationWire
organizations.removeMember(id, params) any OrganizationRemoveMemberResult
organizations.updateMemberRole(id, params) any OrganizationMemberWire
organizations.getActiveMember(id) any OrganizationMemberWithUserWire
organizations.invitations.cancel(id) any OrganizationInvitationWire with status: 'canceled'
organizations.invitations.accept(id) any OrganizationInvitationAcceptResult
organizations.invitations.reject(id) any OrganizationInvitationRejectResult
organizations.invitations.resend(inv) any (inherited) inherited from invite — site untouched
organizations.teams.create(req) / .update(params) any OrganizationTeamWire
organizations.teams.delete(params) any OrganizationTeamRemovedReceipt
organizations.teams.addMember(params) any OrganizationTeamMemberWire
organizations.teams.removeMember(params) any OrganizationTeamMemberRemovedReceipt

Seventeen Organization* interfaces are newly exported from @objectstack/client. Every one of these routes is served BARE by better-auth (auth-route-ledger.ts records them source: 'better-auth'); no envelope is unwrapped and none is introduced. @objectstack/spec/identity's Organization / Member / Invitation are deliberately NOT relayed — each declares updatedAt required and the wire never carries it (the adapter's output transform walks better-auth's own schema, which has no such column); InvitationStatus IS relayed, narrowed to the literal each handler pins.

The shapes were read off the WIRE, not off better-auth's .d.ts

Every route was driven three ways against a real AuthManager (better-auth 1.7.2, organization plugin, teams: { enabled: true } as plugin-auth mounts it, isolated tenancy in force):

  1. raw HTTP over a real SqlDriver (better-sqlite3), 46 exchanges covering the 19 members plus the edge cases;
  2. the same routes THROUGH the real ObjectStackClient with only the socket stood in for (fetch handed to AuthManager.handleRequest) — 19 members plus resend;
  3. the raw routes again on the in-memory engine, to separate null from absent on every nullable column.

Four times the vendor's own declaration was the wrong answer, and the wire won:

  • delete's OpenAPI stub declares the deleted id as a string; the handler answers the organization row (return ctx.json(org)).
  • updateMemberRole's stub declares { member }; the handler answers the membership row bare, and without user.
  • metadata is one column with two wire forms: create and update decode it to an object, every read route (setActive, get, delete, list) answers the stored JSON TEXT — measured "metadata":"{\"plan\":\"enterprise\"}" on set-active two calls after "metadata":{"plan":"enterprise"} on update. Hence two types, OrganizationEchoWire and OrganizationWire.
  • removeMember joins user on ONLY when the member was addressed by email; the by-id path strips it (measured both ways) — so user?.

Also measured and declared: create.members is always the literal one-element tuple (the creator's owner row); get(...).teams[] keeps the vendor's memberCount (stripped on teams.create / teams.update) and the default team minted at org creation has no updatedAt on the in-memory engine; setActive('') and get('') answer the 4-byte JSON null when the session has no active organization — declared | null, not invented away. No member of this family answers with a zero-byte body (the class both prior cards hit), so nothing is left unbound.

Timestamps: createdAt on every row, updatedAt on teams and expiresAt on invitations are the vendor's Date fields. The adapter is supportsDates: false, better-auth revives server-side, JSON.stringify puts an ISO-8601 string back on the wire (measured "createdAt":"2026-09-07T09:27:01.545Z"). They are declared string and pinned there; no Date, no revival layer — the maintainer's ruling, quoted on the card.

Population, and the twentieth entry

Re-derived from the ledger on my tree at merge base 9407e1865, not inherited: 22 entries total; organizations.* 20 = direct 11 · teams 5 · invitations 4; auth.* 1; meta.* 1; a control family that cannot exist answers 0. The card's 19 is the count of members annotated; 20 is the count of entries closed. The reconciling entry is organizations.invitations.resend, whose ledger note said binding invite closes it too. It did: the gate is exact in both directions, and at HEAD it reports 2 ledgered site(s) still open with no unledgered callable — so resend no longer resolves to any with zero edits to its site. The pin file holds the same fact as a type equality, and the ablation below measures its other direction.

Ablation (direction predicted before running)

Removing invite's annotation only — nothing else — must redden the gate naming TWO callables: organizations.invite and organizations.invitations.resend (the delegation), and redden the pin file. Measured, both directions, with the implementation committed (da6f4b2a4b) so the restore leg had a real reference. invite's annotation was replaced by a marker comment — on-disk proof before anything was read: removed-text 1 → 0, injected 0 → 1, source blob d3cc8367…5522b5fb… — then rebuilt, and scripts/ablation-dist-preflight.mjs packages/client MARKER --absent read the marker absent from all 6 built files. Red 1: check:exported-any-returns exit 1, its own line ❌ 2 exported callable(s) of @objectstack/client resolve to any and are not ledgered: ObjectStackClient.organizations.invite · ObjectStackClient.organizations.invitations.resend — the delegation closure, measured in the red direction. Red 2: check:test-typecheck exit 1, 4 type error(s) in return-type-precision.test.ts. Restore: git checkout HEAD -- ABSOLUTE PATH under an EXIT/INT/TERM trap, proven by git diff HEAD empty, git status --porcelain empty and the source blob equal to the HEAD blob (d3cc8367…), never by an exit code; rebuilt; the preflight read the marker present in 2 built files; the gate is green again at 2 ledgered site(s) still open. Recorded, not hidden: the first attempt's dist anchor used the source's single-quoted literal, which the emitted .d.ts spells with double quotes, so that attempt's dist-side reading was vacuous (its tree-side reading and both reds were real); the second attempt used the dist spelling and is the one quoted.

Verification

Baseline first, on the unmodified merge base: the gate answered 22 ledgered site(s) still open; positive control in the built dist/index.d.ts: auth.sessions.revokeAll resolves to Promise< AuthStatusReceipt > and oauth.applications.get to Promise< OAuthApplication >, both absent from the ledger, while organizations.setActive still read Promise< any > in the same dist — the instrument sees this world.

At HEAD (b66530f33c for the code, da6f4b2a4b adds only the changeset marker fix): client build exit 0 (check-dts-emitted: 1/1); check:exported-any-returns exit 0 — ✅ no NEW exported callable of @objectstack/client resolves to any: 317 callables reached (52 caller-supplied generics, not counted as erasure), 2 ledgered site(s) still open. with Ledger is exact in both directions.; pnpm --filter @objectstack/client typecheck exit 0 (tsc clean; check:test-typecheck: OK — 0 file(s) / 0 error(s)), the pin file proven inside that program with tsc -p tsconfig.test.json --listFiles; pnpm --filter @objectstack/client test exit 0 — Test Files 34 passed (34) · Tests 444 passed (444). check:adr-0087-registration --base origin/main exit 0 (14 refs verified, each unannotated -> Promise< X >); check-changeset-no-major exit 0 (minor; the level axis is NOT MEASURED locally — no clause-② declaration is readable outside CI); check-empty-changeset exit 0; check:nul-bytes exit 0 (8145 files); check-partof-closing-keyword exit 0 on this body plus the 2 commits (no card-relation trailer). Gate union derived at HEAD with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths, change set from the merge base): Run reconciliation — 56 derived, 56 run, 0 NOT-MEASURED, 0 UNRUN and ✓ dispatch-gates --ran: 56 derived famil(ies) accounted for at da6f4b2a4b; 53 exit 0, the 3 prerequisite refusals named under Deviations recorded as NOT MEASURED rather than passes. The derivation also flagged a STALE-TREE note (origin/main moved 4 commits past my merge base during the run; the one derived-from file that changed is scripts/pm/check-skill-line-ratchet.mjs, a skills/** family no path of this diff touches). Artifact rosters run separately as their own block (31 commands): 27 exit 0; 4 not verdicts — check-partof-closing-keyword and check-single-claim-paths bare-run NOT WIRED without a PR (the former was then run wired, above), check:react-declaration-parity needs a browser dump, check:published-readme-exports exit 3 prerequisite (whole-workspace dist). The four rosters whose baseline sits under my paths — check-changeset-fixed (70 packages in sync), check:authz-resolver, check:error-code-casing, check:filter-alias-parity — all exit 0.

Deviations, declared

  • pnpm lint narrowed to the two changed .ts files: eslint --no-inline-config --format json over them answered 2 files / 0 errors / 0 warnings; the population is eslint.config.mjs's packages/**/*.{ts,tsx,mts,cts} blocks (both files are members); the config is not type-aware (no parserOptions.project, stated in the config's own comment), so this diff cannot move any untouched file's verdict. CI runs the repo-wide lint.
  • Three derived families NOT MEASURED, each a prerequisite refusal needing a full-workspace build: check:skill-examples (exit 1, wants packages/client-react/dist), check:dual-build-cjs-loads (exit 3, "NOT a pass: nothing was measured"), check:type-check-debt (exit 3, same class). CI builds the farm and runs all three; none reads a file this diff touches.
  • Downstream typecheck: git grep finds ZERO in-repo call sites of the nineteen members outside packages/client (the only other references are auth-route-ledger.ts string rows and docs), and the change adds exports without removing or renaming any, so no consumer in the 8-package prefix closure (...@objectstack/client) can be moved by it; the examples' typecheck (which needs the whole workspace built) is CI's.
  • The ADR-0087 marker names 14 of the 19 refs: the gate's dotted walker refuses organizations.create / update / listMembers / delete / removeMember as AMBIGUOUS because a same-named member sits in the nested teams / invitations literal and the walker counts nested definitions inside the region — a limitation of the gate, filed as check-adr-0087-registration's dotted type-surface-only walker cannot name a DIRECT member of an object literal whose name recurs in a nested literal — organizations.create is refused as AMBIGUOUS with organizations.teams.create, and no deeper path exists for the direct one #16571; the five are stated in the marker's prose and are judged by check:exported-any-returns on the built dist like the other fourteen.
  • The wire was driven against an in-process real server (real AuthManager + real SqlDriver, through the real client), not a pnpm dev process — the same harness class as cards 1 and 2.

Findings filed, not fixed here

Scope

Only the nineteen methods this card names on packages/client/src/index.ts, plus the ledger, the pin file and the changeset. resend's site is untouched. The #13080 BREAKING-token gate is not addressed here — the ruling says that card is independent.

🤖 Generated with Claude Code


Generated by Claude Code

os-sales and others added 2 commits September 7, 2026 09:43
…er-auth sends

Nineteen methods of the organizations.* namespace ended `return res.json()`
with no return annotation, so lib.dom's `Response.json(): Promise<any>` was
their published type. Each now declares the shape its route actually serves,
and its exported-any-returns.json entry is deleted in the same change — as is
the entry for organizations.invitations.resend, which carries no annotation of
its own and inherits invite's (22 entries before, 2 after):

  organizations.create              -> OrganizationCreateResult
  organizations.update              -> OrganizationEchoWire
  organizations.setActive           -> OrganizationWire | null
  organizations.get                 -> OrganizationFullWire | null
  organizations.listMembers         -> OrganizationMembersPage
  organizations.invite              -> OrganizationInvitationWire<'pending'>
  organizations.leave               -> OrganizationMemberWithUserWire
  organizations.delete              -> OrganizationWire
  organizations.removeMember        -> OrganizationRemoveMemberResult
  organizations.updateMemberRole    -> OrganizationMemberWire
  organizations.getActiveMember     -> OrganizationMemberWithUserWire
  organizations.invitations.cancel  -> OrganizationInvitationWire<'canceled'>
  organizations.invitations.accept  -> OrganizationInvitationAcceptResult
  organizations.invitations.reject  -> OrganizationInvitationRejectResult
  organizations.teams.create/update -> OrganizationTeamWire
  organizations.teams.delete        -> OrganizationTeamRemovedReceipt
  organizations.teams.addMember     -> OrganizationTeamMemberWire
  organizations.teams.removeMember  -> OrganizationTeamMemberRemovedReceipt

The shapes were read off the wire against a real server, not off
better-auth's own .d.ts: a real AuthManager (better-auth 1.7.2, organization
plugin, teams enabled) over a real SqlDriver (better-sqlite3), driven raw and
again through the real ObjectStackClient with only the socket stood in for,
plus an in-memory-engine leg for absent-vs-null. Four times the vendor's
declaration was the wrong answer: delete answers the organization row, not the
id string its OpenAPI stub declares; updateMemberRole answers the member bare,
not `{ member }`; metadata is decoded on create/update only and is the stored
JSON text on every read route; removeMember joins `user` only on the by-email
path.

Timestamps are ISO-8601 strings, never Date and never revived (maintainer
ruling on the family card): the adapter runs `supportsDates: false` and
JSON.stringify puts the ISO string back on the wire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
…otted walker can resolve

Five direct members of organizations (create, update, listMembers, delete,
removeMember) share a name with a member of a nested literal, and the gate's
walker counts nested same-named definitions inside the region, so those five
have no resolvable spelling; they are stated in the marker's prose with the
issue that records the walker limitation.

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

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/client, touching 36 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/client/exported-any-returns.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json f2f6684cd5e93032ed5d82a1848784b6026a8a03.

6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/client/exported-any-returns.json) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: organizationId (7 routes)
  • 14 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 14 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 f2f6684cd5e93032ed5d82a1848784b6026a8a03packageMentionDocs.

Which tree this was computed on

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

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

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

…mpile-block line and the teams updatedAt guarantee

The changeset's 'reads that stop compiling' block carried one line that
does not compile: JSON.parse on the read-route metadata, whose declared type
is string | null | undefined (TS2345). It now guards the value first, which
is the honest spelling of 'the caller's step'.

OrganizationTeamWire's JSDoc claimed teams.create and teams.update both set
updatedAt explicitly. The vendor's update-team handler writes no timestamp;
the value on the wire comes from better-auth's team schema, whose updatedAt
declares an onUpdate default the adapter applies on every update of the
model, with the platform's own sys_team.updated_at stamping behind it.
Measured on a real SQL driver and on an engine with no platform stamping in
the loop: update-team on the vendor-minted default team answered a fresh
updatedAt on both. The type is unchanged; only the sentence moved. The
sibling OrganizationFullTeamWire's JSDoc no longer presents a hand-rolled
test fake's absent key as a measured store behaviour.

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

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Patch round — d472728128 (two fixes from the at-tier contract review; PR stays draft)

1. The changeset's compile block. JSON.parse(org!.metadata) failed TS2345 because read-route metadata is string | null | undefined. The line now reads if (org?.metadata) JSON.parse(org.metadata); — the honest spelling of "the caller's step". The whole block was re-typed against the rebuilt HEAD dist (a scratch program resolving @objectstack/client to packages/client/dist/index.d.ts): the fixed line compiles, and every one of the ten stated codes reproduces on its line — TS18047, TS2339, TS2345, TS2339, TS2339, TS2339, TS2339, TS18048, TS2367, TS2339 (the get(id)!.metadata.plan line also reports TS2533 beside its stated TS2339, since metadata is nullable there too).

2. OrganizationTeamWire.updatedAt — sentence fixed, type untouched. The JSDoc claimed teams.create and teams.update both set updatedAt explicitly. Only create-team does; update-team's update is { name, ...additionalFields }. Measured this round, update-team on the DEFAULT team (the one the vendor mints at organization creation WITHOUT updatedAt):

  • real SqlDriver (better-sqlite3): the row at rest already carried updated_at = …57.629Z before any update (the platform's stamp from the same request's member-count write); update-team answered updatedAt: …58.449Z.
  • an engine with NO platform stamping in the loop (the hand-rolled harness fake): update-team answered a fresh updatedAt too — so the stamp on teams.update is better-auth's own team schema, updatedAt: { required: false, onUpdate: () => new Date() } (plugins/organization/organization.mjs), applied by the adapter factory on every update of the model, with the platform's sys_team.updated_at stamping behind it.

So updatedAt: string stands as declared; the JSDoc now names that guarantee. Correction to my earlier report: "the default team has no updatedAt on the in-memory engine" was measured on that hand-rolled fake, not on @objectstack/driver-memory — a fake standing in for a driver manufactured a wire fact no real driver produces. OrganizationFullTeamWire.updatedAt? is unchanged (the safe direction); its JSDoc no longer presents the fake's absent key as a store behaviour.

Re-verified at d472728128: client build exit 0; check:exported-any-returns exit 0 (2 ledgered site(s) still open); pnpm --filter @objectstack/client typecheck exit 0 (check:test-typecheck: OK); pnpm --filter @objectstack/client test exit 0 (34 files / 444 tests); check-adr-0087-registration exit 0 (14 refs verified); check-changeset-no-major 0; check-empty-changeset 0; check-changeset-fixed 0; check:nul-bytes 0; eslint on the touched file 0 errors; check-partof-closing-keyword exit 0 with the stored body plus 3 commits (no trailer); check-clause2-carriers --pair 16576 exit 0. Not re-run, per instruction: the 56-command union.

Not touched, per instruction: the metadata A/B/C question (reading A stands) and invite's optional role (request side, filed separately by the seat).


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 7, 2026 11:23
@os-sales
os-sales enabled auto-merge September 7, 2026 11:23
@os-sales
os-sales added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit 7092d63 Sep 7, 2026
38 checks passed
@os-sales
os-sales deleted the claude/issue-14314-organizations-family-wire-shape-binding branch September 7, 2026 11:49
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/l tests tooling

Projects

None yet

1 participant