Commit e6c34f6
fix(plugin-auth,spec,client): the identity read routes serve what the spec declares (#19122)
Fixes #18728
**Direction: the maintainer's ruling C** (batch #158 item 4, comment
5729189649), quoted verbatim into the dispatch word and implemented as
written. ⛔ Not re-argued here, and neither the card's A nor its B is
implemented — both were superseded.
Clause-②: yes (widening) — `updatedAt` moves from required to optional
on three published schemas, so the set a consumer may hand to
`OrganizationSchema` / `MemberSchema` / `InvitationSchema` grows by
exactly one shape: the key being absent. Nothing previously admitted is
refused, nothing is renamed, and no producer is required to write it.
Contract-review tier.
The claim comment declared `Clause-②: no` **conditionally** — 「unless
fallback A moves a declaration」. Fallback A fired and moved three, so
the condition resolves to `yes`.
---
## Leg 1 — the measurement ruling C made fallback A conditional on
Ruling C, verbatim: 「**Fallback A**, decided by measurement first: if
the identity wire is produced by better-auth's own serializer and its
documented shape carries no `updatedAt`, then for those routes the spec
aligns to the documented wire (`updatedAt` optional there) and the
reason is written on the card; `metadata` is decoded regardless (it is
our column).」
So the first leg was that measurement, per route, taken against the
installed **better-auth 1.7.3** in this worktree
(`packages/plugins/plugin-auth/node_modules/better-auth`, realpath under
the pnpm store, version read from its own manifest).
| route | who serialises the response | evidence | documented
`updatedAt`? | `metadata` before |
|---|---|---|---|---|
| `setActive` — `POST /organization/set-active` | **better-auth's own
handler**, `ctx.json(organization)` from `findOrganizationById` |
`crud-org.mjs:379` handler, `:427` the answer; `adapter.mjs:400` the
read | **no** | stored JSON text |
| `get` — `GET /organization/get-full-organization` | **better-auth's
own handler**, `ctx.json(organization)` from `findFullOrganization` |
`crud-org.mjs:336`, `:371`; `adapter.mjs:424` | **no** | stored JSON
text |
| `delete` — `POST /organization/delete` | **better-auth's own
handler**, `ctx.json(org)` from `findOrganizationById` |
`crud-org.mjs:239`, `:291`; `adapter.mjs:400` | **no** | stored JSON
text |
| `list` — `GET /organization/list` | **better-auth's own handler**,
`ctx.json(organizations)` from `listOrganizations`; the organization
arrives through the adapter factory's fallback join, itself another
`findOne` on this model | `crud-org.mjs:436`, `:455`; `adapter.mjs:474`;
`@better-auth/core` `dist/db/adapter/factory.mjs` `handleFallbackJoin` |
**no** | stored JSON text |
| `create` — `POST /organization/create` | better-auth's handler; its
organization adapter decodes the echo itself | `adapter.mjs:141`, decode
at `:152` | **no** | decoded already |
| `update` — `POST /organization/update` | better-auth's handler; same,
via `parseJSON` | `adapter.mjs:352`, decode at `:367` | **no** | decoded
already |
Three mechanisms make that table a reading rather than an impression:
1. **The routes are the vendor's, mounted through one catch-all.**
`packages/plugins/plugin-auth/src/auth-route-ledger.ts:257-277` books
every `organization/*` row `source: 'better-auth'`, and the ledger's own
header states the mount is `rawApp.all(basePath + '/*')` with no
per-route registration. Each handler answers `ctx.json(...)` directly;
there is no ObjectStack post-processing between the adapter and the
wire.
2. **The vendor's declared model has no `updatedAt`, and its output
transform emits declared fields only.**
`dist/plugins/organization/organization.mjs:705` declares `organization`
as `name` / `slug` / `logo` / `createdAt` / `metadata`; `member` as
`organizationId` / `userId` / `role` / `createdAt`; `invitation` as
`organizationId` / `email` / `role` / `teamId` / `status` / `expiresAt`
/ `createdAt` / `inviterId`. `@better-auth/core`
`dist/db/adapter/factory.mjs:144` `transformOutput` iterates `for (const
key in tableSchema)` — an undeclared column is dropped before any route
sees it. `filterOutputFields` (`@better-auth/core`
`dist/utils/db.mjs:6`) then removes only `additionalFields` marked
not-returned.
3. ⭐ **Lit control, same file and same grep.** `updatedAt` occurs
exactly twice in `organization.mjs` — `:617` on `team` and `:694` on
`organizationRole`, both of which DO declare it. So the zero on
`organization` / `member` / `invitation` is a reading, not a grep
artefact. A second control one level up: better-auth's **core** `user`
and `account` models do declare `updatedAt` (`@better-auth/core`
`dist/db/get-tables.mjs:185` and `:270`), which is why
`packages/spec/src/identity/identity.zod.ts` is **not** touched by this
PR.
⇒ both of fallback A's conditions hold on all four read routes.
**Fallback A fires.**
### One premise inside the ruling's primary arm is false, and it is a
finding
Ruling C's primary arm reads 「every identity read route puts
`updated_at` on the wire as `updatedAt` (**the column exists**)」.
Measured: the column exists on `sys_organization` only.
| object | `updated_at` column | evidence |
|---|---|---|
| `sys_organization` | **yes** |
`packages/platform-objects/src/identity/sys-organization.object.ts:362`
|
| `sys_member` | **no** | `sys-member.object.ts` declares `id` /
`created_at` / `organization_id` / `user_id` / `role` — nothing else |
| `sys_invitation` | **no** | `sys-invitation.object.ts` declares `id` /
`created_at` / `organization_id` / `email` / `role` / `status` /
`inviter_id` / `expires_at` / `team_id` / `business_unit_id` /
`positions` |
And the audit family is not injected onto any of the three: all three
declare `managedBy: 'better-auth'`, which is the single disposition
under which `resolveInjectedSystemColumns`
(`packages/spec/src/data/injected-system-columns.ts`) returns the empty
plan — audit family included. `sys_organization` has `updated_at`
because it declares the field itself.
⇒ for `Member` and `Invitation`, fallback A is forced a second time
over: there is no stored value to serve. ⛔ Per the dispatch,
`packages/platform-objects/**` is out of surface and a missing column is
a finding rather than an edit — so nothing was added there, and this is
reported rather than repaired.
### Re-taken card readings, and the drift
The card invited re-taking its own first-hand readings rather than
trusting them. Every one still holds in substance; the line numbers have
drifted (PR #18718's docblocks moved them), so the card's citations no
longer resolve:
| card's citation | on this base (`07c6f822e`) | still true? |
|---|---|---|
| `organization.zod.ts:57` / `:105` / `:183` required `updatedAt` |
`:89` / `:137` / `:215` | yes — all three required before this PR |
| `identity.zod.ts:55` / `:142` required `updatedAt` | `:86` / `:173` |
yes, and deliberately left alone |
| three 「not relayed」 notes at client `:1213` / `:1249` / `:1335` |
`:1212` / `:1248` / `:1335` | yes |
| `return-type-precision.test.ts:1050` pins `string | null | undefined`
| `:1050`, exactly | yes |
| zero in-repo consumers of the three schemas | zero | yes — every hit
is a generated artefact, a CHANGELOG or docs prose. Lit control:
`ObjectSchema` has real consumers in `packages/cli/src/commands/`. ⭐
After this PR the count is no longer zero: `@objectstack/client`
consumes all three, which is what makes a future drift go red in-repo. |
---
## Leg 2 — what changed, at each end
**Producer** (`packages/plugins/plugin-auth/`)
- **New** `src/organization-metadata-decode.ts` — decodes
`sys_organization.metadata` from stored JSON text into an object, and
OMITS the key when the column is unset (`null`, empty, or a stored
`null` literal), because the spec declares the key optional and never
nullable. Undecodable text and text that decodes to a scalar or array
are passed through **untouched**: never invented, never thrown. That
makes the consumer's spec parse refuse the body and name the field —
loud and located, and distinguishable from an unset column.
- **Wired into `src/objectql-adapter.ts`'s READ verbs only** (`findOne`,
`findMany`). All four read routes reach the row through those, so one
seam covers them with nothing to keep in sync.
- ⛔ **Deliberately NOT the write verbs.** better-auth's own organization
adapter decodes the `create` / `update` echoes itself and discriminates
on the value still being a string (`typeof organization.metadata ===
'string' ? JSON.parse(...) : void 0`, `adapter.mjs:152`). Handing it an
object would fold the create echo's `metadata` to `undefined` — a
regression that reads as "unset". Both directions are pinned.
- Measured reason this could not be done by declaration instead: the
adapter declares `supportsJSON: true` (`objectql-adapter.ts:827`), so
`transformOutput`'s JSON branch — which fires only for a field typed
`json` on an adapter declaring `supportsJSON: false` — is unreachable
here, and the vendor types `metadata` as a string anyway.
**Spec** (`packages/spec/src/identity/organization.zod.ts`, fallback A)
- `OrganizationSchema.updatedAt`, `MemberSchema.updatedAt`,
`InvitationSchema.updatedAt` become `.optional()`. `.optional()` and not
nullish: the key is **absent** on the wire, never `null`. Each carries
the measurement and the ruling's own words in its docblock.
- ⛔ `identity.zod.ts` untouched — the vendor declares `updatedAt` on
`user` and `account`, so no fallback applies there.
- ⛔ PR #18718's `image` / `logo` nullish arm is untouched, neither
extended nor reverted.
- Regenerated by the sanctioned producers only (`check:generated --fix`
ran `gen:api-surface-declarations` and `gen:docs`):
`packages/spec/api-surface-declarations/identity.txt` and
`content/docs/references/identity/organization.mdx`, three declaration
moves each, nothing else.
**Client** (`packages/client/`)
- The three 「not relayed」 notes are **gone**, and the wires are relayed
rather than transcribed: `OrganizationWire` is the spec's
`Organization`, `OrganizationMemberWire` is `Member`, and
`OrganizationInvitationWire` is `Invitation` with `status` narrowed per
route plus the three members the platform adds (`teamId` and the two
ADR-0105 D8 placement fields). The schemas are plain, non-strict
objects, so those three extra keys are stripped on parse rather than
refused — which is what makes the relay claim honest about the wire
being a superset.
- `return-type-precision.test.ts:1050` flipped from the stored-text
union to the decoded object. Two `@ts-expect-error` directives there had
to go with it, because the shapes they suppressed are now legal — each
replaced by a positive pin rather than deleted:
- reading `updatedAt` off the delete answer is now the spec's optional
ISO string (with the measured note that optional is the ACCEPT set, and
the value is absent on every route of this family);
- reading into `metadata` on a read route is now legal, so the
direction-2 suppression moved to `JSON.parse` of it — the thing that is
now refused.
---
## Leg 3 — ⭐ the negative control
"The client now relays the spec schemas" and "the client stopped
validating" look identical from a green positive test, so
`packages/client/src/identity-wire-relay.test.ts` runs the real
`safeParse` over the measured bodies and pairs every accepted one with a
refused one. Each refusal asserts the **issue path**, not merely that it
failed:
| body | verdict | why it is the control |
|---|---|---|
| the served read-route body, `updatedAt` absent, `metadata` decoded |
**accepted** | the relay itself |
| same, `metadata` key absent | **accepted** | an unset column |
| `slug` removed | **refused**, path `slug` | a genuinely required field
is still required |
| ⭐ `metadata` as the stored JSON text | **refused**, path `metadata` |
the exact dimension the producer fix moves — this one distinguishes "the
producer decodes" from "the schema stopped caring" |
| `metadata: null` | **refused**, path `metadata` | the producer omits;
`null` is not quietly admitted |
| `createdAt: 'yesterday'` | **refused**, path `createdAt` | the
datetime check is live |
| ⭐ `updatedAt: 'whenever'`, on all three schemas | **refused**, path
`updatedAt` | `.optional()` widened by ABSENCE only; a present value is
still held to the datetime check |
| `userId` removed / `inviterId` removed / `status: 'withdrawn'` |
**refused**, each path named | the other two schemas, same discipline |
The same discipline lands in
`packages/spec/src/identity/organization.test.ts`, whose #18509
scope-fence pin asked in writing that 「a later fix for either one has to
come here and say so」. It is taken down and answered: the served body
now parses whole, and the `null` the schema still refuses is a shape
nothing sends any more.
---
## Verification
Everything below is a foreground run in this worktree at the head of
this branch; heavy runs went through `scripts/pm/os-verify-lock.sh` and
the verdict quoted is the one it printed.
- **Red before the change, on the producer.** An ablation of the decode
assignment — written to disk by `scripts/ablation-replace.mjs`, which
proved the mutation landed (anchor 1 to 0, blob `6346e2ba97a5` to
`25449f6b3393`) — turned exactly the three decode pins red and left the
other eight green, then restored and proved the restore (blob equals
HEAD, `git diff HEAD` empty).
- **Red before the change, on the client.** Before the flip, the
client's test project reported exactly three errors of mine:
`return-type-precision.test.ts(1050,83)` TS2344 and two TS2578
unused-suppression errors at `:1068` and `:1076`. The other 54 errors in
that run were TS2307 "cannot find module" from an unbuilt workspace and
are gone after a build.
- **Red before the change, on the spec.** The full spec suite failed on
`organization.test.ts`'s scope-fence pin and on nothing else.
- Green after: `@objectstack/plugin-auth` 113 files / 2376 tests;
`@objectstack/spec` 493 files / 14521 tests (1 skipped, an
environment-conditional skip in the suite's project split — there is no
`describe.skip` or `skipIf` in the spec sources); `@objectstack/client`
48 files / 566 tests. Typecheck green on all three, including each one's
test layer.
- The gate families this diff derives were run and accounted: `node
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran
RECORD-FILE` reports **116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN**,
with an exit code recorded per family. Two needed a second pass and are
green now: `check:skill-examples` refused twice on a `dist` older than
`src` (the good refusal — it declines to reach a verdict it cannot read)
and passed after building the package directly;
`check:engine-double-contract` asked for the new fake's `update` to
route through `assertEngineUpdateDispatch` and for its ledger row, both
of which it now has.
- Repo-wide `pnpm lint` (`eslint . --no-inline-config`): exit 0.
- `packages/spec` artefacts: `check:generated` reports all 16 up to
date, on a tree rebuilt after the last source edit.
- Control bytes: `check:nul-bytes` clean over 8954 files, plus a direct
scan of this diff's own files.
## Acceptance notes
- **Hold #7881 intersection, answered.** This PR's producer fix does
touch `packages/plugins/plugin-auth/src/objectql-adapter.ts`, the
declared trigger file of on-hold card #7881 (`rethrowAsBetterAuthError`
maps engine errors code-by-code). That hold's restart condition is an
**incident count**, not a file touch, so the trigger is **unaffected**
and nothing here advances or satisfies it. ⛔ Its generalisation was
deliberately not attempted, and no bodyless-500 path was measured on the
four identity read routes fixed here.
- **Not declared breaking, and the reason is the repo's own criterion**
rather than the level being convenient. AGENTS.md binds the breaking
class to removing or renaming something an author can write, and to the
`(narrowing)` arm of the clause-② pair; neither holds. The `metadata`
half is a producer brought into line with a contract this package has
published all along, and the client's own comment called the served text
「not relayed」 rather than a shape anyone was promised. The changeset
therefore carries no ADR-0087 disposition —
`check-adr-0087-registration` agrees, reporting 「this PR adds no
declared-breaking changeset」 — while still shipping the one-line
consumer note and the compiler as its delivery channel. ⭐ Called out
because it is the one judgement in this diff the contract review should
confirm rather than inherit.
- **`AUTH_ORGANIZATION_SCHEMA` carries a dead field mapping.**
`packages/plugins/plugin-auth/src/auth-schema-config.ts:185` maps
`updatedAt` to `updated_at` for the organization model, and
better-auth's organization model declares no `updatedAt` field at all —
so the mapping sets a `fieldName` for a field the transform never
iterates. It is inert rather than wrong, and out of this card's scope;
reported as a finding.
- **Left deliberately untouched:** `packages/platform-objects/**` (the
two missing `updated_at` columns are reported above, not added),
`content/docs/releases/**`,
`packages/spec/src/identity/identity.zod.ts`, and PR #18718's nullish
arm.
- `needs:contract-review` is the seat's label — ⛔ this PR neither
attaches nor clears it, and the report states what the PR carries plus
the `check-clause2-carriers.mjs --pair` exit code.
- The PR opens as a **draft** and stays draft.
---
_Generated by [Claude
Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent f26fb8e commit e6c34f6
12 files changed
Lines changed: 852 additions & 112 deletions
File tree
- .changeset
- content/docs/references/identity
- packages
- client/src
- plugins/plugin-auth/src
- spec
- api-surface-declarations
- src/identity
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | | - | |
| 45 | + | |
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
| |||
71 | 71 | | |
72 | 72 | | |
73 | 73 | | |
74 | | - | |
| 74 | + | |
75 | 75 | | |
76 | 76 | | |
77 | 77 | | |
| |||
88 | 88 | | |
89 | 89 | | |
90 | 90 | | |
91 | | - | |
| 91 | + | |
92 | 92 | | |
93 | 93 | | |
94 | 94 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
0 commit comments