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 passtsc --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 <[email protected]>
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 df1b275c71fb7939f085c013779f3daefa5114c7packageMentionDocs.

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 <[email protected]>
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 [email protected]), 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 widenUser['image'], UserParsed['image'], Organization['logo']: string | undefinedstring | null | undefinedright 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 imageanyOf [{type string, format uri}, {type null}]; Organization.json logo likewise; required unchanged (image / logo not in it). Right.

  3. Generated reference pagescontent/docs/references/identity/identity.mdx:66 and organization.mdx:88: one cell each, stringstring | 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 hereAccountSchema.{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 citationscheck-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

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