Skip to content

fix(spec): UserSchema.image and OrganizationSchema.logo accept null, the shape better-auth serves - #18718

Merged
os-bill merged 2 commits into
mainfrom
claude/issue-18509-better-auth-nullable-siblings
Sep 17, 2026
Merged

os-bill merged 2 commits into
mainfrom
claude/issue-18509-better-auth-nullable-siblings

Conversation

@os-bill

@os-bill os-bill commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Fixes #18509
Clause-②: yes (widening)

⚠️ The claim comment says Clause-②: no, and this line deliberately does not copy it. The claim was written for the outcome the dispatching seat expected — "does not reproduce", therefore no diff. The measurement below went the other way, so this PR widens a published accept set (@objectstack/spec) and takes a minor changeset. AGENTS.md's Post-Task Checklist §3 makes yes mechanical for that act, and check-changeset-no-major.mjs's level axis grades the declaration against the levels; declaring no here would be green and false, on exactly the gate the seat invoked when it asked for the line to be carried ("Check Changeset reads the BODY, not the card"). The divergence is named here and in the dev report rather than chosen silently — the claim comment is the seat's to correct, and the dev does not PATCH a body.

Verdict: it reproduces. Both of them.

#18509 asked for a measurement, not a widening, and warned that "does not reproduce" would be the good outcome. It is not the outcome. Measured through a real AuthManager (better-auth 1.7.3) over a real ObjectQL on a real SqliteWasmDriver, with the platform's own sys_user / sys_organization definitions, both keys are served present-and-null:

route key served
POST /auth/sign-up/email user.image null
GET /auth/get-session user.image null
POST /auth/organization/create logo null
GET /auth/organization/list [0].logo null
GET /auth/organization/get-full-organization logo null
GET /auth/organization/get-full-organization members[].user.image null

The mechanism is the #17235 one, confirmed at the DDL layer rather than assumed. Both columns are Field.url({ required: false }) in the platform's own object definitions and reach SQLite as nullable columns — PRAGMA table_info reports sys_user.image as varchar(255) notnull=0 and sys_organization.logo as varchar(255) notnull=0. better-auth SELECTs them and serialises the null.

So the remedy is the ruled one, per the card's item 2: .nullish() — not .nullable(), which would retire the legal "key absent" shape.

The two controls

LIT — the probe fires. SessionUserSchema.image, the declaration #17235 fixed, is a field known to be served present-and-null. It shows up under this probe: *** PRESENT-AND-NULL *** on both the sign-up and get-session bodies, and SessionUserSchema.safeParse passes on them (because #18501 already widened it). A probe that could not see that value could not be trusted to report its absence elsewhere.

⭐ The lit control earned its keep — it caught a dead first instrument. The first version of this probe used the in-memory engine shape the plugin-auth suites use (a Map of plain objects). Under it image read ABSENT, not present-and-null, and UserSchema.safeParse passed — a clean, wrong "does not reproduce". The reason is the whole distinction this card is about: a schemaless store has no columns, so "never set" is key-absent there, while a real nullable column reads back as null. The LIT control was the only thing that said so. The instrument was replaced with a real store and the result inverted.

DARK — what must read 0. The population was re-derived rather than trusted: grep -rn '^\s*\(image\|avatar\|avatarUrl\|logo\)\s*:\s*z\.' --include=*.zod.ts packages/spec/src returns 8 declarations, the same 8 the card reported. This PR moves exactly 2 of them. The other 6 — ai/agent.zod.ts avatar, ui/app.zod.ts logo, api/auth.zod.ts SessionUser.image (already .nullish()) and RegisterRequest.image (a REQUEST surface, client-authored, not better-auth-served), kernel/plugin-registry.zod.ts logo, kernel/plugin-security-advanced.zod.ts image — appear nowhere in the conclusion and are untouched.

⭐ How does .url() coexist with null? — the question this card could not answer

Both keys carry .url(); SessionUserSchema.image did not. So this is not a copy of #17235 and the answer had to be measured. It was, on all three candidate forms:

