Repository navigation
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>
… decision in words instead of a tracker number (stage 15) (objectstack-ai#21810) Part of objectstack-ai#20749 Clause-②: no Stage 15 of this card: the next area of class (e), the test strings shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513. This stage takes the first name-ordered file group directly under `packages/spec/src/data/`: the 20 test files from `aggregate-field-type-compatibility.test.ts` to `date-range-presets.test.ts`. They carried 94 messages and 102 tracker ids, citing 60 records. Every one of those ids now either states what its record decided, in words (form D), or is dropped where the title already says it. Text only: no assertion, identifier, test count or code comment changes. ## Census at the base (`0a3480311a`) Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`), `census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs` (md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5 `dda605c54745b4a60cc14c9a686e4eff`). They are byte-identical to the copies stages 10 to 14 used. A literal counts as a test title when its folded message is argument 0 of a `describe` / `it` / `test` call, `.each` / `.skip` / `.only` chains included. Everything else is an "other" string. Both instruments read **1325 messages / 1406 ids in 282 files**, the seat's reading at `0a3480311a` (stage 14's head). | directory | files | messages / ids | titles | other | |:--|--:|--:|--:|--:| | `data/` (this PR: the first 20 files) | 95 | 468 / 501 | 445 / 475 | 23 / 26 | | `ui/` | 81 | 393 / 416 | 375 / 398 | 18 / 18 | | `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 | | `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 | | (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 | | `ai/` | 1 | 2 / 2 | 0 | 2 / 2 | | `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 | | **total** | **282** | **1325 / 1406** | **1246 / 1323** | **79 / 83** | The group reads **94 messages / 102 ids in 20 files**, the seat's figures, file for file: | file (under `data/`) | messages / ids | titles | other | |:--|--:|--:|--:| | `aggregate-field-type-compatibility.test.ts` | 4 / 4 | 4 / 4 | 0 | | `analytics-date-range-closed-vocabulary.test.ts` | 1 / 1 | 1 / 1 | 0 | | `analytics-date-range-two-bound-window.test.ts` | 3 / 3 | 3 / 3 | 0 | | `analytics-query-window-integer.test.ts` | 2 / 2 | 2 / 2 | 0 | | `analytics-strictness-batchd.test.ts` | 9 / 9 | 9 / 9 | 0 | | `analytics.test.ts` | 6 / 6 | 6 / 6 | 0 | | `api-derivation.test.ts` | 6 / 6 | 6 / 6 | 0 | | `api-methods-batch-conformance.test.ts` | 3 / 4 | 1 / 1 | 2 / 3 | | `authoring-key-lint.test.ts` | 2 / 2 | 2 / 2 | 0 | | `autonumber-format.test.ts` | 3 / 3 | 3 / 3 | 0 | | `autonumber-unanchored-boundary.test.ts` | 3 / 4 | 3 / 4 | 0 | | `bulk-write-hook-conformance.test.ts` | 2 / 2 | 2 / 2 | 0 | | `calendar-day.test.ts` | 2 / 2 | 2 / 2 | 0 | | `context-tokens.test.ts` | 1 / 1 | 1 / 1 | 0 | | `currency-mode-family-closure.pin.test.ts` | 2 / 2 | 2 / 2 | 0 | | `currency-precision-iso4217.test.ts` | 7 / 7 | 7 / 7 | 0 | | `data-engine.test.ts` | 20 / 25 | 20 / 25 | 0 | | `datasource-credential-redaction.test.ts` | 6 / 6 | 6 / 6 | 0 | | `datasource.test.ts` | 10 / 10 | 10 / 10 | 0 | | `date-range-presets.test.ts` | 2 / 3 | 2 / 3 | 0 | | **20 files** | **94 / 102** | **92 / 99** | **2 / 3** | - **Controls.** Lit, a title: `data/document.test.ts` reads 2 / 2 at the head. Lit, "other" strings: the two in `data/external-lookup-retirement.test.ts` (`:89`, `:130`) still read at the head. Dark: the file comment at `data/analytics-strictness-batchd.test.ts:4` (it names the strictness batch by its number) reads 0. Planted in a scratch copy of the head `data/calendar-day.test.ts`: an id put into a title reads 1 / 1, and an id put into a comment reads 0. - **A wider pattern** (any `#` plus digits) reads the same totals in 19 of the 20 files. In `aggregate-field-type-compatibility.test.ts` it reads one more, a decision-batch number at `:150` that sits beside a cited record in the same literal. The gate's pattern needs three to five digits, so it is not counted there. - **At the head:** 1231 messages / 1304 ids in 262 files. The 20 files read 0 / 0 on both patterns, and no other file moved. ## How the area was chosen `data/` has no subdirectory to split by (449 ids directly under it, `data/driver/` 52), so its stages take name-ordered file groups near the ~100-id bound, as stage 14's report proposed. This census reads the first group at exactly 102, the claim's figure, so the rule needed no re-cut. **Named for the next stages** (re-cut from the head census, 1231 / 1304; `data/` 374 / 399 left): - `data/` in four more stages, name-ordered: 1. `default-value-shape.test.ts` to `filter-comparand-shape.test.ts`: 20 files, 94 messages / 100 ids; 2. `filter-comparand-type.test.ts` to `filter-view-operator-parity.test.ts`: 20 files, 95 / 99; 3. `filter.test.ts` to `object.test.ts`: 17 files, 103 / 114. `object.test.ts` alone carries 42, so no cut lands nearer the bound; 4. `query-transport.test.ts` to `validation.test.ts` (11 files, 34 / 34) with `data/driver/` (7 files, 48 / 52): 86 ids. - `ui/` 416, about four stages. `api/` 201, two. `system/` 165, two. The files directly in `src/`, 120, one. - The three docblock needles (`ai/build-progress.test.ts:236`, `:237`, `contracts/approval-service.test.ts:274`), one stage with their docblocks. ## What each id became 24 literals (28 ids) now state a decision in words. 2 literals (2 ids) get their subject back in words where the number stood in for it. 69 literals (72 ids) drop a number the title already explains. (95 literals in 94 messages: the `sys_organization` reason string is one message over two lines.) Every cited record was read with its comments through REST: 55 answer 200. objectstack-ai#6345, objectstack-ai#8876, objectstack-ai#9040, objectstack-ai#10194 and objectstack-ai#17014 answer 404, and their decisions were read from what landed: `e2798fa` (one driver vocabulary for start and migrate), `d634e66` (the username half of the URL userinfo grammar), `2420641` (a credential in the mongo `options` passthrough is refused), `2306a76` (`theme` / `analytics_cube` validated at the `/meta` write door) and `80aef80` (a one-day window for the one-day presets), each with its CHANGELOG entry. No cross-repo record is cited in this group. | record(s) | literal (under `data/`) | now reads | |:--|:--|:--| | objectstack-ai#11152 | `aggregate-field-type-compatibility.test.ts:150` | "accepts `sum` / `avg` / `min` / `max` over booleans — numbers on every backend, a ruling that outranks the refused-by-default rule". The maintainer ruled that booleans aggregate as numbers on every backend; decision batch 80 held that ruling over batch 59's blanket refusal of unnamed pairs. That batch number went with the id. | | objectstack-ai#4001 (3) | `analytics-strictness-batchd.test.ts:83`, `:248`, `:306` | "batch D, unknown keys refused — …" before "the doors the cube family is reachable through", "alias claims are true of the surfaces they point at" and "deliberate non-closures (re-verdicts, not omissions)". The campaign's decision: an unknown key is refused, not stripped. | | objectstack-ai#3878 (2) | `analytics-strictness-batchd.test.ts:270`, `:297` | "matching the dispatcher's bespoke hint at the /analytics entry" and "the retired-envelope tombstones still fire". The body is the bare `AnalyticsQuery`; the `{ cube, query }` envelope was retired with tombstones, and the entry answers 400 with a hint at `where`. | | objectstack-ai#18612 | `analytics.test.ts:314` | "a persisted cube heals at the door — the retired join `sql` / `relationship` are stripped (ADR-0087 D2)". | | objectstack-ai#3391 | `api-derivation.test.ts:16` | "api-derivation — one table resolves the effective operations from six primitives". The server is the only adjudicator, through one derivation table. | | objectstack-ai#3543 | `api-derivation.test.ts:286` | "vocabulary split — authors write six primitives, the wire speaks operations". The authored enum shrank; the wire vocabulary stayed byte-stable. | | objectstack-ai#15873 | `api-methods-batch-conformance.test.ts:221` | A declared reason string: "(a ruling grants `update`; both are column-clamped per row by ADR-0092 D2)". Option (a), decision batch 64. | | objectstack-ai#3786 | `authoring-key-lint.test.ts:37` | "lintAuthoredRecordKeys — an unknown authoring key is reported, not swallowed", the decision its source docblock records. | | objectstack-ai#6555 | `autonumber-format.test.ts:23` | "DEFAULT_AUTONUMBER_FORMAT / resolveAutonumberFormat — one declared default both sides read". Route 3: `{0000}` became the contract default, and both fallbacks went away. | | objectstack-ai#5038 | `bulk-write-hook-conformance.test.ts:114` | "records the after half as DELIVERED — the engine fires it once per row". | | objectstack-ai#5574 | `bulk-write-hook-conformance.test.ts:119` | "records the before half as DELIVERED — the engine dispatches it per row too". | | objectstack-ai#20126 | `currency-mode-family-closure.pin.test.ts:348` | "currency-mode family — the enumerating closure pin: `defaultCurrency` holds only under `fixed`". | | objectstack-ai#19992 | `currency-precision-iso4217.test.ts:163` | "the removed `currencyConfig.precision` at rest: a stored row carrying the baked `precision: 2` is served canonical". | | objectstack-ai#7918 | `currency-precision-iso4217.test.ts:224` | "… where the ISO 4217 width check used to refuse it". That check was the record's option A, later reversed. | | objectstack-ai#3407, objectstack-ai#6437 | `data-engine.test.ts:1185` | "DroppedFieldsEventSchema.reason — why a write dropped submitted fields, widened past the readonly pair". | | objectstack-ai#6262, objectstack-ai#6433, objectstack-ai#6435 | `data-engine.test.ts:1198` | "primary_key is the value the engine reports when it strips a payload id it ruled is not an identifier", the schema's own wording of that strip on the bulk and the by-id paths. | | objectstack-ai#8300 | `datasource-credential-redaction.test.ts:70` | "(the drift guard on the one credential-key definition)". | | objectstack-ai#8876 | `datasource-credential-redaction.test.ts:232` | "— the username half of the same alignment". | | objectstack-ai#8337 | `datasource-credential-redaction.test.ts:243` | "redactUrlCredentialQueryParams — the read half: a credential query parameter is never served back". | | objectstack-ai#8153 | `datasource.test.ts:673` | "— unchanged by the managed-row credentialsRef allowance". The ruling allowed `external.credentialsRef`, and only it, on managed rows. | | objectstack-ai#4614, objectstack-ai#8793 | `date-range-presets.test.ts:14` | "date-range preset vocabulary — one source of truth, read by both the UI and the data side". | **Subject restored (2 ids):** objectstack-ai#20126 at `currency-mode-family-closure.pin.test.ts:403` ("currency-mode closure controls — each rule can fail, and passes what it must") and objectstack-ai#7918 at `currency-precision-iso4217.test.ts:311` ("carries the measured anchors — 0 digits for JPY, 2 for USD, 3 for KWD"). That literal moved from double to single quotes, since it no longer holds an apostrophe. **Dropped only (72 ids):** objectstack-ai#1603, objectstack-ai#2377, objectstack-ai#3026, objectstack-ai#3391, objectstack-ai#3543, objectstack-ai#3545, objectstack-ai#3795 (9), objectstack-ai#4001 (2), objectstack-ai#4286, objectstack-ai#4346 (2), objectstack-ai#4538, objectstack-ai#4583, objectstack-ai#5586, objectstack-ai#6345, objectstack-ai#6555, objectstack-ai#6560, objectstack-ai#7178 (5), objectstack-ai#7265, objectstack-ai#7287 (2), objectstack-ai#7802 (2), objectstack-ai#8032, objectstack-ai#8057 (2), objectstack-ai#8153 (7), objectstack-ai#8336, objectstack-ai#8337, objectstack-ai#9040, objectstack-ai#10194, objectstack-ai#10414, objectstack-ai#13802, objectstack-ai#16041, objectstack-ai#16632, objectstack-ai#17014, objectstack-ai#17296, objectstack-ai#17598 (2), objectstack-ai#18278, objectstack-ai#19992 (3), objectstack-ai#20011, objectstack-ai#20300 (2), objectstack-ai#20550, objectstack-ai#20600, objectstack-ai#20808 (3), objectstack-ai#21365 (2). - Each of these titles already states the decision it pins: for example "empty array → deny-all (flipped semantics)" for objectstack-ai#3391, "accepts the BARE query string — the canonical ADR-0061 D1 spelling" for objectstack-ai#7178, or "`currencyConfig.precision` is removed: refused with the prescription, whatever its value" for objectstack-ai#19992. - **Small rewordings that carry no new claim:** `analytics-strictness-batchd.test.ts:307` reads "are CLOSED now" where it named the record; `autonumber-unanchored-boundary.test.ts:51` reads "(ruled: mixed content is out of contract)"; `datasource.test.ts:553` reads "(the happy path)". The circled part numbers after objectstack-ai#17598 went with the id. - **The two `api-methods-batch-conformance.test.ts` reason strings** (`sys_api_key`, `sys_organization`) end "rather than hitting /batch." now. The table is read only through `!== undefined`, so no assertion reads their text. ## Readers - **Test-name filters:** none. A tracked-tree search for `-t` and `--testNamePattern` finds only `packages/qa/dogfood/README.md:142` (`-t "owner-scoped"`), which is unrelated. - **Snapshots:** none. No `__snapshots__` directory exists under `data/`, and no `.snap` file is tracked under `packages/spec`. - **Projects:** two touched files are listed in `packages/spec/vitest.repo-tests.json`: `api-methods-batch-conformance.test.ts` and `currency-mode-family-closure.pin.test.ts`. Both were run in the `repo` project at the base and at the head, and the other 18 in `local`. - **By substring:** every old literal, plus a window around each id (289 needles), was searched across the tracked tree outside its own file. No gate, doc, filter, snapshot or `scripts/check-*.mjs` self-test reads one. The 14 hits are: - **sibling titles in other lanes:** `service-analytics` `aggregate-nontemporal-measure-refusal.test.ts:344` and `objectql` `engine-autonumber-default-format.test.ts:248`; - **this card's later `data/` stage:** `data/driver/postgres.test.ts:169`, the same "placeholders are not resolved here" title, already in the census; - **comments, CHANGELOG, an audit ledger and liveness evidence:** `lint` `validate-dataset-measure-aggregates.test.ts:179`, `service-analytics` `dataset-compiler.ts:227`, `objectql` `engine.ts:6206` and `:6272`, `analytics.zod.ts:1002`, `docs/audits/2026-07-unknown-key-strictness-ledger.md:728`, two `packages/spec/CHANGELOG.md` entries and the `liveness/field.json:218` evidence string, which quotes the `engine.ts` comment. None reads a test title. - **Same-text titles named in stage 14's ACCEPT** (`(objectstack-ai#15680)`, `(objectstack-ai#5955)`, `objectstack-ai#3896 close-out`): none falls in this group. ## Text-only proof Stage 10's scratch tool (`textonly10.cjs`, md5 `d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file on three legs: 1. **Skeleton:** the full AST, with string pieces masked. It must be identical. 2. **Comments:** every comment, byte-equal. 3. **Strings:** each changed string leaf must sit in a test-call title position or on a declared line, must carry a tracker id before, and must carry no `#` plus digits after. The declared lines are the three reason-string leaves in `api-methods-batch-conformance.test.ts`. - **Result:** 20 of 20 files SAME on all three legs, as predicted in writing before the run. - **Totals:** 95 changed literals, 92 titles and 3 declared. The diff's `+` and `-` lines are exactly the 95 planned lines, and every file keeps its line count. - **Controls (10 of 10 as predicted, on scratch copies, each anchor hit once):** identifier rename DIFF; numeric literal DIFF; comment edit COMMENT DIFF; a non-title string given an id VIOLATION; a rewritten title given a new id VIOLATION; a title that was id-free at base edited VIOLATION; one title reverted to base SAME; a declared string given a new id VIOLATION; an undeclared `expect` message changed VIOLATION; a title re-split into a `+` chain DIFF. **Test counts:** the 20 files were run at the base, in a separate base worktree, and at the head, with `--project local --project repo`. Both sides read 553 / 553 passed, with the same count and status sequence per file in 20 of 20. 291 full test names change, and each equals the base name with the planned replacements applied (0 mismatches). No full name repeats on either side. ## `main` merged in, once objectstack-ai#21800 (the console pin bump) landed while this branch was being verified, and it rewrites the comment block at `:61-77` of `api-methods-batch-conformance.test.ts`. This PR edits only string literals in that file, more than 100 lines below the block, so `origin/main` (`18c2ddc1ec`, which also carries objectstack-ai#21801) was merged in with a plain merge, no rebase, and no conflict. The PR's delta against `main` is still exactly the 20 files, +95 / -95. Every reading in this body was re-taken on the merged head `bf16ad1190`, against `18c2ddc1ec` as the base: the census (1325 / 1406 there, 1231 / 1304 here, unchanged by the two commits), the text-only proof and its controls (the three declared lines now sit at `:202`, `:230` and `:235`), the 20-file runs, the full build, the suite, the typecheck and the gates. Re-fetched just before this PR opened, `origin/main` was one commit further (`75ddcd1b41`, objectstack-ai#21805, in `cloud-connection`, `metadata-core` and `runtime`). It touches no `packages/spec` path and no file here, so it was not merged. ## Changeset: `skip-changeset` Measured, not assumed: - `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the 20 touched files are in it, and no `*.test.ts` at all. Of `src/`, only the `*.zod.ts` sources ship: the controls `src/data/analytics.zod.ts`, `src/data/data-engine.zod.ts` and `dist/data/index.js` are in it. - In the built `dist/`, five new phrases and four old literals each read in 0 files. The control `Unrecognized key(s) on` reads in 42. So this PR publishes nothing, and no changeset is added. ## Verification (at `bf16ad1190`) - `pnpm turbo run build` over all packages: 71 / 71 (also 71 / 71 at the pre-merge head `89c4b300c2`). - `@objectstack/spec`: - `vitest run --project local`: 615 files, 18360 passed, 1 todo. - `typecheck` exit 0, including `check:test-typecheck` (52 files / 246 errors / 135 pinned signatures held). Its program holds all 20 touched files, counted with `tsc --listFilesOnly -p tsconfig.test.json`. - `check:generated`: all 15 generated artifacts up to date after the merge. - **Gates:** `dispatch-gates --commands` derived 79 families, the same set as stages 13 and 14, and all 79 exit 0. `--ran` reconciles: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN. - The five roster families whose rosters sit under a touched directory were also run, and each exits 0: `check:meta-url-spelling`, `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. - **ESLint, a proven narrowing:** `--no-inline-config` over the 20 files reads 0 errors and 0 warnings. The population comes from ESLint's own config: 20 configured, 0 ignored. No file sets `parserOptions.project` or `projectService`, so no untouched file's verdict can move. - `check-governed-merges --test`: NOT governed, 190 changed lines. ## Acceptance notes - **No needle in this group.** Every id was a title or a declared reason string; no expected value of an assertion over a source docblock was found. The three known needles are untouched. - **Same-id test titles in other packages** are their lanes' test-string shares. A search of `describe` / `it` / `test` lines outside `packages/spec` finds 156 lines citing ids this PR handled, in 79 files of 23 packages: `objectql` 73 (29 files), `rest` 18 (7), `runtime` 7 (4), `plugin-security` 6 (5), `cli` 6 (3), `lint` 6 (4), `service-datasource` 6 (3), `driver-sql` 4 (4), `platform-objects` 4 (2), `plugin-approvals` 3 (2), `service-automation` 3 (2), `driver-mongodb` 3 (1), `plugin-auth` 3 (1), `service-analytics` 3 (3), `metadata-core` 2 (1), `plugin-hono-server` 2 (1), and one each in `client`, `triggers`, `core`, `metadata-protocol`, `qa/dogfood`, `types` and `driver-memory`. - **Two spec test files outside `src/`** carry same-id titles: `packages/spec/scripts/file-description.test.ts:66` and `packages/spec/scripts/format-type.test.ts:85`. They are outside class (e) as ruled ("the test strings shipped under `src/`"). - **Numeric delivery fields:** `bulk-write-hook-conformance.test.ts:115-116` and `:129-130` assert `engineDeliveryIssue: 5038` / `5574`, numbers in the source contract table. They are not strings, the gate's pattern cannot see them, and they are not this card's share. - **Code comments still carry ids** in these files and their sources, for example the header of `analytics-strictness-batchd.test.ts` and the `SINGLE_RECORD_WRITE_ONLY` comments in `api-methods-batch-conformance.test.ts`. Comments are not this card's share, and none is touched here. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ 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