Repository navigation
fix(spec): SessionUser.image accepts null, the shape every /auth/* route serves - #18501
Conversation
…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>
📓 Docs Drift Check1 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
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
….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>
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Landing provenance — PR #18501, card #17235, head
|
| 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.
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
- spec/identity:
UserSchema.imageandOrganizationSchema.logoare the same better-auth nullable-column shape #17235 just fixed — measure whether either is served present-and-null #18509 —UserSchema.imageandOrganizationSchema.logoare the same better-auth nullable-column shape, NOT MEASURED for present-and-null. ⛔ Filed as a measurement request, not a widening request: there is no measured victim path, and⚠️ both carry.url(), whichSessionUser.imageis declaredz.string().optional(), but every/auth/*session route serves"image": null— no real session body parses asSessionResponse#17235's declaration did not. - client:
normalizeSessionResponse's JSDoc will state a false present-tense gap the moment PR #18501 lands #18510 —normalizeSessionResponse's JSDoc states a present-tense gap that goes false the moment this PR merges, and points at 「SessionUser.imageis declaredz.string().optional(), but every/auth/*session route serves"image": null— no real session body parses asSessionResponse#17235, tracked separately」, which reads as 「still open」.packages/client, ⛔ not this seat's lane.
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
…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>
…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>
…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>
Fixes #17235
Clause-②: yes — this widens a published accept set (
@objectstack/spec), so the changeset isminor. Theneeds:contract-reviewcarrier 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/mainatfed4a15ab5,AGENTS.md:1067-1068: ayesdeclaration takes at leastminor, it may carry at most one arm from the closed pair(widening)/(narrowing),(narrowing)is BREAKING, andno (widening)is malformed. This diff is a widening, sominoris the floor and nothing here is breaking. (An earlier revision citedAGENTS.md:1043. That was a real read — but off the shared checkout at7e58d177ee, AGENTS.md blobf04446be, which is notmainand whose HEAD other agents move. Measured across three refs: the same rule sits at 1043 on that tree and at 1067 onorigin/main, blobfb8bae3e— and this branch's own merge basebf61f0a196carries the same blob as main, so the worktree would have answered correctly all along. The text was rewritten too, not merely moved. ⇒ A bareAGENTS.md:NNNNis 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()becomesz.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.safeParseon a session body, varying onlydata.user.image:data.user.imagenulldata.user.image [invalid_type] Invalid input: expected string, received null"https://x/a.png"42(number)expected string, received number{}(object)expected string, received objectExactly 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.sessionremoved reportsdata.sessionin 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 admittingnull.Ablation — the pins are real, not a cached green
The declaration was reverted to
.optional(),@objectstack/specrebuilt, and the marker confirmed live in the artifact the suite consumes before reading any result:The 5 reddened assertions are exactly the 5 rows this PR edited; the other 17 were untouched. Restore leg, rebuilt again:
The generated page is a second carrier
content/docs/references/api/auth.mdxis 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:docscaught it on the first push; regenerated withpnpm --filter @objectstack/spec gen:schema && gen:docs, never hand-edited.git status --porcelainafter the regeneration listed exactly one path, insidecontent/docs/references/**; nothing outside it moved, andpackages/spec/authorable-surface.base.jsondid not move.check:docsthen 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 newstring \| nullrow is 2, lit controlAvatar URLis 2, dark controlnullishis 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:97finding 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.imageas 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'])becomestoEqual([]), plus its header comment.packages/client/src/auth-login-register-envelope.test.ts—const RESIDUEbecomes[](coversregister()andlogin()), 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 -zused, so a wrapped phrase cannot give a false zero), whiletoEqual(itself is still present 1x and 6x respectively — the lists are empty because the residue closed, not because the assertions were deleted. The two survivingdata.user.imagementions are past-tense comments recording the closure.The lists stay pinned as exhaustive, so a regression on
successordata.sessionreddens 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 realAuthManagerover a realObjectQLon a realSqliteWasmDriver:get-session(case ①),register()andlogin()(case ②). 22 passed.Acceptance notes
auth.zod.tscarriesimage: 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— isRegisterRequestSchema.image, and it is deliberately left alone:// Requestsbanner, not the// Responsesone.packages/client/src/index.ts:4463types it as the input parameter —register: async (request: RegisterRequest): Promise pointing at SessionResponse— so the API caller authors it, whileSessionUserSchemais what better-auth serves.packages/cli/src/commands/register.ts:128constructs that payload by hand and does not setimageat all.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