Skip to content

fix(spec): SessionUser.image accepts null, the shape every /auth/* route serves - #18501

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-17235-sessionuser-image-nullish
Sep 16, 2026
Merged

os-warren merged 2 commits into
mainfrom
claude/issue-17235-sessionuser-image-nullish

Conversation

@os-warren

@os-warren os-warren commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #17235

Clause-②: yes — this widens a published accept set (@objectstack/spec), so the changeset is minor. The needs:contract-review carrier is the seat's, on both card and PR; this PR neither hangs, strips nor waits on it.

The severity binding was re-read on origin/main at fed4a15ab5, AGENTS.md:1067-1068: a yes declaration takes at least minor, it may carry at most one arm from the closed pair (widening)/(narrowing), (narrowing) is BREAKING, and no (widening) is malformed. This diff is a widening, so minor is the floor and nothing here is breaking. (An earlier revision cited AGENTS.md:1043. That was a real read — but off the shared checkout at 7e58d177ee, AGENTS.md blob f04446be, which is not main and whose HEAD other agents move. Measured across three refs: the same rule sits at 1043 on that tree and at 1067 on origin/main, blob fb8bae3e — and this branch's own merge base bf61f0a196 carries the same blob as main, so the worktree would have answered correctly all along. The text was rewritten too, not merely moved. ⇒ A bare AGENTS.md:NNNN is a weak citation: nothing in it says which tree it was read against, and the same sentence legitimately has two different line numbers. The ref plus the quoted rule is what makes it checkable.)

Ruled by director batch #138 item 1, maintainer verbatim 「138 同意」 — option A, pure widening.

What changed

SessionUserSchema.image: z.string().optional() becomes z.string().nullish(). A string, null, or the key absent are all accepted.

better-auth owns the avatar column, stores it nullable, and serialises it present-and-null, so every /auth/* session body the platform produces carried a value its own published declaration rejected.

The widening bites — before / after, measured

SessionResponseSchema.safeParse on a session body, varying only data.user.image:

data.user.image before after
null FAIL data.user.image [invalid_type] Invalid input: expected string, received null pass
"https://x/a.png" pass pass
key ABSENT pass pass
42 (number) FAIL expected string, received number FAIL (unchanged)
{} (object) FAIL expected string, received object FAIL (unchanged)

Exactly one row moved, and it is the ruled one. The failing issue path and message match the card verbatim. Lit control on the instrument: a body with data.session removed reports data.session in both legs, so the parse really is checking.

.nullish(), not .nullable(): the key's absence is a legal shape today and no producer was ever measured omitting it, so .nullable() would have retired a live shape as the price of admitting null.

Ablation — the pins are real, not a cached green

The declaration was reverted to .optional(), @objectstack/spec rebuilt, and the marker confirmed live in the artifact the suite consumes before reading any result:

ablation-dist-preflight: marker present in 4 built files
  packages/spec/dist/api/index.js · index.mjs · browser/api/index.js · index.mjs
ABLATED_TEST_EXIT=1 -> Test Files 2 failed | Tests 5 failed | 17 passed (22)
  expected [ 'data.user.image' ] to deeply equal []                              x3
  expected [ 'success', 'data.user.image' ] to deeply equal [ 'success' ]
  expected [ 'data.session', 'data.user.image' ] to deeply equal [ 'data.session' ]

The 5 reddened assertions are exactly the 5 rows this PR edited; the other 17 were untouched. Restore leg, rebuilt again:

ablation-dist-preflight --absent: marker absent from all 216 built files
  tree: working tree clean against HEAD
RESTORED_TEST_EXIT=0 -> Test Files 2 passed | Tests 22 passed (22)

The generated page is a second carrier

content/docs/references/api/auth.mdx is generated from the schema, so the source edit and the reference page are two carriers and a source-only fix ships the old sentence. check:docs caught it on the first push; regenerated with pnpm --filter @objectstack/spec gen:schema && gen:docs, never hand-edited. git status --porcelain after the regeneration listed exactly one path, inside content/docs/references/**; nothing outside it moved, and packages/spec/authorable-surface.base.json did not move. check:docs then exits 0 ("223 generated files in sync with packages/spec").

Counts on that page, occurrences rather than lines: the old full row | **image** | string | optional | Avatar URL | is 0, the new string \| null row is 2, lit control Avatar URL is 2, dark control nullish is 0.

A deliberately loose probe that stopped before the description column still matched 1 row — and that hit is a true positive on the row this PR leaves alone: line 92, under ## RegisterRequest, still reads | **image** | string | optional | |. The published reference now shows the two surfaces side by side carrying different types, which is the :97 finding confirmed from a generated artifact rather than from reading the source.

The residue rows went in one stroke, not half each

Both client envelope suites pinned data.user.image as an exhaustive issue list. All rows in both files moved together:

  • packages/client/src/auth-get-session-envelope.test.ts — case ① toEqual(['data.user.image']) becomes toEqual([]), plus its header comment.
  • packages/client/src/auth-login-register-envelope.test.ts — const RESIDUE becomes [] (covers register() and login()), plus two negative controls at ③ that carried the residue alongside a deliberately reintroduced defect: ['success','data.user.image'] becomes ['success'], and ['data.session','data.user.image'] becomes ['data.session'].

Discriminating counts after the edit: residue inside any toEqual(...) expectation is 0 in both files (grep -z used, so a wrapped phrase cannot give a false zero), while toEqual( itself is still present 1x and 6x respectively — the lists are empty because the residue closed, not because the assertions were deleted. The two surviving data.user.image mentions are past-tense comments recording the closure.

The lists stay pinned as exhaustive, so a regression on success or data.session reddens them instead of hiding inside an already-failing parse.

Acceptance

「SessionResponseSchema.safeParse(await client.auth.me()) succeeds for a signed-in user with no avatar on all three /auth/* routes」 — met, through a real AuthManager over a real ObjectQL on a real SqliteWasmDriver: get-session (case ①), register() and login() (case ②). 22 passed.

Acceptance notes

auth.zod.ts carries image: z.string().optional() at a second site, and the ruling does not reach it. The ruling names the declaration at old :33 (.describe('Avatar URL')). The second site — old :97, now :111 — is RegisterRequestSchema.image, and it is deliberately left alone:

  • It sits under the file's own // Requests banner, not the // Responses one.
  • Its producer is the opposite party. packages/client/src/index.ts:4463 types it as the input parameter — register: async (request: RegisterRequest): Promise pointing at SessionResponse — so the API caller authors it, while SessionUserSchema is what better-auth serves. packages/cli/src/commands/register.ts:128 constructs that payload by hand and does not set image at all.
  • Nothing in this card measures a registration caller sending null. Widening an input surface would be a separate published accept-set change with its own Clause-② weight, and it is unruled.

So 「the one declaration」 means one of the two, not both. Noted, not filed — there is no measured defect here, and no in-flight PR or person is carrying this file otherwise.

The claim's 工作项面 reading was incomplete (reported, not a defect): it listed residue at auth-login-register-envelope.test.ts :33, :39, :310-312, but live assertion rows also sat at :319 (const RESIDUE), :346 and :362 (the two negative controls). Removing only the listed rows would have left the suite red.


Generated by Claude Code


Generated by Claude Code

…ute serves

`SessionUserSchema.image` was `z.string().optional()` - a string or the key
absent, and never `null`. better-auth owns the avatar column, stores it
nullable and serialises it present-and-null, so every `/auth/*` session body
the platform produces carried a value its own published declaration rejected:

    SessionResponseSchema.safeParse(await client.auth.me())
      -> [{ path: ["data","user","image"], code: "invalid_type",
            message: "Invalid input: expected string, received null" }]

The declaration is now `z.string().nullish()`: a string, `null`, or the key
absent are all accepted, and the full envelope parses on `get-session`,
`sign-up/email` and `sign-in/email` alike.

`.nullish()`, not `.nullable()`: the key's ABSENCE is a legal shape today and
no producer was ever measured omitting it, so `.nullable()` would have retired
a live shape as the price of admitting `null`. Pure widening - every body legal
before this change is still legal, and a number or an object is still refused
at `data.user.image`.

The residue rows both client envelope suites pinned as an exhaustive issue list
go in one stroke rather than half each: the lists are now empty, and they stay
pinned as exhaustive so a regression on `success` or `data.session` reddens
them instead of hiding inside an already-failing parse. The two negative
controls that carried the residue alongside a deliberately reintroduced defect
drop only that row and keep their own.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 16, 2026
@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 fed4a15ab5b50633553a939e4a9793e5a17ac530 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

….image

`content/docs/references/api/auth.mdx` is generated from the schema, so the
source edit and the reference page are two carriers and a source-only fix
ships the old sentence. `check:docs` caught it; regenerated with
`gen:schema && gen:docs`, never hand-edited.

Both SessionUser tables - `SessionUser.Properties` and the nested
`UserProfileResponse.data` - now read `string | null` where they read `string`.
The third `image` row on the page, under `RegisterRequest`, is deliberately
unchanged: it is a REQUEST field the API caller authors, not the response
field better-auth serves, and it is outside the ruling.

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

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cd5fafdb7da7fbb2c8c3c9d0b52eae9f542fd0ba

① Derived judgments

  1. SessionUserSchema.image: z.string().optional() → z.string().nullish() (packages/spec/src/api/auth.zod.ts:47, .describe('Avatar URL') kept) — right. It is the ruled edit (batch 🔗 Broken links detected in documentation #138 item 1, 「138 同意」): .nullish(), not .nullable(). Proven both directions on my own instrument (probe.ts, tsx, base schema from git show bf61f0a196:… vs head): the null row moved FAIL→pass; the ABSENT row and explicit-undefined row pass on BOTH base and head, so the legal 「key absent」 shape is retained; .nullable() would have reddened the ABSENT row and did not.
  2. SessionResponseSchema.data.user.image accept set gains null — right; it is the embedding of judgment 1 and the card's measured defect path (data.user.image [invalid_type] expected string, received null on base, [] on head — message matches the card verbatim).
  3. UserProfileResponseSchema.data.image accept set gains null — right. Same declaration, second embedding (auth.zod.ts:131 data: SessionUserSchema); 「the one declaration」 reaches every embedding of it by construction. Probe: base FAIL, head pass; 42 still refused on both.
  4. Published TypeScript type SessionUser['image'] widens string | undefined → string | null | undefined — right. Packed d.ts/d.mts: image: z.ZodOptional<z.ZodNullable<z.ZodString>> ×3 (schema + both embeddings); image: z.ZodOptional<z.ZodString> ×1 (RegisterRequest). All four CI Type Check lanes green on this head; in-repo runtime readers of .image on a SessionUser-typed value: 0 (population sweep of plugin-auth, plugin-hono-server, client, cli src; every hit is a comment, a whitelist string, or the client's own already-string | null raw types at index.ts:1066/:1270/:4592); runtime .parse/.safeParse callers of the three widened schemas: 0 (lit: 2 test files). Runtime bytes do not change — better-auth served null before this PR; only the declaration moves.
  5. RegisterRequestSchema.image (auth.zod.ts:111) left as z.string().optional() — right, and the ruling does not reach it. Decided independently: it sits under // Requests (banner at :92-94); its only consumer is packages/client/src/index.ts:4463 register: async (request: RegisterRequest): Promise<SessionResponse>, which JSON.stringifys the caller's object to better-auth /sign-up/email — the caller authors the value, better-auth consumes it; packages/cli/src/commands/register.ts builds the payload from email/name/password flags with no image at all; runtime parsers of RegisterRequestSchema: 0 (the only .parse hits are spec's own auth.test.ts:252-282). Probe on head: RegisterRequest.image=null still refused, identical to base. Widening it would be an unruled input-surface accept-set change with its own Clause-② weight. The round did not do less than the card needs.
  6. JSON Schema json-schema/api/SessionUser.json image → {"description":"Avatar URL","anyOf":[{"type":"string"},{"type":"null"}]}, mirrored under SessionResponse and UserProfileResponse; RegisterRequest.json image stays {"type":"string"} — right. Build output (not tracked), re-derived on the tarball.
  7. Generated reference content/docs/references/api/auth.mdx: rows :159 (SessionUser Properties) and :204 (UserProfileResponse.data) → string \| null; row :92 (RegisterRequest) unchanged string — right; at merge base all three read string. Commit cd5fafdb touches exactly that one file (+2/−2); nothing outside content/docs/references/** moved; check:docs exit 0 twice and check:generated all 15 artifacts up to date.
  8. Residue rows: 5 assertions across both envelope suites move from ['data.user.image']-bearing lists to residue-free lists, still pinned as exhaustive (toEqual([]), RESIDUE: string[] = [], ['success'], ['data.session']) — right; one stroke, both files (client SDK auth.login / auth.register normalize into data but never set success — neither satisfies the SessionResponse they declare #17234's suite included).
  9. Nothing retired, no ADR-0087 entry, no authorable key moved — right: check-adr-0087-registration --base bf61f0a196 exit 0; check:authorable-surface ✓ inside check:generated; authorable-surface.base.json diff vs merge base empty.

② Semver level

minor on @objectstack/spec, Clause-②: yes, arm (widening) implied and not misdeclared — right. Rule on origin/main (AGENTS.md, blob fb8bae3e, § Post-Task Checklist item 3, lines 1067-1068): 「The declaration is Clause-②: yes|no plus at most one arm from the closed pair (widening)/(narrowing): yes takes at least minor, (narrowing) is BREAKING, no (widening) malformed.」 Re-derived on the packed tarball (pnpm pack of packages/spec at head, objectstack-spec-17.4.0.tgz, 27.4 MB): runtime dist/api/index.{js,mjs} and dist/browser/api/index.{js,mjs} each carry nullish().describe('Avatar URL') 1× (lit) and optional().describe('Avatar URL') 0× (dark); whole-tarball files with the nullish marker 5 (4 dist + shipped src/api/auth.zod.ts), with the optional marker 0; d.ts ZodOptional<ZodNullable<ZodString>> ×3; json-schema/api/SessionUser.json anyOf [string,null]. Every body legal before is legal after (probe: string/ABSENT/undefined/empty-string pass on both legs); exactly the null rows moved ⇒ pure widening ⇒ minor is the floor and nothing is breaking. openapi.json in the tarball does not contain SessionUser and is not a carrier. Changeset frontmatter "@objectstack/spec": minor; check-changeset-no-major exit 0; check-empty-changeset exit 0; Clause-② carrier gate --pair 18501 exit 0 (both carriers agree).

③ Boundary flags

  • Second declaration (:111) — answered, see ①.5. Population: runtime parsers 0; CLI image flags 0; client callers 1 (input direction). Ruling scope holds at 「one of the two」.
  • Residue population — answered. Merge base bf61f0a196: literal data.user.image in 4 live code lines carrying 5 assertions (auth-get-session-envelope.test.ts:310; auth-login-register-envelope.test.ts:319 const RESIDUE feeding :327/:333, plus :346, :362). Head: 0 live assertion rows carry it; toEqual( population in the two files = 7 lines (5 issue-path assertions, 2 wire-key assertions), all residue-free; remaining mentions are 3 past-tense comments (get-session:27, login-register:33,:310-312). Repo-wide at head: no other file asserts the old list (only client/src/index.ts:1515, a comment — next flag).
  • Ablation — answered, reproduced my own way in a detached worktree at the head: mutate .nullish()→.optional() (blob b06b60a5… → cea33409…, git diff --stat 1 file / 1 line), OS_SKIP_DTS=1 rebuild exit 0, dist preflight optional-marker 4 built files / nullish 0; suite Tests 5 failed | 17 passed (22), exit 1; the 5 red are exactly the 5 PR-edited assertions (3× expected ['data.user.image'] to deeply equal [], 1× ['success','data.user.image'] vs ['success'], 1× ['data.session','data.user.image'] vs ['data.session']). Restore: blob back to b06b60a5…, porcelain 0 lines, rebuild exit 0, preflight nullish 4 / optional 0, suite 22 passed exit 0. Baseline before mutation 22 passed exit 0.
  • Dangerous direction — answered. Head: 42, {}, [], false all refused at data.user.image [invalid_type]; lit control (session removed) reports data.session. "" (empty string) is accepted on both base and head (z.string() carries no .min(1)/.url()) — pre-existing, not moved by this PR; noted for PM (dedupe: SessionUser.image, empty string, .url()). Downstream null-safety: no in-repo runtime consumer reads the value; bytes unchanged; type widening compiled green in all four Type Check lanes.
  • Console Pin Gate skipped on this head — answered by measurement: pinned sibling objectui@53ded82b imports SessionUser/SessionResponse/UserProfileResponse 0 files (lit control useAuth 165 files); its own packages/auth/src/types.ts:65 image?: string is its own type and already receives better-auth's null at runtime (its fixtures model image: null). The widening cannot reach its tsc.
  • Stale JSDoc, not a contract surface — packages/client/src/index.ts:1514-1517 (inside non-exported normalizeSessionResponse, not in any d.ts) still reads present-tense 「with one gap that is NOT this: data.user.image served null against a declared string | undefined (SessionUser.image is declared z.string().optional(), but every /auth/* session route serves "image": null — no real session body parses as SessionResponse #17235, tracked separately)」 — false after this PR. Not blocking; PM to file a comment-drift rider. Dedupe: normalizeSessionResponse, data.user.image, JSDoc, #17235, stale comment.
  • Same-class candidates, NOT MEASURED here — packages/spec/src/identity/identity.zod.ts:44 UserSchema.image: z.string().url().optional() and identity/organization.zod.ts:41 OrganizationSchema.logo: z.string().url().optional(): both columns are better-auth-owned and nullable by the same model; whether any route serves them present-and-null was not measured in this review (runtime parsers of either schema in plugin-auth/hono-server/client: 0). Escalate to PM for a measurement card. Dedupe: UserSchema.image, OrganizationSchema.logo, present-and-null, better-auth, nullish, identity.zod.ts.
  • Clause-② carrier attribution — gate exit 0 but prints 「ATTRIBUTION NOT VERIFIED: the governing claim comment carries no Session: line」; seat hygiene on the card, not a PR defect.
  • CI — latest run per check NAME on cd5fafdb: 31 success, 4 skipped (Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke opt-in), 0 in progress, 0 failed at 2026-09-16T17:12:52Z; Lint & Repo Gates completed success 17:11:43Z; all six Test Core shards and all Type Check lanes success. No cancelled lane read as any verdict.

Implemented-by: claude/issue-17235-sessionuser-image-nullish
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Landing provenance — PR #18501, card #17235, head cd5fafdb7da7fbb2c8c3c9d0b52eae9f542fd0ba

domain:spec execution seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-16T17:17Z. Every reading is command output taken in this window, ⛔ not recall and ⛔ not any round's self-report.

reading value
reviewer tier by transcript 154 / 154 claude-fable-5-1; control an os-dev round in the same container 297 / 297 claude-opus-5
record, adopted verbatim comment 5701552216
independence pair Implemented-by: claude/issue-17235-sessionuser-image-nullish · Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

落地前检三条

check reading
① at-tier PASS naming this head record 5701552216, Head-sha: cd5fafdb7da7fbb2c8c3c9d0b52eae9f542fd0ba
② --pair 18501, re-read after the carrier strip exit 0 — readable in the fixed spelling on the correction comment, both carriers agree
③ every check green, latest run per NAME read after the ready flip, below ⚠️

check-governed-merges.mjs --pr 18501 → 0 of 5 paths hit the register ⇒ NOT governed, derived on the FINAL file list.

⚠️ ③ is read after the ready flip and after the carrier strip: a strip re-triggers Check Changeset and a ready flip starts Governed Surface Queue Guard.

⭐ Why this review is worth reading rather than just counting

It answered the hard question independently and the answer was 「no」. The seat handed the round a fence: the same file carries a second image: z.string().optional(), and if the honest answer were 「both must move」 the round was to stop and report. The round said it is RegisterRequestSchema.image, and the review re-derived that from the consumer side rather than from the banner: its only consumer JSON.stringifys the caller's object to better-auth /sign-up/email, and the CLI builds that payload with no image at all. ⇒ the caller authors :111 while better-auth serves :47 — same file, opposite direction. Runtime parsers of RegisterRequestSchema: 0. ⛔ The ruling reaches one of the two, and widening the other would be an unruled input-surface change.

「Pure widening」 was proven, not asserted. The ABSENT row and the explicit-undefined row pass on both base and head ⇒ the legal 「key absent」 shape survives, which is exactly what .nullable() would have retired and what the ruling forbade. The dangerous direction still refuses 42, {}, [], false.

The ablation was reproduced independently, mutation proven by blob hash (b06b60a5… → cea33409…), the ablation proven live in the built artifact the suite consumes (optional-marker in 4 dist files, nullish 0), 5 red exactly matching the 5 edited assertions, restore proven by blob hash and a clean porcelain, ⛔ not by an exit code.

The consumer question a widening actually owes — does anything downstream now receive null where it could not? — was measured: in-repo runtime readers of .image on a SessionUser-typed value = 0, runtime .parse/.safeParse callers of the three widened schemas = 0, and the pinned objectui sibling imports these types in 0 files (lit control useAuth 165). Runtime bytes do not move; only the declaration does.

Residue filed BEFORE this enqueue, ⛔ not after

The empty-string note (SessionUser.image accepts "" on both legs, pre-existing) is carried inside #18509 rather than filed separately — it only becomes live if that card adds .url() reasoning to the family.

⛔ What this landing does not settle, and one piece of seat hygiene

--pair prints 「ATTRIBUTION NOT VERIFIED: the governing claim comment carries no Session: line」. That is this seat's omission, ⛔ not a PR defect: the template at SKILL.md:802 carries the line and I composed the block from memory instead of copying it. The gate's reading stands and the pair is exit 0. Recorded rather than quietly left.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 16, 2026 17:17
@os-warren
os-warren added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit 0e51278 Sep 16, 2026
54 checks passed
@os-warren
os-warren deleted the claude/issue-17235-sessionuser-image-nullish branch September 16, 2026 17:44
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…the shape better-auth serves (objectstack-ai#18718)

Fixes objectstack-ai#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.

objectstack-ai#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 objectstack-ai#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
objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#17235 refused,
and the table is why the same refusal holds here.

⭐ **The card's carried boundary note does not come live.** objectstack-ai#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 objectstack-ai#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](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…mage gap as closed, not tracked (objectstack-ai#18855)

Fixes objectstack-ai#18510

Clause-②: no

The JSDoc block above `normalizeSessionResponse` says what the lift
compensates for, so it names the cards that opened and closed each
compensation. One clause was still in the present tense:

```text
… with one gap that is NOT this: `data.user.image` served `null` against a declared
`string | undefined` (objectstack-ai#17235, tracked separately).
```

⚠️ Quoted in a fence, not a blockquote, deliberately: this PR must not
become a delivering
reference for the card that sentence names.

Both halves went false when PR objectstack-ai#18501 widened `SessionUserSchema.image`
to `z.string().nullish()` and objectstack-ai#17235 moved to closed. It now reads in
the past tense, names the widening that closed it, and points at the
residue list in `auth-login-register-envelope.test.ts` ② that is pinned
empty. Nothing else in the block moves.

⭐ Located **by symbol**, never by line number: the card's `:1514-1517`
had already drifted to `:1520-1531` by the time this was dispatched, and
the pin added here locates the block the same way.

## Premise re-verified independently of the dispatch

Both of triage's sequencing conditions, re-read rather than inherited:

- PR objectstack-ai#18501 is merged (`merged_at` 2026-09-16T17:44:07Z) and objectstack-ai#17235 is
`closed` / `completed` (2026-09-16T17:44:08Z).
- On the tree, located by symbol: `SessionUserSchema` is declared
`lazySchema(() => z.object({…}))` in
`packages/spec/src/api/auth.zod.ts`, and `image` inside it reads
`z.string().nullish()`. ⚠️ The near-miss at `:111` is
`RegisterRequestSchema` and was not touched.

## "Only comments changed" is NOT "nothing published moves" — measured

The card asserts the function is not exported and its comment reaches no
`.d.ts`, so it "ships to nobody". That assertion was measured against
the built artifacts rather than inherited, and it is **half right**.

`@objectstack/client` ships `dist`, `README.md`, `CHANGELOG.md`. ⭐
`dist` is six files — `index.js`, `index.mjs`, `index.d.ts`,
`index.d.mts` and a `.map` beside each bundle. This package emits **no**
`*.cjs` and **no** `*.d.cts`, so a `*.d.cts` probe here would measure an
absent file and return a zero about nothing; the four that exist were
probed instead.

Built from the same tree before and after the change:

| probe | index.js | index.mjs | index.d.ts | index.d.mts | index.js.map
| index.mjs.map |
|:--|--:|--:|--:|--:|--:|--:|
| NEG `no longer residue` (block-unique) | 0 | 0 | 0 | 0 | 0 | 0 |
| NEG `data.user.image` | 0 | 0 | 0 | 0 | 0 | 0 |
| NEG `tracked separately` | 0 | 0 | 0 | 0 | 0 | 0 |
| POS `{@link normalizeSessionResponse}` | 3 | 3 | 3 | 3 | 0 | 0 |
| POS `set-auth-token` | 4 | 4 | 3 | 3 | 0 | 0 |

- **The block's own text ships nowhere** — the negative probes are zero
across all six while the positive controls land, so this is a
discrimination, not a dead probe.
- **But comment text from this file DOES reach the published types.**
`{@link normalizeSessionResponse}` appears 3× in each `.d.ts`, carried
there by the JSDoc of the **exported** `auth.login` / `auth.register` /
`auth.me` that link to it. The card's reason is the load-bearing half:
the block does not ship because the function it documents is not
exported — not because comments in this file do not ship.
- **And the published bytes DO move.** `index.js`, `index.mjs`,
`index.d.ts` and `index.d.mts` are byte-identical across the change
(sha256, with the HEAD build re-run as a reproducibility control). Both
`.map` files **differ**: neither carries `sourcesContent` (`pnpm
check:sourcemap-no-sources-content` is why), so no comment text ships
inside them either — the position table shifts because the rewritten
comment is two lines longer.

⇒ `skip-changeset` would have been a false declaration. This carries a
`patch` changeset for `@objectstack/client`, and the measurement is
recorded in the changeset body rather than left in a PR nobody greps.

## The pin

`packages/client/src/normalize-session-response-jsdoc.pin.test.ts` — ⛔
**not** a pin on the block's wording. It pins one relation: for each
card on a declared closed roster (objectstack-ai#17234, objectstack-ai#17235, objectstack-ai#17238, objectstack-ai#17881, each
with the state that was read), the block may cite it — past tense is the
point of citing it at all — but may not carry an open-tracking phrase
beside that citation. A card that really is open can still be tracked in
those words.

Red before, green after, with controls that can fail:

- **red before** — on the unfixed source, exactly ② failed (`1 failed |
4 passed`), reporting `objectstack-ai#17235`.
- **green after** — `5 passed`, inside a suite of `47 passed (47)` files
/ `550 passed` tests.
- **locator control ①** — the extractor must find *this* block (asserts
its anchors `objectstack-ai#17234`, `data.token`, `SessionResponseSchema`, and that it
is one whole JSDoc block), and a symbol the file does not declare must
yield no block. Without it an extractor returning `''` would make ② pass
while measuring nothing.
- **negative control ③** — the pre-fix sentence, copied verbatim from
the base, must still be reported; and the past-tense form must not be.

⚠️ ③ earned its keep on its first run: the past-tense sentence
originally drafted for this fix opened *"the one gap that was still open
when this was written"*, and `still open` is on the vocabulary — the
rewrite would have tripped the pin written to satisfy it. The shipped
sentence says "remained", and the control records why.

**Tier measured in both directions**, because adding a pin can move a
whole file out of its vitest tier: the unit config's roster is 46 files
before the pin file exists and 47 after, and the diff is exactly one
added line — this file — with nothing dropped. `packages/client` tiers
by path (`tests/integration/**` is excluded from the unit config and has
its own config), so a new `src/*.test.ts` lands in the unit tier; the
`vitest-tiers.ts` content-based mechanism is `packages/cli`'s and does
not reach here.

## Verification

- `pnpm --filter @objectstack/client test` — **47 passed (47) files, 550
passed tests**.
- `pnpm --filter @objectstack/client typecheck` — `tsc --noEmit` clean;
`check:test-typecheck` reports the test layer compiles under
`tsconfig.test.json`, 0 files / 0 errors, which is what covers the new
pin file.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived **59** gate commands from the real
change set (re-derived after merging `origin/main`, so the stale-tree
warning the first derivation printed is cleared). **58 pass.**
- `pnpm lint` — the repo-wide union (`eslint . --no-inline-config`),
**exit 0**, at `52b7812953`. ⚠️ `dispatch-gates.mjs` does not name this
family; it was run anyway.
- ⛔ **NOT MEASURED: `pnpm check:dual-build-cjs-loads`.** It refuses with
`PREREQUISITE NOT MET … ⛔ This is NOT a pass: nothing was measured`
(exit 3) because 34 packages have no `dist` — its prerequisite is a
repo-wide `pnpm build`, which CI performs on a fresh checkout. It is
recorded as unmeasured rather than as a pass, and this change cannot
move it: the four loadable artifacts are byte-identical above.

## Acceptance notes

⛔ Noted, not filed — none is a reproducible defect, a contract
violation, or a metadata-authoring trap.

1. **The card's own reach measurement is wrong, in attribution and in
count.** It records the literal `data.user.image` as appearing in "**3**
past-tense comments inside `packages/spec`'s own test files plus this
one line in `packages/client`". At `origin/main` it appears in **2**
past-tense comments inside **`packages/client`**'s own test file
(`auth-login-register-envelope.test.ts:33` and `:310`), **zero** in any
`packages/spec` test file, plus one changeset line and the target line.
The fix is unaffected — the target was the only present-tense
occurrence, which is what the card was actually about. Successor: this
PR.
2. **A comment-only diff moves published bytes whenever the package
emits source maps.** The shortcut "it is only a comment, so nothing
publishes" is measurably false for any package shipping a `dist` with
`.map` files: a comment whose line count changes shifts every mapping
after it. Successor: named — the measurement is written into this PR's
changeset body, so it ships to `packages/client/CHANGELOG.md` rather
than staying in a PR nobody greps.
3. **Container hazard, for sibling agents on this box, not a repo
defect.** `pnpm check:type-check-debt` runs a turbo build; when that
build is SIGKILLed under memory pressure mid-DTS, it leaves
`packages/spec/dist` with **zero** `.d.ts` files, after which unrelated
packages' `typecheck` fails with a pile of `TS7016 Could not find a
declaration file for module '@objectstack/spec/api'` that reads exactly
like the caller's own change breaking something (AGENTS.md Multi-agent
discipline §9, the `packages/spec/dist` row). ⚠️ Counter-intuitive
recovery, measured here: **raising** `--max-old-space-size` made it
worse (8192 and 4096 were both killed at exit 137; the cgroup kills
before V8's own limit bites), and **3072** completed. Successor: this
note.

Authored by the `domain:cli` execution seat in session
`session_01DvvamiacK328idtBYJBxV3`
(https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3). ⚠️ The
attribution lives in this prose line rather than a footer of its own:
measured on this PR, a REST **create** stores the footer block
byte-identical, while a REST **edit** APPENDS its own bare block on top
of whatever the body already carries — so a body that arrives at its
final text through an edit can only carry one footer by sending none.


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…l three 'not addressed here' clauses had been answered (objectstack-ai#18765)

Fixes objectstack-ai#18652

`.changeset/client-get-session-envelope-and-refresh-read.md` is
**pending release
input**, not a note: `changeset version` copies it verbatim into
`packages/client/CHANGELOG.md`, which is in `@objectstack/client`'s
published
`files[]` and ships in the npm tarball. AGENTS.md's release-owned table
states the
deadline in its own words — *"Your PR's input is its **changeset**, on a
hard,
unwatched deadline: the release that consumes it deletes that input and
publishes
the sentence."*

## ⚠️ This PR corrects a pending changeset it did not author — read this
first

`check:empty-changeset`'s real scan **reds on this PR by design** and
asks for
exactly this paragraph. Its own text names the two classes and their
opposite
remedies; this is the **DELIBERATE CORRECTION** class, and the gate says
*"this
gate stays red either way, and staying red is what puts the decision in
front of a
person instead of routing around it."*

- **The note:**
`.changeset/client-get-session-envelope-and-refresh-read.md`,
introduced by `5de93728a` (PR objectstack-ai#17237, **merged** 2026-09-09T21:43:50Z —
confirmed
  at the tree, so there is no open author to defer to).
- **What changed under it:** its closing paragraph was an **undated,
present-tense**
register of what that change left undone, and all three of its clauses
had since
been falsified on `origin/main` by other cards' landings (readings
below).
- **⛔ Not the collision class.** No changeset filename was drawn or
overwritten
here; this PR adds none. ⛔ Do not restore the file from the merge base —
that
  republishes three false sentences.
- ⛔ **I have deliberately NOT applied `skip-changeset`.** It would
exempt the
`changeset-check` job **wholesale** (`lint.yml`'s own note on the
self-test split
says so), which is the one label that would silence this refusal.
Suppressing it
is the routing-around the gate forbids, so the label decision is left to
a human.

## Readings — measured on this checkout, ⛔ not relayed from the citing
cards

Each clause, the commit that falsified it, and its ancestry on
`origin/main`
(`git merge-base --is-ancestor SHA origin/main`, where **exit 0 is
self-proving**):

| clause, as it read | measured now | falsified by | ancestor? |
|:--|:--|:--|:--|
| the anonymous `null` "would need the published return annotation to
widen" | the **producer** moved instead; the annotation is untouched |
`374d9d3afa` (objectstack-ai#17881), 2026-09-12 | exit 0 |
| `SessionUser.image` "declared `z.string().optional()`" | `image:
z.string().nullish().describe('Avatar URL')` —
`packages/spec/src/api/auth.zod.ts` | `0e51278f3` (objectstack-ai#18501, for objectstack-ai#17235),
2026-09-16 | exit 0 |
| `auth.login` / `auth.register` "normalize into `data` but set no
`success`" | both run `normalizeSessionResponse`, which returns `{
success: true, ...body, data }` | `01388fe81` (objectstack-ai#17791, for objectstack-ai#17234),
2026-09-12 | exit 0 |

**Clause 1, at the definition rather than a call site.**
`packages/plugins/plugin-auth/src/anonymous-session-refusal.ts:124`
`refuseAnonymousSession` keys on the **answer shape** and on nothing
else — three
early returns: `isGetSessionPath(endpointPath)`, `response.status !==
200`, and
`body.trim() !== ANONYMOUS_BODY` where `ANONYMOUS_BODY = 'null'`
(compared as
**text**, so `'0'` / `'""'` / `'false'` are left alone). It never reads
how the
caller became anonymous, so *never signed in*, *unknown cookie* and
*revoked
session* all convert alike. `ANONYMOUS_SESSION_REFUSAL_STATUS = 401`,
and it is
wired at `auth-manager.ts:5679`, the one seam every vendor route passes
through.
⇒ the anonymous answer is not a `null` outside the declared type; it is
a
**rejection**, which a `Promise` of `SessionResponse` annotation already
permits.

## What the diff does, and the one design choice in it

Each clause is **kept as what it recorded**, anchored to when it was
written
(*"sat outside … when this change was written"*), with the landing that
answered it
beside it. Two reasons that shape rather than a straight fact-swap:

1. A fact-swap re-arms the same defect — the census's own header names
the cause:
*"prose does not re-measure itself"*. A dated clause cannot be falsified
again.
2. It is the same rule the card's own fence applies one paragraph up:
the
better-auth **1.7.2** transcript is correct **because** it is dated and
   attributed. ⛔ That transcript is untouched — verified by needle
(`better-auth 1.7.2` and `(anonymous) -> 200 null` both still present at
lines
   10 and 14).

## Scope note — clauses 2 and 3 were outside the card's fence

Card objectstack-ai#18652 fenced scope to clause 1 and marked clause 2 **not
re-measured, in
either direction**; clause 3 it did not mention. I measured both and
they are false
too, so fixing clause 1 alone was not available: the sentence enumerates
(*"**Two** answers stay outside the declared type"*), so a clause-1-only
repair
would have had to **newly author** the surviving false clauses into
release input —
strictly worse than what was there. Same defect class, same sentence,
same file, no
new verification surface. ⇒ folded in, declared here, and reported
separately to
the dispatching seat. Strip the last two rows if the seat disagrees; the
diff is
one paragraph.

## Tests / gates — the pin question, answered with three readings

⛔ **NOT MEASURED: nothing in this repo can pin changeset prose**, and it
is not an
untried idea — the repo has ruled against building it. Three
measurements, none of
them my own instrument:

1. **Step 49, `check:pm-changeset-deadline-census`** — its header:
*"REPORT-ONLY:
it fails nothing and gates nothing."* It measures **path presence**,
never
prose; its own blind-spot list says *"`window-open` means the FILE
exists, never
that the card's sentence about it is still correct."* Live run on this
checkout:
   `objectstack-ai#18652 → verdict "window-open", assertsPending "pending changeset",
inListing true`, tally `{window-open: 11, consumed: 3}`,
`falsifiedAssertions: []`.
The row is **identical before and after this diff** (I amend, not
delete).
   ⇒ reachable, but structurally unable to fail. Not a pin.
2. **Step 122, `check:changeset-gate-self-tests`** — `lint.yml`'s own
note:
*"The SELF-TEST halves only — the real scans stay in pr-automation.yml's
   `changeset-check`."* `dispatch-gates` scores self-test-only families
*"checker-health only … NOT a PR verdict."* Green here and green without
this
   diff. Not a pin.
3. **Building one is forbidden right now.**
`changeset-deadline-census.mjs`:
*"⛔ This file is deliberately NOT the enforcement half … report-only
first, the
census is the deliverable, expansion only when the census reads zero
including
its blind spot."* The census reads **3 exposed**, not zero.
Independently,
`.changeset/**` is a ruled scan exemption — *"a changeset is that record
before
   it is compiled into a CHANGELOG"*
   (`packages/objectql/src/action-owner-key-single-source.test.ts`,
`NOT_A_STALE_MENTION`). And the file is consumed and deleted at the next
release, so a pin reading that path becomes a phantom check by
construction.

⭐ The one reachable instrument that **does** respond to this diff is a
third the
roster reading did not name — `check-empty-changeset.mjs`'s real scan —
and its
polarity is **inverted**: it is green without this change and red with
it, on the
**act**, not the prose. That is the human-confirmation gate above, not a
pin.

**Derived sweep** — `node scripts/pm/dispatch-gates.mjs --commands
--repo
objectstack-ai/objectstack` at `aefbf5927`, all 18 derived commands plus
`check:changeset-fixed` (flagged ⛔ *roster under `.changeset`*) run
locally:

```
adr0087 --base 0    no-major --base 0   closing-parity 0    comment-mask-corpus 0
adr0087 --self 0    no-major --self 0   closing-parity 0    driver-memory-census 0
empty-changeset --self 0                release-rehearsal --self 0
changeset-gate-self-tests 0             pm-changeset-deadline-census 0
nul-bytes 0   objectui-changeset 0   published-files 0   refd-timer-probe 0
watch-hint-literal 0   changeset-fixed 0
empty-changeset --base 1  <- THE DESIGNED RED, declared above
```

- **NOT MEASURED: `check:rerun-safety-verdict`.** `dispatch-gates`
flagged a stale
tree; the roster delta across it is exactly this one new whole-tree
family
(`95b21b33b`, objectstack-ai#18746). It does not exist in this checkout, so `pnpm`
exited 254
(script-not-found) — ⛔ that is not a red gate and not a pass. It is
self-test-only
and reads only its own fixtures, so it cannot judge this diff either
way.
- **No control characters**: `grep -naP
'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'` over the
  changed file exits 1 (clean), beyond `check:nul-bytes`.
- **No `pnpm lint` narrowing claimed, and no build/test**: this diff
compiles
nothing and is read by no test. `turbo`'s graph is not consulted because
the path
  is in no package.

## Does this diff owe a changeset of its own? — measured, ⛔ not assumed

**No, and adding one would be a defect.** `@objectstack/client`'s
published
`files[]` is `["dist","README.md","CHANGELOG.md"]`; `.changeset/**` is
not in it, and
this diff moves no `dist` byte. The published text that *does* move is
this very
entry — so the amended changeset **is** the release input. A second
changeset would
emit a second CHANGELOG bullet correcting the first, which is the shape
AGENTS.md
forbids by name: *"Factual error in a released entry → amend that entry
in a
dedicated docs-only PR, ⛔ never an erratum in a later entry."* Gate
readings
agree: `check-changeset-no-major --base` 0, `check-adr-0087-registration
--base` 0,
`check-empty-changeset --base` prints *"✓ No empty-frontmatter changeset
introduced
by this diff (1 declaring changeset(s) added)"*. ⛔ `major` does not
exist in this
launch window and nothing here is breaking.

Clause-②: no

No key moves, no accept set widens or narrows, no export changes, no
error code or
`ERROR_CODE_LEDGER` entry moves, and no `packages/spec` path is touched.
The diff is
prose inside one pending changeset; zero lines of shipped code change.

## ⛔ Reported, not touched: the open Version Packages PR

**objectstack-ai#17076 `chore: version packages` is OPEN and already renders this
entry** — at its
head `1c0ce1713` the changeset is **absent** (contents API `404`,
against a `200`
positive control on `.changeset/config.json`) and the paragraph is
compiled into
`packages/client/CHANGELOG.md:506`. ⛔ I did not touch that PR, ran no
release, and
merged nothing.

⭐ **But an open Version Packages PR does not mean the window has closed,
and the
dispatch order's reading that it does is falsified by the repo's own
workflow.**
That branch is a **derived artefact, regenerated from scratch**:

- `release.yml`'s `version-pr` job is `if: github.event_name ==
'schedule' || (… workflow_dispatch && inputs.refresh_version_pr)` — ⛔
**not** `push`; the file's
own note: *"this job regenerates the PR from scratch, so the newest
run's result
is the one that was wanted anyway"*, and *"renders changesets that are
already
  committed on main, so lateness costs nothing."*
- The mechanism, quoted in that file from changesets/action v1: `git
reset --hard SHA` → `pnpm run version` → `git push … --force`.
- Corroborated at the object: objectstack-ai#17076 was created 2026-09-09 and carries
**one**
commit, `1c0ce1713`, authored by `github-actions[bot]` at
2026-09-17T18:14:46Z,
whose parent `e77a23f02` is an **ancestor of `origin/main`** (exit 0)
and only 7
commits behind it. Created eight days before the commit it holds ⇒
force-rebuilt.

⇒ The window closes when a **release consumes** the changeset
(AGENTS.md's wording),
i.e. when objectstack-ai#17076 is merged and published — a human-only act
(`release.yml`:
*"TWO LANES, ONE INVARIANT: ONLY A HUMAN PUBLISHES."*). Until then this
entry is
still amendable at one paragraph, and the next 6-hourly tick re-renders
objectstack-ai#17076 from
the corrected text with no action on that PR. The deadline is real and
this PR is
inside it.

---
🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3

---
_Generated by [Claude
Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_
---

### Landing note added by the dispatching seat (objectstack-ai#6024) — clearing a red
that was the seat's, not this PR's

The earlier red on `The card this PR closes must claim this branch` was
caused by the **claim comment's shape**, not by anything in this diff:
the seat's claim on objectstack-ai#18652 opened `**CLAIM · …**`, and
`scripts/check-closing-target-claim.mjs` selects a comment by a line
beginning `Claim:` plus a **separate** `Branch:` directive line. Fixed
on the card (`5720097499`), measured before and after —
`check-clause2-carriers --pair 18765` went from `EXIT=4 /
claim.selected: none` to `EXIT=0 / 1 comment(s) in the pool`, both
carriers reading `no`.

That guard is body- and comment-scoped and re-fires only on a
`pull_request` or `merge_group` event, so **this edit is the event that
re-judges it** — ⛔ not an empty commit, which the deliverer correctly
refused, and ⛔ not a push, since the seat has ruled that all three
clauses stay and no further code change is owed.

⚠️ The two remaining reds are **by design** and are not touched by this
edit: `check-empty-changeset` asks in its own words for a human to
confirm the DELIBERATE CORRECTION class, and `pr-automation.yml`'s
`changeset-check` counts only **added** changesets (this PR amends one
and adds zero). The ruling request is on objectstack-ai#18652 (`5720153848`). ⛔
`skip-changeset` is deliberately NOT applied — it exempts that job
wholesale and would silence the refusal that exists to put this decision
in front of a person.


---
_Generated by [Claude Code](https://claude.ai/code)_

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