input .url().optional() (today) .url().nullish() (ruled remedy) .url().nullable() (the shape #17235 refused)
key ABSENT pass pass FAIL invalid_type
null FAIL invalid_type pass pass
"" FAIL invalid_format FAIL invalid_format FAIL invalid_format
"https://x/a.png" pass pass pass
"not-a-url" FAIL invalid_format FAIL invalid_format FAIL invalid_format
42 FAIL invalid_type FAIL invalid_type FAIL invalid_type

The answer: .url() and null do not compete, because they never meet. .nullish() wraps the whole z.string().url(), so null and undefined are separate branches that the URL check never evaluates, while a present string is still required to be a well-formed URL. The middle column moves exactly one row from the left column, and it is the ruled one. The right column moves two rows in opposite directions — that second, upward move is the narrowing #17235 refused, and the table is why the same refusal holds here.

⭐ The card's carried boundary note does not come live. #18509 recorded that SessionUser.image accepts "" and warned it "becomes live if step 2 adds .url() reasoning to this family". Measured: it does not. SessionUser.image has that hole because it is bare z.string(); these two keys carry .url(), so "" is refused before and after — the invalid_format row is unchanged in every column. Nothing in this PR widens toward the empty string, and the note stays where the card put it.

Before / after, on the real bodies

UserSchema.safeParse(SERVED_GET_SESSION_USER)
  before: FAIL [{ path: ["image"], code: "invalid_type",
                  message: "Invalid input: expected string, received null" }]
  after:  PASS

OrganizationSchema.safeParse(SERVED_ORG_CREATE_BODY)
  before: FAIL  logo       [invalid_type] expected string, received null
                updatedAt  [invalid_type] expected string, received undefined
  after:  FAIL  updatedAt  [invalid_type] expected string, received undefined

image is the ONLY divergence UserSchema had against the served user, so that body now parses clean. logo was one of three on OrganizationSchema — see the scope fence below.

⛔ Scope fence: two further findings, named and NOT fixed here

The same probe found two more divergences on OrganizationSchema, both out of this card's scope and both reported for separate filing rather than folded in:

  • metadata is served present-and-null. /auth/organization/list and /auth/organization/get-full-organization serve "metadata": null against z.record(z.string(), z.unknown()).optional(). Same present-and-null shape, different key, and a z.record rather than a z.string().url() — so it deserves its own reasoning, not this one by extension.
  • /auth/organization/create omits updatedAt, which the schema declares required. That is the opposite shape — a missing key, not a null one — and the remedy is a different question.

Folding either in would be exactly the step #18509 exists to prevent. They are pinned as current behaviour in organization.test.ts so the fence is visible and a later fix has to come here and say so.

Tests

New pin blocks in packages/spec/src/identity/identity.test.ts and organization.test.ts assert the whole accept set, not just the row that moved — so a later flip to .nullable() (retiring the absent-key shape) or a drop of .url() (admitting "") goes red here instead of passing as "still accepts null". They assert issue paths, so a refusal is attributed to image/logo and not to a neighbour, and each block carries a lit control that removes a neighbouring required key and checks the instrument names it.

Ablation — the pins can fail. Both declarations were reverted to .optional() from the committed state; the mutation was proved on disk before any result was read (anchor counts 1 → 0 for the injected text and 0 → 1 for the removed text, plus git hash-object differing from the HEAD blob on both files), and the restore was proved byte-exact the same way (git diff HEAD empty; both disk hashes equal to their HEAD blobs). These tests resolve ./identity.zod relatively, i.e. to src, not through the package exports to dist, so no rebuild is interposed and the dist-preflight step does not apply.

ABLATED_TEST_EXIT=1 -> Test Files 2 failed | Tests 5 failed | 48 passed (53)
  × accepts `null` — the value every /auth/* user body carries
  × accepts `null` — the value every organization body carries
  × lit control: the instrument reports a neighbour when a neighbour is wrong   (x2)
  × does NOT (yet) accept a served body whole — metadata/updatedAt are separate cards

The 48 that stayed green are the rows that must not move: absent, a valid URL, "", "not-a-url", a number.

Verification

leg result
pnpm --filter @objectstack/spec test 486 files / 13895 tests passed
pnpm --filter @objectstack/spec typecheck pass — tsc --noEmit + check:scripts-typecheck + check:test-typecheck (the first excludes **/*.test.ts; the third is what covers the new pins)
pnpm --filter @objectstack/spec check:generated 15/15 up to date after regenerating the one it proved stale (gen:docs)
pnpm --filter '@objectstack/spec^...' build empty run — "No projects matched the filters"; packages/spec has no workspace dependencies, so there is no upstream closure. Reported as empty, not as a pass.
gates check:nul-bytes, check:spec-docblock-symbol-anchors, check:comment-mask-adoption, check:comment-mask-corpus, check:doc-frontmatter, check:docs-section-name, check:keyed-text-bounds, check:pm-widening-tells, check:spec-parsed-alias, check:docs-spec-enumerations, check:doc-anchors, check:empty-changeset — all exit 0

