Repository navigation
fix(spec): UserSchema.image and OrganizationSchema.logo accept null, the shape better-auth serves - #18718
Conversation
…the shape better-auth serves
Both were `z.string().url().optional()` — a URL string or the key absent,
`null` refused. Both columns are better-auth-owned and nullable
(`sys_user.image` / `sys_organization.logo` are each
`Field.url({ required: false })`, reaching SQLite as `varchar(255)` with
`notnull=0`), and better-auth SELECTs them and serialises them
present-and-null for a user who never set an avatar and an organization
created without a logo.
Measured through a real `AuthManager` (better-auth 1.7.3) over a real
`ObjectQL` on a real `SqliteWasmDriver`, with the platform's own object
definitions — not inferred from the sibling ruling:
/auth/sign-up/email -> user.image = null
/auth/get-session -> user.image = null
/auth/organization/create -> logo = null
/auth/organization/list -> [0].logo = null
/auth/organization/get-full-organization -> logo = null
-> members[].user.image = null
`.nullish()`, not `.nullable()`: the key's absence is a legal shape today,
so `.nullable()` would retire a live shape as the price of admitting null.
`.url()` is kept and does not fight `null` — `.nullish()` wraps the whole
`z.string().url()`, so null and undefined are branches the URL check never
sees while a present string must still be a well-formed URL. Of six inputs
(absent / null / '' / a URL / a non-URL / a number) exactly one row moves.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
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 4dc4c0486bd12a504cf30267d771b3884ed1e601 && git checkout 4dc4c0486bd12a504cf30267d771b3884ed1e601
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin df1b275c71fb7939f085c013779f3daefa5114c7 2d3dea2a59f0c254b8ede6b14b1e77b1710bc670 && git checkout -B drift-repro df1b275c71fb7939f085c013779f3daefa5114c7 && git merge --no-ff 2d3dea2a59f0c254b8ede6b14b1e77b1710bc670
node scripts/docs-audit/affected-docs.mjs --json df1b275c71fb7939f085c013779f3daefa5114c7
|
The docblocks were written before the PR existed and cited a guessed number. No assertion changes. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Contract reviewServed-tier: 119/119 Tier evidence: every ① Derived judgmentsWhat moves, row by row — accept set and public surface:
The head column moves exactly one row from base —
Parses on the served bodies:
② Semver levelChangeset ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
… spec declares (objectstack-ai#19122) Fixes objectstack-ai#18728 **Direction: the maintainer's ruling C** (batch objectstack-ai#158 item 4, comment 5729189649), quoted verbatim into the dispatch word and implemented as written. ⛔ Not re-argued here, and neither the card's A nor its B is implemented — both were superseded. Clause-②: yes (widening) — `updatedAt` moves from required to optional on three published schemas, so the set a consumer may hand to `OrganizationSchema` / `MemberSchema` / `InvitationSchema` grows by exactly one shape: the key being absent. Nothing previously admitted is refused, nothing is renamed, and no producer is required to write it. Contract-review tier. The claim comment declared `Clause-②: no` **conditionally** — 「unless fallback A moves a declaration」. Fallback A fired and moved three, so the condition resolves to `yes`. --- ## Leg 1 — the measurement ruling C made fallback A conditional on Ruling C, verbatim: 「**Fallback A**, decided by measurement first: if the identity wire is produced by better-auth's own serializer and its documented shape carries no `updatedAt`, then for those routes the spec aligns to the documented wire (`updatedAt` optional there) and the reason is written on the card; `metadata` is decoded regardless (it is our column).」 So the first leg was that measurement, per route, taken against the installed **better-auth 1.7.3** in this worktree (`packages/plugins/plugin-auth/node_modules/better-auth`, realpath under the pnpm store, version read from its own manifest). | route | who serialises the response | evidence | documented `updatedAt`? | `metadata` before | |---|---|---|---|---| | `setActive` — `POST /organization/set-active` | **better-auth's own handler**, `ctx.json(organization)` from `findOrganizationById` | `crud-org.mjs:379` handler, `:427` the answer; `adapter.mjs:400` the read | **no** | stored JSON text | | `get` — `GET /organization/get-full-organization` | **better-auth's own handler**, `ctx.json(organization)` from `findFullOrganization` | `crud-org.mjs:336`, `:371`; `adapter.mjs:424` | **no** | stored JSON text | | `delete` — `POST /organization/delete` | **better-auth's own handler**, `ctx.json(org)` from `findOrganizationById` | `crud-org.mjs:239`, `:291`; `adapter.mjs:400` | **no** | stored JSON text | | `list` — `GET /organization/list` | **better-auth's own handler**, `ctx.json(organizations)` from `listOrganizations`; the organization arrives through the adapter factory's fallback join, itself another `findOne` on this model | `crud-org.mjs:436`, `:455`; `adapter.mjs:474`; `@better-auth/core` `dist/db/adapter/factory.mjs` `handleFallbackJoin` | **no** | stored JSON text | | `create` — `POST /organization/create` | better-auth's handler; its organization adapter decodes the echo itself | `adapter.mjs:141`, decode at `:152` | **no** | decoded already | | `update` — `POST /organization/update` | better-auth's handler; same, via `parseJSON` | `adapter.mjs:352`, decode at `:367` | **no** | decoded already | Three mechanisms make that table a reading rather than an impression: 1. **The routes are the vendor's, mounted through one catch-all.** `packages/plugins/plugin-auth/src/auth-route-ledger.ts:257-277` books every `organization/*` row `source: 'better-auth'`, and the ledger's own header states the mount is `rawApp.all(basePath + '/*')` with no per-route registration. Each handler answers `ctx.json(...)` directly; there is no ObjectStack post-processing between the adapter and the wire. 2. **The vendor's declared model has no `updatedAt`, and its output transform emits declared fields only.** `dist/plugins/organization/organization.mjs:705` declares `organization` as `name` / `slug` / `logo` / `createdAt` / `metadata`; `member` as `organizationId` / `userId` / `role` / `createdAt`; `invitation` as `organizationId` / `email` / `role` / `teamId` / `status` / `expiresAt` / `createdAt` / `inviterId`. `@better-auth/core` `dist/db/adapter/factory.mjs:144` `transformOutput` iterates `for (const key in tableSchema)` — an undeclared column is dropped before any route sees it. `filterOutputFields` (`@better-auth/core` `dist/utils/db.mjs:6`) then removes only `additionalFields` marked not-returned. 3. ⭐ **Lit control, same file and same grep.** `updatedAt` occurs exactly twice in `organization.mjs` — `:617` on `team` and `:694` on `organizationRole`, both of which DO declare it. So the zero on `organization` / `member` / `invitation` is a reading, not a grep artefact. A second control one level up: better-auth's **core** `user` and `account` models do declare `updatedAt` (`@better-auth/core` `dist/db/get-tables.mjs:185` and `:270`), which is why `packages/spec/src/identity/identity.zod.ts` is **not** touched by this PR. ⇒ both of fallback A's conditions hold on all four read routes. **Fallback A fires.** ### One premise inside the ruling's primary arm is false, and it is a finding Ruling C's primary arm reads 「every identity read route puts `updated_at` on the wire as `updatedAt` (**the column exists**)」. Measured: the column exists on `sys_organization` only. | object | `updated_at` column | evidence | |---|---|---| | `sys_organization` | **yes** | `packages/platform-objects/src/identity/sys-organization.object.ts:362` | | `sys_member` | **no** | `sys-member.object.ts` declares `id` / `created_at` / `organization_id` / `user_id` / `role` — nothing else | | `sys_invitation` | **no** | `sys-invitation.object.ts` declares `id` / `created_at` / `organization_id` / `email` / `role` / `status` / `inviter_id` / `expires_at` / `team_id` / `business_unit_id` / `positions` | And the audit family is not injected onto any of the three: all three declare `managedBy: 'better-auth'`, which is the single disposition under which `resolveInjectedSystemColumns` (`packages/spec/src/data/injected-system-columns.ts`) returns the empty plan — audit family included. `sys_organization` has `updated_at` because it declares the field itself. ⇒ for `Member` and `Invitation`, fallback A is forced a second time over: there is no stored value to serve. ⛔ Per the dispatch, `packages/platform-objects/**` is out of surface and a missing column is a finding rather than an edit — so nothing was added there, and this is reported rather than repaired. ### Re-taken card readings, and the drift The card invited re-taking its own first-hand readings rather than trusting them. Every one still holds in substance; the line numbers have drifted (PR objectstack-ai#18718's docblocks moved them), so the card's citations no longer resolve: | card's citation | on this base (`07c6f822e`) | still true? | |---|---|---| | `organization.zod.ts:57` / `:105` / `:183` required `updatedAt` | `:89` / `:137` / `:215` | yes — all three required before this PR | | `identity.zod.ts:55` / `:142` required `updatedAt` | `:86` / `:173` | yes, and deliberately left alone | | three 「not relayed」 notes at client `:1213` / `:1249` / `:1335` | `:1212` / `:1248` / `:1335` | yes | | `return-type-precision.test.ts:1050` pins `string | null | undefined` | `:1050`, exactly | yes | | zero in-repo consumers of the three schemas | zero | yes — every hit is a generated artefact, a CHANGELOG or docs prose. Lit control: `ObjectSchema` has real consumers in `packages/cli/src/commands/`. ⭐ After this PR the count is no longer zero: `@objectstack/client` consumes all three, which is what makes a future drift go red in-repo. | --- ## Leg 2 — what changed, at each end **Producer** (`packages/plugins/plugin-auth/`) - **New** `src/organization-metadata-decode.ts` — decodes `sys_organization.metadata` from stored JSON text into an object, and OMITS the key when the column is unset (`null`, empty, or a stored `null` literal), because the spec declares the key optional and never nullable. Undecodable text and text that decodes to a scalar or array are passed through **untouched**: never invented, never thrown. That makes the consumer's spec parse refuse the body and name the field — loud and located, and distinguishable from an unset column. - **Wired into `src/objectql-adapter.ts`'s READ verbs only** (`findOne`, `findMany`). All four read routes reach the row through those, so one seam covers them with nothing to keep in sync. - ⛔ **Deliberately NOT the write verbs.** better-auth's own organization adapter decodes the `create` / `update` echoes itself and discriminates on the value still being a string (`typeof organization.metadata === 'string' ? JSON.parse(...) : void 0`, `adapter.mjs:152`). Handing it an object would fold the create echo's `metadata` to `undefined` — a regression that reads as "unset". Both directions are pinned. - Measured reason this could not be done by declaration instead: the adapter declares `supportsJSON: true` (`objectql-adapter.ts:827`), so `transformOutput`'s JSON branch — which fires only for a field typed `json` on an adapter declaring `supportsJSON: false` — is unreachable here, and the vendor types `metadata` as a string anyway. **Spec** (`packages/spec/src/identity/organization.zod.ts`, fallback A) - `OrganizationSchema.updatedAt`, `MemberSchema.updatedAt`, `InvitationSchema.updatedAt` become `.optional()`. `.optional()` and not nullish: the key is **absent** on the wire, never `null`. Each carries the measurement and the ruling's own words in its docblock. - ⛔ `identity.zod.ts` untouched — the vendor declares `updatedAt` on `user` and `account`, so no fallback applies there. - ⛔ PR objectstack-ai#18718's `image` / `logo` nullish arm is untouched, neither extended nor reverted. - Regenerated by the sanctioned producers only (`check:generated --fix` ran `gen:api-surface-declarations` and `gen:docs`): `packages/spec/api-surface-declarations/identity.txt` and `content/docs/references/identity/organization.mdx`, three declaration moves each, nothing else. **Client** (`packages/client/`) - The three 「not relayed」 notes are **gone**, and the wires are relayed rather than transcribed: `OrganizationWire` is the spec's `Organization`, `OrganizationMemberWire` is `Member`, and `OrganizationInvitationWire` is `Invitation` with `status` narrowed per route plus the three members the platform adds (`teamId` and the two ADR-0105 D8 placement fields). The schemas are plain, non-strict objects, so those three extra keys are stripped on parse rather than refused — which is what makes the relay claim honest about the wire being a superset. - `return-type-precision.test.ts:1050` flipped from the stored-text union to the decoded object. Two `@ts-expect-error` directives there had to go with it, because the shapes they suppressed are now legal — each replaced by a positive pin rather than deleted: - reading `updatedAt` off the delete answer is now the spec's optional ISO string (with the measured note that optional is the ACCEPT set, and the value is absent on every route of this family); - reading into `metadata` on a read route is now legal, so the direction-2 suppression moved to `JSON.parse` of it — the thing that is now refused. --- ## Leg 3 — ⭐ the negative control "The client now relays the spec schemas" and "the client stopped validating" look identical from a green positive test, so `packages/client/src/identity-wire-relay.test.ts` runs the real `safeParse` over the measured bodies and pairs every accepted one with a refused one. Each refusal asserts the **issue path**, not merely that it failed: | body | verdict | why it is the control | |---|---|---| | the served read-route body, `updatedAt` absent, `metadata` decoded | **accepted** | the relay itself | | same, `metadata` key absent | **accepted** | an unset column | | `slug` removed | **refused**, path `slug` | a genuinely required field is still required | | ⭐ `metadata` as the stored JSON text | **refused**, path `metadata` | the exact dimension the producer fix moves — this one distinguishes "the producer decodes" from "the schema stopped caring" | | `metadata: null` | **refused**, path `metadata` | the producer omits; `null` is not quietly admitted | | `createdAt: 'yesterday'` | **refused**, path `createdAt` | the datetime check is live | | ⭐ `updatedAt: 'whenever'`, on all three schemas | **refused**, path `updatedAt` | `.optional()` widened by ABSENCE only; a present value is still held to the datetime check | | `userId` removed / `inviterId` removed / `status: 'withdrawn'` | **refused**, each path named | the other two schemas, same discipline | The same discipline lands in `packages/spec/src/identity/organization.test.ts`, whose objectstack-ai#18509 scope-fence pin asked in writing that 「a later fix for either one has to come here and say so」. It is taken down and answered: the served body now parses whole, and the `null` the schema still refuses is a shape nothing sends any more. --- ## Verification Everything below is a foreground run in this worktree at the head of this branch; heavy runs went through `scripts/pm/os-verify-lock.sh` and the verdict quoted is the one it printed. - **Red before the change, on the producer.** An ablation of the decode assignment — written to disk by `scripts/ablation-replace.mjs`, which proved the mutation landed (anchor 1 to 0, blob `6346e2ba97a5` to `25449f6b3393`) — turned exactly the three decode pins red and left the other eight green, then restored and proved the restore (blob equals HEAD, `git diff HEAD` empty). - **Red before the change, on the client.** Before the flip, the client's test project reported exactly three errors of mine: `return-type-precision.test.ts(1050,83)` TS2344 and two TS2578 unused-suppression errors at `:1068` and `:1076`. The other 54 errors in that run were TS2307 "cannot find module" from an unbuilt workspace and are gone after a build. - **Red before the change, on the spec.** The full spec suite failed on `organization.test.ts`'s scope-fence pin and on nothing else. - Green after: `@objectstack/plugin-auth` 113 files / 2376 tests; `@objectstack/spec` 493 files / 14521 tests (1 skipped, an environment-conditional skip in the suite's project split — there is no `describe.skip` or `skipIf` in the spec sources); `@objectstack/client` 48 files / 566 tests. Typecheck green on all three, including each one's test layer. - The gate families this diff derives were run and accounted: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD-FILE` reports **116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN**, with an exit code recorded per family. Two needed a second pass and are green now: `check:skill-examples` refused twice on a `dist` older than `src` (the good refusal — it declines to reach a verdict it cannot read) and passed after building the package directly; `check:engine-double-contract` asked for the new fake's `update` to route through `assertEngineUpdateDispatch` and for its ledger row, both of which it now has. - Repo-wide `pnpm lint` (`eslint . --no-inline-config`): exit 0. - `packages/spec` artefacts: `check:generated` reports all 16 up to date, on a tree rebuilt after the last source edit. - Control bytes: `check:nul-bytes` clean over 8954 files, plus a direct scan of this diff's own files. ## Acceptance notes - **Hold objectstack-ai#7881 intersection, answered.** This PR's producer fix does touch `packages/plugins/plugin-auth/src/objectql-adapter.ts`, the declared trigger file of on-hold card objectstack-ai#7881 (`rethrowAsBetterAuthError` maps engine errors code-by-code). That hold's restart condition is an **incident count**, not a file touch, so the trigger is **unaffected** and nothing here advances or satisfies it. ⛔ Its generalisation was deliberately not attempted, and no bodyless-500 path was measured on the four identity read routes fixed here. - **Not declared breaking, and the reason is the repo's own criterion** rather than the level being convenient. AGENTS.md binds the breaking class to removing or renaming something an author can write, and to the `(narrowing)` arm of the clause-② pair; neither holds. The `metadata` half is a producer brought into line with a contract this package has published all along, and the client's own comment called the served text 「not relayed」 rather than a shape anyone was promised. The changeset therefore carries no ADR-0087 disposition — `check-adr-0087-registration` agrees, reporting 「this PR adds no declared-breaking changeset」 — while still shipping the one-line consumer note and the compiler as its delivery channel. ⭐ Called out because it is the one judgement in this diff the contract review should confirm rather than inherit. - **`AUTH_ORGANIZATION_SCHEMA` carries a dead field mapping.** `packages/plugins/plugin-auth/src/auth-schema-config.ts:185` maps `updatedAt` to `updated_at` for the organization model, and better-auth's organization model declares no `updatedAt` field at all — so the mapping sets a `fieldName` for a field the transform never iterates. It is inert rather than wrong, and out of this card's scope; reported as a finding. - **Left deliberately untouched:** `packages/platform-objects/**` (the two missing `updated_at` columns are reported above, not added), `content/docs/releases/**`, `packages/spec/src/identity/identity.zod.ts`, and PR objectstack-ai#18718's nullish arm. - `needs:contract-review` is the seat's label — ⛔ this PR neither attaches nor clears it, and the report states what the PR carries plus the `check-clause2-carriers.mjs --pair` exit code. - The PR opens as a **draft** and stays draft. --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18509
Clause-②: yes (widening)
Verdict: it reproduces. Both of them.
#18509 asked for a measurement, not a widening, and warned that "does not reproduce" would be the good outcome. It is not the outcome. Measured through a real
AuthManager(better-auth 1.7.3) over a realObjectQLon a realSqliteWasmDriver, with the platform's ownsys_user/sys_organizationdefinitions, both keys are served present-and-null:POST /auth/sign-up/emailuser.imagenullGET /auth/get-sessionuser.imagenullPOST /auth/organization/createlogonullGET /auth/organization/list[0].logonullGET /auth/organization/get-full-organizationlogonullGET /auth/organization/get-full-organizationmembers[].user.imagenullThe mechanism is the #17235 one, confirmed at the DDL layer rather than assumed. Both columns are
Field.url({ required: false })in the platform's own object definitions and reach SQLite as nullable columns —PRAGMA table_inforeportssys_user.imageasvarchar(255) notnull=0andsys_organization.logoasvarchar(255) notnull=0. better-auth SELECTs them and serialises the null.So the remedy is the ruled one, per the card's item 2:
.nullish()— not.nullable(), which would retire the legal "key absent" shape.The two controls
LIT — the probe fires.
SessionUserSchema.image, the declaration #17235 fixed, is a field known to be served present-and-null. It shows up under this probe:*** PRESENT-AND-NULL ***on both the sign-up and get-session bodies, andSessionUserSchema.safeParsepasses on them (because #18501 already widened it). A probe that could not see that value could not be trusted to report its absence elsewhere.⭐ The lit control earned its keep — it caught a dead first instrument. The first version of this probe used the in-memory engine shape the plugin-auth suites use (a
Mapof plain objects). Under itimageread ABSENT, not present-and-null, andUserSchema.safeParsepassed — 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 asnull. 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/srcreturns 8 declarations, the same 8 the card reported. This PR moves exactly 2 of them. The other 6 —ai/agent.zod.tsavatar,ui/app.zod.tslogo,api/auth.zod.tsSessionUser.image(already.nullish()) andRegisterRequest.image(a REQUEST surface, client-authored, not better-auth-served),kernel/plugin-registry.zod.tslogo,kernel/plugin-security-advanced.zod.tsimage— appear nowhere in the conclusion and are untouched.⭐ How does
.url()coexist withnull? — the question this card could not answerBoth keys carry
.url();SessionUserSchema.imagedid not. So this is not a copy of #17235 and the answer had to be measured. It was, on all three candidate forms:.url().optional()(today).url().nullish()(ruled remedy).url().nullable()(the shape #17235 refused)invalid_typenullinvalid_type""invalid_formatinvalid_formatinvalid_format"https://x/a.png""not-a-url"invalid_formatinvalid_formatinvalid_format42invalid_typeinvalid_typeinvalid_typeThe answer:
.url()andnulldo not compete, because they never meet..nullish()wraps the wholez.string().url(), sonullandundefinedare separate branches that the URL check never evaluates, while a present string is still required to be a well-formed URL. The middle column moves exactly one row from the left column, and it is the ruled one. The right column moves two rows in opposite directions — that second, upward move is the narrowing #17235 refused, and the table is why the same refusal holds here.⭐ The card's carried boundary note does not come live. #18509 recorded that
SessionUser.imageaccepts""and warned it "becomes live if step 2 adds.url()reasoning to this family". Measured: it does not.SessionUser.imagehas that hole because it is barez.string(); these two keys carry.url(), so""is refused before and after — theinvalid_formatrow 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
imageis the ONLY divergenceUserSchemahad against the served user, so that body now parses clean.logowas one of three onOrganizationSchema— 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:metadatais served present-and-null./auth/organization/listand/auth/organization/get-full-organizationserve"metadata": nullagainstz.record(z.string(), z.unknown()).optional(). Same present-and-null shape, different key, and az.recordrather than az.string().url()— so it deserves its own reasoning, not this one by extension./auth/organization/createomitsupdatedAt, which the schema declares required. That is the opposite shape — a missing key, not a null one — and the remedy is a different question.Folding either in would be exactly the step #18509 exists to prevent. They are pinned as current behaviour in
organization.test.tsso 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.tsandorganization.test.tsassert 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 toimage/logoand 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, plusgit hash-objectdiffering from theHEADblob on both files), and the restore was proved byte-exact the same way (git diff HEADempty; both disk hashes equal to theirHEADblobs). These tests resolve./identity.zodrelatively, i.e. tosrc, not through the packageexportstodist, so no rebuild is interposed and the dist-preflight step does not apply.The 48 that stayed green are the rows that must not move: absent, a valid URL,
"","not-a-url", a number.Verification
pnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec typechecktsc --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:generatedgen:docs)pnpm --filter '@objectstack/spec^...' buildpackages/spechas no workspace dependencies, so there is no upstream closure. Reported as empty, not as a pass.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 0Lint was narrowed, and the narrowing is measured rather than assumed (at
6f01ef3491): the config-derived population is 6817 files (read by walkinggit ls-filesthrough ESLint's ownisPathIgnored, 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 — everyparserOptionsineslint.config.mjscarries onlyecmaVersion/sourceType, with noprojectorprojectService, 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:generatedproved exactly one artifact stale and it was regenerated with--fix(never the whole set). The diff is two table cells, both intended:imageandlogorender asstring | nullincontent/docs/references/identity/.authorable-surface.base.jsondid not move andcheck:authorable-surfaceis green.Surface note
The dispatch named the two
.zod.tsfiles, plus.changeset/*.mdand gate-required derivatives, and markedplugin-auth/plugin-hono-server/clientread-only. Those three were not written to — the probe runs from the scratchpad against builtdist, so the measurement sites were only read. Two files were touched beyond the literal list: the siblingidentity.test.tsandorganization.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 by2c86fe3ea7and4b5702ab77, both landed). The regeneratedcontent/docs/references/identity/*.mdxare the gate-required derivative.Generated by Claude Code