feat(spec): declare {0000} as the contract default for format-less autonumber fields (#6555) - #7265
Conversation
…autonumber (#6555) `FieldSchema.autonumberFormat` is optional, and the two sides that mint record numbers each answered "no format declared" on their own — differently. driver-sql substituted `'{0000}'` and issued `0001`; the ObjectQL engine's in-memory fallback path parsed the empty string and fell through `renderAutonumber`'s no-slot branch to a bare `1`. One metadata document, two number shapes. The counter VALUE always agreed (#6468 pinned it) — the fork was rendering width alone. Per the maintainer's 2026-08-08 ruling (route 3, default fixed at `{0000}`), this lands the CONTRACT half only: - `DEFAULT_AUTONUMBER_FORMAT` — new export beside `renderAutonumber`; the one place the value is written down. - `resolveAutonumberFormat(field)` — new export; canonical `autonumberFormat`, then the `format` shorthand (#1603), then the declared default. A key holding anything but a non-empty string counts as undeclared — driver-sql's truthiness rule, chosen so already-stored SQL numbers keep their shape. - `FieldSchema.autonumberFormat` declares the default as a JSON-Schema annotation, registered in `DEFAULT_CHANGES_BY_MAJOR` (#4666 ratchet). Deliberately an annotation, not a Zod `.default()`: the key is flat on FieldSchema and shared by every field type, so a parse-time default was measured to materialize `autonumberFormat: '{0000}'` on `text`/`number`/ `lookup` fields and turned 28 cases in `build-schemas-check-mode.test.ts` red. Parse output is unchanged for every type. Both generators keep their hand-written fallbacks for now; removing them follows in two separate cards, so nothing about today's rendering moves. Part of #6555.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
…onumber-default-format # Conflicts: # packages/spec/src/data/autonumber-format.test.ts
|
PM → executor session: rebase needed ( Please: merge current Generated by Claude Code |
|
Merge-queue eviction (
The conflict was one hunk: the import list in Worth noting the two changes agree rather than collide: Generated artifacts were merge-driver deferred, then regenerated from the merged tree after a real Re-verified on the merged tree (heavy runs serialised on
Not re-queueing myself — leaving that to the PM's serial landing order, per the dispatch's no-auto-merge instruction. Generated by Claude Code |
Archive criteria (PR merged + report reviewed), the batch sweep, and the one hard boundary: never archive before the PR merges — a live completed session is the dirty-rescue executor (#7265/#7325). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
…not a hardcoded fallback (objectstack-ai#7263) (objectstack-ai#7334) Execution half 3/3 of the route-3 ruling on objectstack-ai#6555. `{0000}` is a declared contract default now (`DEFAULT_AUTONUMBER_FORMAT` / `resolveAutonumberFormat`, landed in `@objectstack/spec/data` by objectstack-ai#7265), so this driver stops keeping its own copy of it. `initObjects` and the external-object registration path each spelled the same four lines by hand — canonical `autonumberFormat`, then the `format` shorthand, then `|| '{0000}'`. Both are now `resolveAutonumberFormat(field)`: one symbol added to an import this file already had, no new dependency. Behaviour-neutral by construction and by measurement. The resolver's precedence and its non-empty-string test were taken from these very lines, so a differential check over 484 field documents (both spellings x 22 value shapes) returns the identical string in every case — `format: ''`, `autonumberFormat: ''` and the non-string values included. All eight driver-sql autonumber suites (64 tests) and the cross-side parity integration test stay green unedited; the one test-file change is prose, re-pointing a comment at the declared default while keeping true its note that the engine still emits a bare counter until objectstack-ai#7262. Claude-Session: https://claude.ai/code/session_01TcsV4ES5JJjCZ9BLrfhTy8 Co-authored-by: Claude <noreply@anthropic.com>
…fault, not the empty string (objectstack-ai#7262) (objectstack-ai#7433) Execution half 2/3 -- the last one -- of the maintainer's route-3 ruling on objectstack-ai#6555. `applyAutonumbers` resolved the format by hand (`autonumberFormat ?? format`, then `typeof fmt === 'string' ? fmt : ''`), so a format-LESS field parsed the EMPTY string and rendered `renderAutonumber`'s no-slot branch as a bare `1`, `2`, .... driver-sql substituted its own `'{0000}'` and issued `0001`. One metadata document, two number shapes, decided by which driver served it. It is now `resolveAutonumberFormat(def)` -- the resolver landed by objectstack-ai#7265 and already read by driver-sql since objectstack-ai#7263. Unlike the driver half this MOVES behaviour, two ways, both in the changeset: - a format-less field on the engine fallback path issues `0001` where it issued `1` (engine-fallback deployments only; stored driver-sql data is undisturbed, and objectstack-ai#6468's counter continuity is unaffected -- `{0000}` renders prefix '' / suffix '', so the seeding scan stays on its unanchored legacy reading); - `??` -> truthiness means `autonumberFormat: ''` / `format: ''` resolve to the default instead of rendering bare, and an empty canonical key no longer masks a declared `format` shorthand. Tests. New `engine-autonumber-default-format.test.ts` covers the empty-string inputs no suite on either side declared before (the gap the drivers seat measured on this card), plus the format-less renders and the controls that must NOT move. `autonumber-seed-cross-side-parity.integration.test.ts` gains the bug report's own reproduction -- no format, stored 1/2/10, both sides `0011` -- which is what lets that file assert a shared RENDERING and not only a shared counter. Five pre-existing cases asserting bare `'1'`/`'2'`/`'8'`/`'11'` on format-less fields were re-pinned to the padded form, each with a objectstack-ai#6555 comment. Closes objectstack-ai#7262. Part of objectstack-ai#6555. Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ary (objectstack-ai#7287) (objectstack-ai#7405) Maintainer ruling on objectstack-ai#7287 (2026-08-10, treatment (2) 「宣告边界」): on an UNANCHORED autonumber format (neither prefix nor suffix), a stored value carrying non-digit content is OUT OF CONTRACT for counter readback, and `readAutonumberCounter`'s `undefined` for that slot is the contract rather than a gap. Zero behavior movement. The two consumers keep their divergent legacy readings — the engine's `readStoredAutonumberCounter` takes the last digit run, driver-sql's `scanMaxNumericTail` concatenates every digit — each now labelled implementation detail outside the declared boundary, pointing at spec's TSDoc. Hoisting either reading was rejected: it would move live behavior on the other side over record numbers already issued (the reason objectstack-ai#7247 refused the hoist as a rider). The boundary is drawn by CONTENT and deliberately wider than the observed divergence: pure-digit values (what `renderAutonumber` emits for an unanchored format) are in contract and read the same on both sides, while every mixed-content value is outside — including ones the two readings happen to agree on, since divergence needs two digit runs. Narrowing it to "values the sides actually disagree on" would make membership undecidable from the value alone. Reachability: objectstack-ai#6555's Route-3 ruling (PR objectstack-ai#7265) made `{0000}` the declared default for format-less autonumber fields, so the default authoring shape now lands in this unanchored slot. New pins in `autonumber-unanchored-boundary.test.ts` assert the `undefined` for eight mixed-content shapes, show the two readings diverging on the inputs the boundary excludes and agreeing on the ones it admits, and pin that `{0000}` really renders an unanchored pair. No existing test is edited. Diff is comments and one new test file only — no executable line is added, removed or moved outside tests. Closes objectstack-ai#7287 Claude-Session: https://claude.ai/code/session_0184Hrx9PcaQ2KMMt88DRZ2c Co-authored-by: Claude <noreply@anthropic.com>
…-21 verify-before-toggle, PR subscription as hard step (objectstack-ai#7755) (objectstack-ai#7794) Three validated deltas from the 2026-08-11 spec-lane shift, per the maintainer's same-day rulings, plus the bbe056b rider folded in: - mode:cloud backend: record the cross-session-messaging roster limitation (CCR cloud sessions unreachable via ListAgents/SendMessage) and the ruling that the event-driven architecture is the standing solution — no roster re-tests, no local-machine migration. - Operational note 21, amended in place: after an empty-field enable, verify queue membership FIRST (queue branch, ~20-30s allowance); never issue a disable between an enable and its verification — an out-of-order 'added to merge queue' webhook means that disable can cancel a real enqueue (objectstack-ai#7655 negative / objectstack-ai#7657 positive, same day). - 入队与落地 B / mode:cloud delivery contract: cloud-card PR subscription elevated from accompanying-discipline bullet to numbered hard step (lesson 4), with the objectstack-ai#7672 vs objectstack-ai#7655/objectstack-ai#7657 A/B measurement; the bullet stays as a pointer. - Rider bbe056b (cloud-card archival) folded into the B-section archival bullet: merged+report-reviewed criteria, review_ready batch sweep, the never-archive-before-merge boundary (objectstack-ai#7265/objectstack-ai#7325), unarchive caveat. Ratchet headroom was 0 (ceiling 2568): all additions offset in-file by moving residual narrative verbatim to references/incidents.md (append-only, five new sections with provenance) and reflowing touched paragraphs. SKILL.md stays exactly 2568 lines; MAX_LINES untouched. Fixes objectstack-ai#7755 Co-authored-by: Claude <noreply@anthropic.com>
Part of #6555. Not "Closes" — the card stays open until the two de-fallback halves land (#7262, #7263).
The maintainer ruling this executes
Comment 5225535766, 2026-08-08, verbatim:
Re-affirmed 2026-08-10 in the PM's claim comment: 「统一为 {0000}(推荐)」.
Scope: the spec half only, per the ruling's contract-first ordering. No engine or driver file is touched by this PR (
git diff --stat -- packages/objectql packages/driversis empty).Premise re-measurement on fresh
origin/main@3566e55sql-driver.ts:5353and:5435— drifted again from triage's:4330/:4412(and from the body's:4322/:4404). Sameconst fmt = rawFmt || '{0000}';, unchanged.engine.ts:2553-2554, not:2115(that line is now audit-hook session code).const fmt = …autonumberFormat ?? …format;thenparseAutonumberFormat(typeof fmt === 'string' ? fmt : '').width === nullbare-counter branchautonumber-format.ts:210-213, comment still reads "legacy behaviour".DEFAULT_AUTONUMBER_FORMATanywhere inpackages/;data/Field:autonumberFormatabsent fromauthorable-defaults/data.json.Nothing had landed ahead of this card.
Declaration mechanics — the decision, and the blast radius that drove it
The ruling says the default becomes "a declared contract default in FieldSchema". Two mechanics could carry that. The dispatch flagged the Zod
.default()option as needing its blast radius measured first, so it was measured, not reasoned about.Option A —
autonumberFormat: z.string().optional().default('{0000}'). Applied to a scratch tree and run:autonumberFormatis a flat key onFieldSchema, shared by all 49 field types — it is not scoped totype: 'autonumber'. Measured directly:FieldSchema.parse({ type: 'text', label: 'Name' })returns…,"autonumberFormat":"{0000}","externalId":false}— a counter format materialised onto a text field. Parse output is observed by metadata loaders, the metadata API, and both drivers'initObjects, so that shift travels well outside autonumber.packages/spec/scripts/build-schemas-check-mode.test.ts(28 failed | 9369 passed). Control run on the same tree with the patch reverted:60 passedin that file — so the reds are caused by the patch, not pre-existing.z.output<typeof FieldSchema>['autonumberFormat']narrows fromstring | undefinedtostringfor every consumer typed on the parsed shape.⇒ Rejected. It measurably breaks existing parse-output expectations, which is exactly the condition the dispatch named.
Option B — JSON-Schema
defaultannotation (chosen)..meta({ description, default: DEFAULT_AUTONUMBER_FORMAT }), house style (approval.zod.ts,schemaless-node-config.zod.ts— "a single.meta()carries both description and annotation"). Parse output is byte-identical for every field type; the default is declared to schema consumers and AI metadata authors.This is not a documentation-only escape hatch — the repo's own machinery treats it as the contract default.
gen:schemarefused the build until the change was declared:so the change is registered in
DEFAULT_CHANGES_BY_MAJOR(scripts/lib/default-changes.ts, the #4666 ratchet) with a consumer-facingreason, and lands in the trackedauthorable-defaults/data.jsonasdata/Field:autonumberFormat = "{0000}". Both endpoints are re-verified on every build, so the declaration dies if it stops being true. Neither option was blocked, so no STOP was needed.Changes
packages/spec/src/data/autonumber-format.tsDEFAULT_AUTONUMBER_FORMAT = '{0000}'besiderenderAutonumber; newresolveAutonumberFormat(field)+AutonumberFormatSource; TSDoc note on thewidth === nullbranch.packages/spec/src/data/field.zod.tsautonumberFormatdeclares the default via.meta({ description, default }); TSDoc section on omitting the key and on why this is an annotation.packages/spec/scripts/lib/default-changes.tsdata/Field:autonumberFormat: (none) → "{0000}"under major 17, with the consumer-facing reason.packages/spec/src/data/autonumber-format.test.ts0001rendering, the surviving bare branch.packages/spec/src/data/field-autonumber-default-format.test.tsdefault, the key staying optional, parse output unchanged on 5 field types, and a parsed format-less field resolving to{0000}..changeset/autonumber-default-format-contract.mdminoron@objectstack/spec, carrying the ruling's compatibility note verbatim.authorable-defaults/data.json,api-surface/data.json,export-origins/data.json,content/docs/references/data/field.mdxPrecedence, and one decision inside it.
resolveAutonumberFormattreats anything that is not a non-empty string as undeclared. That is driver-sql's long-standing truthiness rule, not the engine's??— the two disagreed onformat: ''as well as on the missing key, and resolving''to the default is the direction that leaves already-stored SQL numbers untouched, matching the ruling's compatibility note. Consequence: the driver-side de-fallback (#7263) becomes behaviour-neutral by construction, and the engine side (#7262) carries the entire behaviour move — which is what the ruling describes.Reverse verification — direction predicted before running
Ran both sides: implementation applied, then
packages/spec/src/data/{autonumber-format,field.zod}.tsreverted toHEADwith the tests kept.autonumber-format.test.ts— 6 new#6555casesfield-autonumber-default-format.test.ts— JSON-Schemadefault+ resolvefield-autonumber-default-format.test.ts— key optional, parse output unmovedautonumber-format.test.ts— 20 pre-existing casesfield-autonumber-readonly.test.ts(#5628)Totals: pre-change
8 failed | 26 passed, post-change34 passed.One prediction was imprecise in a way worth naming: I expected the pre-change run to fail at import (missing exports taking the whole file down). It did not — the missing exports arrived as
undefinedand only the 8 asserting cases failed, leaving the 26 pre-existing pins green on both sides. A cleaner signal than predicted, but not the mechanism predicted.Gates
Heavy runs serialised on
flock /tmp/os-heavy-verify.lock; no contention — every acquisition was immediate (0s wait).pnpm --filter @objectstack/spec buildgen:schemacheck:generated--fix→ re-verified clean)api-surface/+export-origins/+AutonumberFormatSource,+DEFAULT_AUTONUMBER_FORMAT,+resolveAutonumberFormat, 0 removedpnpm --filter @objectstack/spec typecheckcheck:test-typecheck, debt unchanged)pnpm --filter @objectstack/spec testpackages/drivers/driver-sql— all 8sql-driver-autonumber-*suitespackages/objectql— all 7engine-autonumber-*suitespackages/runtime/src/autonumber-seed-cross-side-parity.integration.test.tsThe last three are the proof the dispatch asked for: the spec half alone breaks neither side.
Follow-up cards filed
Both unassigned, no routing labels (triage routes them), each
Blocked-bythis PR, each quoting the ruling verbatim. Dedup-searched first — nothing existing covers the render fallback.{0000}default instead of parsing the empty string (#6555 half 2/3) #7262 — engine:applyAutonumbersreads the declared default instead of parsing''. This is the half that moves behaviour (bare1→0001on engine-fallback deployments), and it must re-pin any engine test currently asserting a bare counter.|| '{0000}'sites for the declared default (#6555 half 3/3) #7263 — driver-sql: drop both|| '{0000}'sites forresolveAutonumberFormat. Behaviour-neutral by construction; all 8 driver autonumber suites must stay green unedited.For the PM — special inspection
:4330/:4412→:5353/:5435;:2115→:2553). The follow-up cards carry re-measured line numbers plus the code text, so the next dev can re-anchor by content.{0000}default instead of parsing the empty string (#6555 half 2/3) #7262/De-fallback driver-sql's autonumber format — drop the two hardcoded|| '{0000}'sites for the declared default (#6555 half 3/3) #7263 the contract says{0000}while the engine still emits bare1. That is the ruling's own contract-first ordering; it closes when both cards land, and 无 format 的 autonumber 字段两侧渲染不同:driver-sql 兜底成{0000}发出0001,引擎兜底路径发出裸1—— 同一份元数据换驱动号形不同 #6555 stays open until then.format: ''semantics move on the engine side too. Called out in De-fallback the engine's autonumber render path — read the declared{0000}default instead of parsing the empty string (#6555 half 2/3) #7262 — a second, smaller behaviour change beyond the ruling's compatibility note, and it belongs in that card's changeset.autonumberFormathelp text (object.form.ts:187) could state "leave blank →0001", but that regenerates*.metadata-forms.generated.tsacross 4 locales inplatform-objects— a cross-package generated chain outside this card's scope. Worth a small follow-up if the PM wants it.content/docs/releases/anddocs/adr/**: untouched.Generated by Claude Code