Lint was narrowed, and the narrowing is measured rather than assumed (at 6f01ef3491): the config-derived population is 6817 files (read by walking git ls-files through ESLint's own isPathIgnored, not guessed); 4 files were linted, counted from --format json, 0 errors / 0 warnings; and the narrowing excludes nothing because type-aware linting is not enabled — every parserOptions in eslint.config.mjs carries only ecmaVersion / sourceType, with no project or projectService, so each file is judged from its own source text and this diff cannot move the verdict of a file it did not touch. The repo-wide run is CI's.

Generated artifacts

check:generated proved exactly one artifact stale and it was regenerated with --fix (never the whole set). The diff is two table cells, both intended: image and logo render as string | null in content/docs/references/identity/. authorable-surface.base.json did not move and check:authorable-surface is green.

Surface note

The dispatch named the two .zod.ts files, plus .changeset/*.md and gate-required derivatives, and marked plugin-auth / plugin-hono-server / client read-only. Those three were not written to — the probe runs from the scratchpad against built dist, so the measurement sites were only read. Two files were touched beyond the literal list: the sibling identity.test.ts and organization.test.ts, because shipping a spec widening with no pin is the always-green hazard this repo refuses, and the Definition of Done requires the coverage. Both were checked for in-flight holders first (last touched by 2c86fe3ea7 and 4b5702ab77, both landed). The regenerated content/docs/references/identity/*.mdx are the gate-required derivative.


Generated by Claude Code

…the shape better-auth serves

Both were `z.string().url().optional()` — a URL string or the key absent,
`null` refused. Both columns are better-auth-owned and nullable
(`sys_user.image` / `sys_organization.logo` are each
`Field.url({ required: false })`, reaching SQLite as `varchar(255)` with
`notnull=0`), and better-auth SELECTs them and serialises them
present-and-null for a user who never set an avatar and an organization
created without a logo.

Measured through a real `AuthManager` (better-auth 1.7.3) over a real
`ObjectQL` on a real `SqliteWasmDriver`, with the platform's own object
definitions — not inferred from the sibling ruling:

  /auth/sign-up/email                      -> user.image = null
  /auth/get-session                        -> user.image = null
  /auth/organization/create                -> logo       = null
  /auth/organization/list                  -> [0].logo   = null
  /auth/organization/get-full-organization -> logo       = null
                                           -> members[].user.image = null

`.nullish()`, not `.nullable()`: the key's absence is a legal shape today,
so `.nullable()` would retire a live shape as the price of admitting null.

`.url()` is kept and does not fight `null` — `.nullish()` wraps the whole
`z.string().url()`, so null and undefined are branches the URL check never
sees while a present string must still be a well-formed URL. Of six inputs
(absent / null / '' / a URL / a non-URL / a number) exactly one row moves.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 2 documentable anchor(s).

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

  • content/docs/ai/skills.mdx (via UserSchema (symbol, a top-level const))
What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 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 — 136 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 df1b275c71fb7939f085c013779f3daefa5114c7 → packageMentionDocs.

Which tree this was computed on

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

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

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 17, 2026
The docblocks were written before the PR existed and cited a guessed
number. No assertion changes.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3

os-bill commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 119/119 CONTRACT_REVIEW_TIER
Head-sha: 2d3dea2a59f0c254b8ede6b14b1e77b1710bc670

Tier evidence: every type:"assistant" line of this review's own transcript carries the harness-stamped model, and every one equals the live CONTRACT_REVIEW_TIER import from scripts/pm/dispatch-gates.mjs — 119/119 at-tier/total at the time this record was composed. Every reading below was taken on the tree at this head: a detached worktree of 2d3dea2a, fresh pnpm install --frozen-lockfile, OS_SKIP_DTS=1 build of the runtime closure; diffs are against merge-base 72dd95fa (the PR's recorded base f6189a43 is main after the branch point). The merge-base diff is exactly the 7 files the PR lists, 260 insertions / 8 deletions. Seat comments on the card and the PR were not read; the dispatch order was not read.

① Derived judgments

What moves, row by row — accept set and public surface:

  1. UserSchema.image: z.string().url().optional() → z.string().url().nullish() (packages/spec/src/identity/identity.zod.ts:76, .describe('Profile image URL') kept) — right. Measured on zod 4.4.3 (the version packages/spec pins; lockfile zod@4.4.3), key-in-object form, all three candidate declarations:
input .url().optional() (base) .url().nullish() (head) .url().nullable() (refused)
key ABSENT pass pass FAIL invalid_type
explicit undefined pass pass FAIL invalid_type
null FAIL invalid_type pass pass
"" FAIL invalid_format FAIL invalid_format FAIL invalid_format
"https://x/a.png" pass pass pass
"not-a-url" FAIL invalid_format FAIL invalid_format FAIL invalid_format
42 FAIL invalid_type FAIL invalid_type FAIL invalid_type

The head column moves exactly one row from base — null, the ruled one. .nullable() moves two rows in opposite directions: it admits null and REFUSES both the absent key and explicit undefined, retiring the legal absent-key shape — the narrowing the #17235 ruling refused. So .nullish() is the correct widening and the refusal of .nullable() is measured-right, not copied. Structure on this zod: .nullish() is optional(nullable(string+url)) (def.type chain optional → nullable → string); .nullable() is nullable(string).

  1. OrganizationSchema.logo: the same edit (organization.zod.ts:73, .describe('Organization logo URL') kept) — right, same table on the head schema (through the PR's pins and my probe; identical rows). Corroboration from the owner of the bytes: better-auth 1.7.3's own organizationSchema (plugins/organization/schema.d.mts:256) types logo as ZodOptional(ZodOptional(ZodNullable(ZodString))) — the producer declares it nullish itself.

  2. What .url() means beside null — answered by measurement. The URL check lives on the string branch only: a .refine spy on the string branch fired 1 time across [null, undefined, 'https://x/a.png'] (expect 1), so null and undefined short-circuit before any string check runs. The string branch is the base behaviour unchanged: on zod 4.4.3 .url() refuses "", "not-a-url", "/relative/a.png", "x.png" (invalid_format) and admits any absolute URL of any scheme — data:, javascript:, mailto:, blob:, ftp:, http://localhost/x — tolerating a leading space and an unencoded space in the path (WHATWG parsing). Every one of those rows is identical across base / head / nullable: pre-existing, unmoved. Two consequences: (a) the card's carried boundary note (SessionUser.image accepts "") does NOT come live here — "" is refused on both keys before and after, because these two carry .url() and that one does not; (b) a RELATIVE avatar or logo path is refused by these declarations before and after — whether the platform ever serves one is NOT MEASURED (no route in the probe did; both served null).

  3. Served present-and-null — the evidence and its method. The PR's probe is not in the tree (it ran from the author's scratchpad), so the method was judged by re-taking the measurement on my own instrument at this head: better-auth 1.7.3 (plugin-auth's pinned dep, read from node_modules) AuthManager over ObjectQL on SqliteWasmDriver({ filename: ':memory:' }), registering the platform's own @objectstack/platform-objects/identity definitions plus the three plugin-security authz tables the existing auth-get-session-envelope.test.ts fixture spells — the way SessionUser.image is declared z.string().optional(), but every /auth/* session route serves "image": null — no real session body parses as SessionResponse #17235's evidence was taken. Every row of the PR's table reproduces:

route key measured here
POST /auth/sign-up/email user.image PRESENT-AND-NULL
GET /auth/get-session user.image PRESENT-AND-NULL
POST /auth/organization/create logo PRESENT-AND-NULL
GET /auth/organization/list [0].logo PRESENT-AND-NULL
GET /auth/organization/get-full-organization logo PRESENT-AND-NULL
same members[0].user.image PRESENT-AND-NULL

Parses on the served bodies: UserSchema at head on the get-session user → issue paths []; a base-shaped schema (UserSchema.extend({ image: z.string().url().optional() })) on the same body → ['image']. OrganizationSchema at head on the create body → ['updatedAt']; base-shaped → ['logo', 'updatedAt']. Exactly the PR's before/after. Controls: LIT — SessionUserSchema (nullish since #18501) parses the same get-session user [] while the raw key is literally null; DARK — user.name on the same body reads back as the set string, so the present-and-null detector does not fire on a set key. The PR's 「dead first instrument」 claim reproduces: on InMemoryDriver the sign-up user.image reads ABSENT and the base-shaped schema parses [] — a clean, wrong 「does not reproduce」 — so the real-store choice is what makes the measurement valid, and the PR was right to say so. Method verdict: sound. Two method notes, neither changing the conclusion: (a) the organization legs are gated — beforeCreateOrganization answers 403 「Creating additional organizations is disabled on this deployment.」 unless the effective tenancy posture walls (getTenancy / OS_TENANCY_POSTURE); my first run got that 403, the PR body does not say how its probe got past it, and with getTenancy answering posture group all four organization rows appeared — a reproducibility gap in the description, not in the finding; (b) the DDL reading reproduces (sys_user.image varchar(255) notnull=0, sys_organization.logo varchar(255) notnull=0) but the control beside it says it does not discriminate: sys_user.email and sys_organization.name are notnull=0 too, because at this head driver-sql emits NOT NULL only for storage: { notNull: true } (ADR-0113; declaresColumnNotNull, sql-driver.ts:1809 and :17845) and no field in platform-objects/src/identity/sys-user.object.ts declares it (notNull count 0). Field.url({ required: false }) is a true description of both fields but not the CAUSE of the nullable column; the mechanism the memory-vs-real comparison actually demonstrates is column-store semantics — a real store returns every column and an unset one reads back null. The changeset and both docblocks phrase it causally; see ③.

  1. Published TypeScript types widen — User['image'], UserParsed['image'], Organization['logo']: string | undefined → string | null | undefined — right and measured: a type probe compiled with tsc --strict on the head sources assigns null to all three and @ts-expect-error-pins 42 as refused (clean); on the ablated tree (.nullish() → .optional()) exactly the three null assignments fail with 「Type 'null' is not assignable to type 'string | undefined'」. Consumers: in-repo, 38 files outside packages/spec import @objectstack/spec/identity and 0 of them import User / UserParsed / Organization / OrganizationParsed (multi-line-aware read); runtime .parse/.safeParse callers of UserSchema / OrganizationSchema outside spec: 0 (lit: SessionUserSchema 2 outside spec). Pinned sibling objectui@53ded82b (.objectui-sha at head): 0 files import UserSchema / OrganizationSchema / the two types from @objectstack/spec (lit: 1160 files reference @objectstack/spec), so the skipped Console Pin Gate hides no break.

  2. Published JSON Schema face widens — shipped in the npm package (files lists json-schema), regenerated on the built head: json-schema/identity/User.json image → anyOf [{type string, format uri}, {type null}]; Organization.json logo likewise; required unchanged (image / logo not in it). Right.

  3. Generated reference pages — content/docs/references/identity/identity.mdx:66 and organization.mdx:88: one cell each, string → string | null, still optional, descriptions unchanged — right. check:docs on the built head: 224 generated files in sync, exit 0; check:authorable-surface exit 0; authorable-surface.base.json, authorable-surface/, api-surface/ diff vs merge-base: empty. api-surface/identity.json records UserSchema (const) / OrganizationSchema (const) by name and kind only, so a type-level widening correctly moves nothing there.

  4. Pins (identity.test.ts +63, organization.test.ts +91) pin the whole accept set with issue paths and a neighbour-attribution control, plus a scope-fence pin (metadata / updatedAt) — right. Baseline on head: 2 files, 53 passed. Ablation in my worktree (both keys → .optional(); mutation proved on disk: url().nullish() count 1 → 0 per file, git diff --stat 2 files / 2 lines): 5 failed / 48 passed, the same five the PR names; restore via git checkout HEAD --, porcelain 0, 53 passed. Remark, not a finding: the two 「lit control」 cases carry image: null / logo: null in their fixture, so under ablation they redden for the widened value rather than for a dead instrument — a valid-URL fixture would make them independent of the change they sit beside.

  5. Population — the card's grep re-derived on base and head: 8 declarations both times; the PR moves exactly 2; the other 6 (ai/agent.zod.ts:191 avatar; api/auth.zod.ts:47 already nullish; :111 RegisterRequest.image, request surface under the prior ruling; kernel/plugin-registry.zod.ts:248; kernel/plugin-security-advanced.zod.ts:251; ui/app.zod.ts:823) are authored or client-authored surfaces, none better-auth-served — right to leave. UserSchema / OrganizationSchema are embedded by no other spec schema (non-test grep of packages/spec/src: only their own files), so the widening reaches exactly two declarations, two types, two JSON-Schema faces, two doc rows. Nothing retired; no ADR-0087 entry needed (check-adr-0087-registration --base: non-breaking, exit 0).

② Semver level

Changeset .changeset/18509-identity-image-logo-nullish.md, read from the tree: frontmatter "@objectstack/spec": minor; body a pure-widening description; no model identifier (sweep over the whole diff: 0 hits; lit control on a model string: 1). PR body line 2: Clause-②: yes (widening). Consistent with the diff: every body legal before is legal after (rows ABSENT / undefined / URL / "" / non-URL / 42 unchanged on both keys); exactly the null row gains, on two keys; types and JSON-Schema faces widen; nothing removed, renamed or narrowed. Under Post-Task Checklist §3 (yes takes at least minor; (narrowing) is BREAKING) minor is the floor and nothing here is breaking; @objectstack/spec sits in the fixed lockstep group, so the whole group takes minor — by design. Gates offline against merge-base 72dd95fa: check-changeset-no-major exit 0 (level axis NOT APPLICABLE offline — no PR payload; CI Check Changeset, which reads the PR event, is success on this head); check-empty-changeset exit 0 (1 declaring changeset added; none from the merge-base modified or deleted); check-adr-0087-registration exit 0. Recorded limit: a read-side TS type widening can in principle break a downstream consumer that narrowed on !== undefined; measured consumers in this repo and in the pinned objectui: 0 (①.5), and it is the grade the #18501 ruling took for the same shape. minor is right.

③ Boundary flags

  • Two out-of-scope findings are asserted as 「filed separately」 and are not filed. The changeset body (ships verbatim into CHANGELOG.md), both zod docblocks and the organization.test.ts fence pin say the metadata present-and-null and updatedAt omission are 「filed separately」; the PR body says 「reported for separate filing」. Listing every issue created since 2026-09-16T10:19Z (157 non-PR issues; the window reaches before the card's 2026-09-16T17:16Z creation; lit: spec/identity: UserSchema.image and OrganizationSchema.logo are the same better-auth nullable-column shape #17235 just fixed — measure whether either is served present-and-null #18509 is in the listing): 0 titles or bodies name OrganizationSchema, organization/create, metadata present-and-null or updatedAt. /search is unreachable through this proxy, but a complete created-since listing does not need it. Both findings reproduced here, with refinements the cards should carry: metadata is PRESENT-AND-NULL on list and get-full-organization and ABSENT on create; updatedAt is ABSENT on all three organization routes, not only create — better-auth 1.7.3's own organizationSchema carries no updatedAt at all (schema.d.mts:252-259) while its model declares it required: false, so the spec's required updatedAt names a key the producer never serialises. Not folding them in was right (the card's fence; PD chore: version packages #10 「never expand scope」); PD chore: version packages #10's other half is 「never bury a defect」. Escalated to the owning seat: file both before landing so the CHANGELOG sentence is true when it ships, or reword it. Dedupe: OrganizationSchema.metadata, present-and-null, z.record; OrganizationSchema.updatedAt, organization/create, organization/list, get-full-organization, never served.
  • required: false is not the cause of notnull=0 — ①.4(b). The changeset sentence 「each Field.url({ required: false }), reaching SQLite as varchar(255) with notnull=0」 and the matching docblock lines read causally and are not: every column in both tables is notnull=0 at this head (ADR-0113: only storage.notNull emits NOT NULL; controls sys_user.email and sys_organization.name also notnull=0). The conclusion stands on the served bytes, which reproduce; the wording is the seat's to tighten or accept. Dedupe: declaresColumnNotNull, storage.notNull, required: false, notnull=0.
  • Clause-② carrier divergence — the PR body says its claim comment carries Clause-②: no. That comment was not read here (seat comment). On the artefacts this review may read, the BODY's yes (widening) is the declaration consistent with the diff and the minor changeset; the card-side carrier is the seat's to correct before enqueue. check-clause2-carriers --pair 18718 NOT RUN here, deliberately: it reads the claim comment.
  • .nullable() refused — answered in ①.1: it retires ABSENT and explicit undefined (measured), which no route was measured omitting; the only shape both routes serve for an unset value is null, and absence stays legal.
  • RegisterRequest.image (api/auth.zod.ts:111) — untouched, correctly; the prior ruling's request-surface reasoning holds and nothing here measures a caller sending null.
  • Served-shape evidence lives only in the PR body — the tree pins the accept set, not a served body; there is no in-tree runtime parse of UserSchema / OrganizationSchema because no consumer performs one (①.5). This review's re-take is the second reading; a future reader has the body and this record. Not blocking; noted.
  • Same-class candidates NOT MEASURED here — AccountSchema.{accessToken, refreshToken, idToken, scope} (identity.zod.ts, .optional(), better-auth account columns): no route in this probe serves an account body. Named, not judged. Dedupe: AccountSchema, present-and-null, better-auth account columns.
  • Comment citations — check-issue-citations --base on what the diff adds: 10 citations across 4 files, 8 resolve, 2 resolve as pull requests (PR #18501, PR #18718, both written as PRs), batch #138 excluded as a decision-batch ordinal; exit 0.
  • CI — latest run per check name on 2d3dea2a at 2026-09-17T17:05:01Z: 31 success, 4 skipped, 0 in progress, 0 failed; all seven required contexts success (Lint & Repo Gates completed 17:04:37Z; skipped: Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke opt-in). The PR is a draft; landing is the seat's.
  • Surface note — the two test files touched beyond the dispatch's literal list are the pins the widening needs; nothing in plugin-auth / plugin-hono-server / client moved (7-file diff).

Implemented-by: claude/issue-18509-better-auth-nullable-siblings
Reviewed-by: session_01JbZnqu8bt6YqfJsr9vaFb3

VERDICT: PASS


Generated by Claude Code

@os-bill
os-bill marked this pull request as ready for review September 17, 2026 17:28
@os-bill
os-bill added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit b9d5422 Sep 17, 2026
44 checks passed
@os-bill
os-bill deleted the claude/issue-18509-better-auth-nullable-siblings branch September 17, 2026 17:50
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… spec declares (objectstack-ai#19122)

Fixes objectstack-ai#18728

**Direction: the maintainer's ruling C** (batch objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants