fix(rest)!: /import reads a comma in a number cell only as a thousands group, refusing the rest (#20497) - #20517
Conversation
… group
`parseNumberCell` stripped every comma before parsing, so a decimal-comma
cell ('3,14', '1,5', '1.000,5') or a stray comma ('1,2,3') was stored as a
different number (314, 15, 1.0005, 123) with ok 1, errors 0. A comma is now
admitted only in a well-formed thousands grouping (1 to 3 leading digits,
then groups of exactly three, only before any '.'); any other comma makes
the cell the row's invalid_number error, the code the plain write doors
already answer. No locale is guessed.
Pins /import on InMemoryDriver and SqlDriver (better-sqlite3) with the four
cells refused per row and 1,000 / 12,345.67 / (1,234) as admitted controls;
rest gains @objectstack/driver-memory as a devDependency, aliased to source.
Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
…n case Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…-> TO for each refused cell shape Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…ry test consumer The driver-memory test consumers are a ruled, ledgered set (scripts/driver-memory-census.ledger.json): a new one needs a maintainer ruling, so the InMemoryDriver arm, its devDependency and its vitest alias come back out. The cell is judged before any driver is reached; the memory reading stays recorded as a measurement in the pin's header. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 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 — 15 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 0407d41f6ca81d3c5d27931689f30341fdcdcedc && git checkout 0407d41f6ca81d3c5d27931689f30341fdcdcedc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9449512a31f61cfb85f6b6934f2bf0d556c8111f c76a3c95f27e053b6c9fe4f9066d7b06d5860325 && git checkout -B drift-repro 9449512a31f61cfb85f6b6934f2bf0d556c8111f && git merge --no-ff c76a3c95f27e053b6c9fe4f9066d7b06d5860325
node scripts/docs-audit/affected-docs.mjs --json 9449512a31f61cfb85f6b6934f2bf0d556c8111f |
Contract reviewServed-tier: ① Derived judgmentsInputs read: card #20497 (body; comments 5877481993 triage grade, 5878411415 claim, 5879111895 os-dev-report, 5879135639 seat answer A), PR #20517 (body, 4-file list, net diff against
Check-runs on the head, read at 21:39 UTC, newest run per name, 33 runs: 28 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke opt-in), 2 in progress (Lint & Repo Gates; Test Core (4/6)), 0 failure. ② Semver level
③ Boundary flagsDev flags (os-dev-report 5879111895), each answered or escalated:
Out-of-scope findings, escalated (none blocks this PR):
Landing note: the PASS is on the contract. Two check-runs were still in progress at the read, and landing waits for them to complete green. Implemented-by: VERDICT: PASS |
…efused with VALIDATION_FAILED / invalid_date (objectstack-ai#20481) (objectstack-ai#20524) Fixes objectstack-ai#20481 Clause-②: no (narrowing) A `date` field's write door now stores a day or refuses. A `date` string is accepted only when it carries a leading `YYYY-MM-DD`, the `date` storage rule's own reading, which `temporalStorageForm` collapses to that day. Every other spelling is refused with `VALIDATION_FAILED` / 400 (`invalid_date`) before any driver write. This executes triage's ruling on the card (5875651303): "**Direction (triage's call, as the card asks): refuse.**" No spelling is canonicalised: "`07/08/2026` is ambiguous between locales, and a guess stores a wrong day silently." Measured head: `4c5740d2d`. It is the last change commit `9f0d29239` plus a true merge of `origin/main` `fb194c70e` (two parents). The merge brought PR objectstack-ai#20517 (`packages/rest/src/import-coerce.ts` and a test). It touched no file under `packages/objectql`, `packages/core` or `packages/drivers/driver-memory`. ## The change One source file changes: `packages/objectql/src/validation/record-validator.ts`, the `date` / `datetime` arm, +26 / −3. - For a `date` **string**, the arm also asks `isUninterpretableTemporalComparand('date', value)` from `@objectstack/core`. The engine's temporal-comparand door already refuses these same strings on `where` with that predicate. It trims the string and tests for a leading `YYYY-MM-DD`, the same reading `temporalStorageForm` makes. So the write door and the comparand door agree on which `date` strings the rule reads, and no second copy of the leading-day regex is written. - `Date.parse` readability and the 0001..9999 year range still apply on top. - A `Date`, a number, every `datetime` and every `time` go through the arm exactly as before. - `packages/core` is not edited and gains no export, so the claim's `Clause-②: no (narrowing)` holds and only `@objectstack/objectql` carries a changeset. ## Before and after, at the public door `POST /api/v1/data/:object`, then a read-back. The process ran in America/New_York. PostgreSQL 16.13 was a local server at `Asia/Shanghai`, `DateStyle` `ISO, MDY`. Readings are from base `0bbe4005e` and head. | written to a `date` | memory | SQLite | PostgreSQL | head, all three | |:--|:--|:--|:--|:--| | `"2026/07/15"`, `"07/15/2026"`, `"15 July 2026"`, `"2026-7-15"`, `"2026.07.15"`, `"July 15, 2026"` | 201, read back verbatim | 201, read back verbatim | 201, `"2026-07-15"` | 400 `invalid_date` | | `"07/08/2026"` | 201, verbatim | 201, verbatim | 201, `"2026-07-08"` (`ISO, DMY` reads August 7, measured in `psql`) | 400 `invalid_date` | | `"+002026-07-15"` | 201, verbatim | 201, verbatim | 500 `DATABASE_ERROR` | 400 `invalid_date` | | `"2026-07-15"`, `"2026-07-15T10:00:00Z"`, `"2026-07-15 10:00"`, `" 2026-07-15"` | 201, `"2026-07-15"` | the same | the same | unchanged | | `"20260715"`, `"15/07/2026"` | 400 | 400 | 400 | unchanged | | an epoch-millisecond number | 400 `invalid_date` | 400 | 400 | unchanged | | a `Date` | 201, its UTC day | 201 | 201 | unchanged | ## The dispatch's hypotheses - **H1 holds.** Memory and SQLite answered 201 and read `"2026/07/15"` back verbatim. PostgreSQL does not store it verbatim. Its `DATE` input parser reads the spelling by the server's `DateStyle`, so the stored day is a property of the server's configuration. `"07/08/2026"` is July 8 under `MDY` and August 7 under `DMY`. A spelling it cannot parse (`"+002026-07-15"`) was a 500. - **H2.** Today `readable` is `Date.parse`-readable. These pass today and are refused now: `"2026/07/15"`, `"07/15/2026"`, `"15 July 2026"`, `"2026-7-15"`. These have a leading `YYYY-MM-DD`, are accepted and are stored as `"2026-07-15"` before and after: `"2026-07-15T10:00:00Z"`, `"2026-07-15 10:00"`, `" 2026-07-15"`. The last one is accepted because the rule trims. `"20260715"` has no `Date.parse` reading, so it was refused at the base and still is. The predicate is core's exported `isUninterpretableTemporalComparand` (its `date` branch, private `readsAsCalendarDay` in `temporal-comparand.ts`), so no new predicate was added. - **H3, producers.** No shipped producer generates a non-ISO `date` string on the shipped composition. The one path that forwards raw text runs only when `/import` is unreachable. Details are in the census section below. - **H4, the `datetime` arm.** It does not have this defect: no spelling was stored verbatim on any of the three drivers. It has a different one, reported and not edited: a zone-naive non-ISO spelling is read in the server process's zone. See Acceptance notes. - **H5 holds.** A `Date` and an epoch number keep the behaviour they had. A `Date` is stored as its UTC day. A number is refused with `invalid_date` on the write door, as it was at the base. Both are pinned as controls. ## Producer census (H3) Read at objectstack `0bbe4005e` and objectui `origin/main` `797a30f`. - **objectui `DateField`** (`packages/fields/src/widgets/DateField.tsx`): an `input` of type `date`, and `onChange` emits `e.target.value`, which is `YYYY-MM-DD` or empty. ISO. - **objectui calendar and gantt drag / quick-create writers** (`plugin-calendar/src/ObjectCalendar.tsx` `toStoredDateValue` / `toMovedDateValue`, `plugin-gantt/src/ObjectGantt.tsx` `toStoredDateValue`): `toDateInputValue` or `toISOString().slice(0, 10)` for a `date` field. ISO. - **Server `/import` cell reader** (`packages/rest/src/import-coerce.ts` `parseDateCell`): it always returns `YYYY-MM-DD` for a `date` cell. Both the bulk path and the per-row path of `import-runner.ts` call `coerceRow` first (`:775`). ISO. Not edited. - **objectui Import Wizard's legacy per-row fallback** (`plugin-grid/src/ImportWizard.tsx` `legacyImport` → `validateRow` `:561`, `validateValue` `:486`): it sends the raw cell text, checked only by `Date.parse`. It runs only when the data source has no `importRecords`, or the client has no `data.import` (`isUnsupportedImport` `:626`). objectui's `data-objectstack` adapter implements `importRecords` (`src/index.ts:4430`). On this path a non-ISO cell is now a per-row refusal instead of a stored non-day. See open question 1 in the report. - **Seeds under `examples/`**: CEL `daysAgo(n)` / `daysFromNow(n)` (a `Date`, normalised to `YYYY-MM-DD`) and ISO literals. A regex census of non-ISO date literals over `examples/**` finds 0, with a control regex for ISO literals finding hits in 6 files. - **AI / MCP writers**: `packages/mcp/src/mcp-http-tools.ts` `create_record` / `update_record` forward `data` values unchanged, as `z.unknown()`. They are pass-through, and a model-written non-ISO date now gets the 400 back as the tool error. ## Tests - `packages/objectql/src/engine-date-write-iso-only.test.ts` (new, 4 tests, recording driver): 8 refused spellings plus 5 already refused (including `{today}` and an epoch number), on insert, update, a multi-row update and `engine.validate`. Each asserts `code` `VALIDATION_FAILED` and `fields` `[placed_on, invalid_date]`, with zero driver writes. The accepted leading-day spellings and a `Date` reach the driver. One test holds both doors to one verdict per string: `validate` validity equals `where` acceptance. - `packages/rest/src/data-date-write-iso-only.test.ts` (new, 3 tests per cell): `POST` and `PATCH` over a real `SqlDriver`. SQLite always runs. Live PostgreSQL runs where `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise. Each refused spelling asserts status 400, `code` `VALIDATION_FAILED` and the field code, with no write. There is an epoch-number control, and the ISO spellings read back as `"2026-07-15"`. - `packages/drivers/driver-memory/src/memory-20481-date-write-iso-only.test.ts` (new, 2 tests): each spelling the door admits, and a `Date`, is stored as `2026-07-15`, found by it, and ordered after `2026-07-14` on `InMemoryDriver`. Reverse verification: - **objectql:** the fix was committed first. `scripts/ablation-replace.mjs` put the base condition back in `record-validator.ts` (anchor 1 → 0, blob `b5c6bb81dc72` → `d501d34ad6bc`), and the new file went 3 failed / 1 passed. It passes 4/4 at head. The tool restored the file: blob equals HEAD and `git diff HEAD` is empty. - **REST:** the rest suite reads `@objectstack/objectql` from `dist`. Against the base `dist` (`readsAsDay` count 0), the new file went 2 failed / 4 passed on SQLite and live PostgreSQL, because `"2026/07/15"` answered 201. After `pnpm --filter @objectstack/objectql build` (count 2), it passed 6/6. Suites: - **objectql:** at `9f0d29239`, 332 files / 6632 tests passed. `test:repo` 1 / 5. `typecheck` exit 0; `check:test-typecheck` compiles the new file with the debt unchanged at 40 files. - **driver-memory:** at `9f0d29239`, 59 / 1380, and `typecheck` exit 0. - **rest:** at `4c5740d2d`, `--project local` 221 files, 4214 passed / 43 skipped. `test:repo` 1 / 8, and `typecheck` exit 0. - **REST file with live PostgreSQL, at `4c5740d2d`:** 6 / 6. The objectql and driver-memory trees are byte-identical between `9f0d29239` and `4c5740d2d`. ## Gates at `4c5740d2d` - **`dispatch-gates --commands --repo objectstack-ai/objectstack`:** 65 commands, each run with its exit code captured before any pipe. 63 exit 0. - **NOT MEASURED: `check:dual-build-cjs-loads`, `check:type-check-debt`.** Reason: both exit 3 `PREREQUISITE NOT MET` and need the whole tree built (`lint.yml` builds it first); only the rest and driver-memory closures are built here. Scoped reading: both `require` entries of `@objectstack/objectql` (`.` and `./core`) load from the rebuilt `dist`. - **`--ran` reconciliation:** 65 derived, 63 run, 2 NOT-MEASURED, 0 UNRUN. - **Rosters in the touched directories:** `check-changeset-fixed`, `check:authz-resolver`, `check:filter-alias-parity`, `check:object-def-param-keys` and `check:tenant-chokepoint` each exit 0. - **Narrowed lint:** `eslint --no-inline-config --format json` over the 4 changed TS files, which are inside `eslint.config.mjs`'s population: 4 files, 0 errors, 0 warnings. `--print-config` shows no `parserOptions.project` or `projectService`, so type-aware linting is off and this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **REST on memory** is not a cell of the committed REST file. `@objectstack/driver-memory` has no binding in `packages/rest`, and a new binding is a `check:driver-memory-census` disposition, not a test's. Memory is covered by the engine pin (the refusal reaches no driver), by the driver-memory pin (admitted spellings are stored as the day), and by the before / after table above, measured through `RestServer` over `InMemoryDriver` with a scratch script that was not committed. - **The `invalid_date` message** still reads "must be a valid date (ISO-8601)". It lives in `packages/spec` (`system/validation-message.ts`), outside this card's file surface. `"20260715"` (ISO basic) and `"+002026-07-15"` (ISO extended year) are ISO-8601 spellings refused with that message. Carrier: none. - **Out of scope, measured and not edited (reported to the seat):** a `date` `"2026-02-30"` is `Date.parse`-readable and has a leading day shape. It is still 201 and read back verbatim on memory and SQLite, and a 500 on PostgreSQL. On the `datetime` arm, a non-ISO zone-naive spelling is read in the process zone (`"2026/07/15 10:00"` became `14:00Z` under America/New_York, while `"2026-07-15 10:00"` reads as UTC), `"07/08/2026"` is read month-first, and `"2026-02-30T10:00:00Z"` rolls over to `2026-03-02T10:00Z`. - **`driver-mongodb`** keeps its own copy of the storage rule and is unmeasured here. The refusal sits in the engine in front of it. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…601, the export shape or a year-first date, on a real day, with a four-digit year (objectstack-ai#20534) (objectstack-ai#20601) Fixes objectstack-ai#20534 Clause-②: no ## What changes `parseDateCell` in `packages/rest/src/import-coerce.ts` is the reader behind `POST /api/v1/data/:object/import` for `date`, `datetime` and `time` cells. It had four faults: - it rolled an impossible day into the next month; - it passed every cell it could not read itself to `new Date(s)`, which reads the cell in the server process's zone and reads `07/08/2026` month-first; - it spelled a `date` year below 1000 without padding; - it read a bare day into a `datetime` through `Date.UTC(y, …)`, which puts years 0..99 in the 1900s. This PR carries out triage's answer **A** (`5883900872`). The maintainer ruling on the card (`5885066497`) supersedes A on one point: year-first dates stay admitted. **The set that is read.** After trimming, a text cell is read only in these shapes: - ISO 8601 and the export shape: - `YYYY-MM-DD`; - `YYYY-MM-DDTHH:MM[:SS[.f]]`, then `Z`, `±HH:MM`, `±HHMM` or nothing; - `YYYY-MM-DD HH:MM[:SS[.f]]` with no zone, the export shape. xlsx date cells reach the reader in this shape. - These are the spellings the write door admits for a `datetime` string since objectstack-ai#20525. - A year-first date, per the ruling: - `YYYY/M/D` or `YYYY-M-D`: a four-digit year, a one- or two-digit month and day, and the same separator in both places; - optionally followed by one space and `H:MM` or `H:MM:SS`, with a one- or two-digit hour; - no zone, no fraction and no `T` in this form. It is one closed grammar, `YEAR_FIRST_CELL`, beside `ISO_TEMPORAL_CELL`. - For a `time` cell, also a bare `HH:MM[:SS]`. **The rules every admitted cell keeps:** - **Real day.** The day must exist (`namesRealCalendarDay`, arithmetic, never a `Date` round trip). `2026-02-30` and `2026/2/30` are refused and never rolled over. - **Clock range.** On a zone-naive clock the hour runs 0..23 and the minute and second 00..59, so `24:00` is refused. `T24:00Z` names its instant and reads as before. - **Refusals.** Everything else is refused per row as `invalid_date`, with the existing `import_invalid_date` / `import_invalid_datetime` / `import_invalid_time` sentences. There is no new error code and no date-format option. Nothing is read in the host zone, and no field order is guessed: `07/15/2026`, `15/07/2026` and `26/7/15` stay refused. - **Four-digit year on every `date` branch.** - A text cell keeps the four digits it was written with, and a year-first day is stored padded (`2026/7/15` → `2026-07-15`, `0500/1/1` → `0500-01-01`). - An instant (a `Date`, or a zone-bearing cell) takes core's `temporalStorageForm` `date` rule, imported from `@objectstack/core`. - **Bare day into a `datetime`.** It is spelled from the day itself (`…T00:00:00.000Z`), for ISO and year-first alike, so `0001-01-01` is stored in year 1, not 1901. - **Year-first clocks.** A clock is a wall clock read exactly as the export shape's is: through core's `zonedWallClockToUtcMs` in the business zone for a `datetime`, verbatim for a `time` or a `date`. - **Unchanged.** A zone-naive ISO or export-shape cell is still read in the business timezone (objectstack-ai#8485), and an offset-bearing cell is still honoured as written. One source file changes: `parseDateCell`, its docblock, and the private helpers beside it: `ISO_TEMPORAL_CELL`, `YEAR_FIRST_CELL`, `namesRealCalendarDay`, `readIsoTemporalCell`, `readYearFirstCell` and `utcClock`. `NAIVE_DATE_TIME` and `parseNaiveWallClock` are replaced. ## PM hypotheses, measured ### H0 holds Measured through the real `/import` route, JSON rows, no business timezone. The runs used `InMemoryDriver` and `SqlDriver` (better-sqlite3) under `TZ=America/New_York` and `TZ=Asia/Shanghai`, at base `f11b5f20a2`; the reader is byte-identical on today's `main` (`3a89d459af`). Memory and SQLite gave the same answer on every cell, at base and at head. | cell | kind | base, New York | base, Shanghai | head, both zones, both drivers | |:--|:--|:--|:--|:--| | `2026-02-30` | datetime | `2026-03-02T00:00:00.000Z` | same | refused `invalid_date` | | `2026-02-30 10:00` | datetime | `2026-03-02T10:00:00.000Z` | same | refused | | `2026-02-30T10:00:00Z` | datetime | `2026-03-02T10:00:00.000Z` | same | refused | | `2026-02-30T10:00:00Z` | date | `2026-03-02` | same | refused | | `07/15/2026 10:00` | datetime | `2026-07-15T14:00:00.000Z` | `2026-07-15T02:00:00.000Z` | refused | | `07/08/2026` | datetime | `2026-07-08T04:00:00.000Z` | `2026-07-07T16:00:00.000Z` | refused | | `07/15/2026`, `15 July 2026` | date | `2026-07-15` | `2026-07-14` | refused | | `07/15/2026 10:00` | time | `14:00:00` | `02:00:00` | refused | | `2026-07-15 24:00` | datetime | `2026-07-16T04:00:00.000Z` | `2026-07-15T16:00:00.000Z` | refused | | `0500-01-01`, `0001-01-01`, `0999-12-31` | date | refused (`500-01-01` reached the write door) | same | stored `0500-01-01`, `0001-01-01`, `0999-12-31` | | `2026/7/15`, `2026/07/15`, `2026-7-15` | date | `2026-07-15` | same | `2026-07-15` (unchanged) | | `2026/7/15 9:00` | datetime | `2026-07-15T09:00:00.000Z` | same | unchanged, and equal to what `2026-07-15 09:00:00` stores | | `2026/2/30` | date | refused by the write door (`2026-02-30` reached it) | same | refused by the reader | | `2026-07-15`, `2026-07-15T10:00:00Z`, `2026-07-15 10:00:00`, `2026-07-15T10:00:00+08:00` | both | unchanged | unchanged | unchanged | The write door takes `0500-01-01`: `POST /api/v1/data/:object` answers `201` and stores it as written. The import now stores the same value, and a pin asserts they agree. ### H1 holds: the census, `main` against head The census called `parseDateCell` directly. The old reader is `main`'s `import-coerce.ts` at `3a89d459af`, byte-identical to the base. The new reader is this head's `src`. It covered 111 shapes, the 3 kinds, 2 host zones and 2 business-zone settings (none, and `Asia/Shanghai`), 666 rows. That is the first round's 98 shapes (588 rows) plus 13 year-first edges. | | all 666 rows | the original 588 rows | |:--|:--|:--| | refused → admitted | **0** | **0** | | admitted → refused | 240 | 198 | | admitted, value changed | 42 | 36 | | rows whose answer differs by host zone: `main` → head | 114 → 0 | 100 → 0 | - **Admitted → refused.** Each is one of these: an impossible day, a locale or prose spelling, a reduced or expanded form, a zone after a space, lower-case `t`/`z`, a zone-naive `24:00`, a number, or a year-first date outside its one form. - The year-first forms `main` admitted that are now refused, each by the ruling: - `2026/2/30` (impossible day); - `2026/7-15` (mixed separator); - `2026/7/15 24:00`; - `2026/7/15T9:00` (`T`); - `2026/7/15 9:00Z` (zone); - `2026/7/15 9:00:00.5` and `2026/07/15 10:00:00.123` (fraction). - **Value changes, 42.** - 30 are the four-digit year on a `date` (`500-01-01` → `0500-01-01`, including `0500/1/1` and a `Date` of year 500), or the 1900s fix for a bare day into a `datetime` (`0050-01-01` was `1950-01-01T00:00:00.000Z`). - **12 are one named exception:** a year-first date with no clock, given to a `time` field. The cells are `2026/7/15`, `2026/07/15`, `2026-7-15`, `2026-07-5`, `2028/2/29` and `0500/1/1`, under both business-zone settings. - `main` read these through `new Date(s)` in the host zone: `04:00:00` in New York, `16:00:00` in Shanghai, and `04:56:02` / `15:54:17` for year 500. No host-independent reading can equal a value that differs by host. - Head reads them as a bare ISO day into a `time` field is read on `main` and on head alike: `00:00:00`. - **Year-first forms `main` admits.** Every one stores the same value at head, apart from the padding, the real-day refusals, the ruled refusals above and the `time` exception. ### H2: the year pad, and where each rule comes from - **Year pad: imported.** Instant-derived `date` branches call `@objectstack/core`'s `temporalStorageForm(…, 'date')`. ISO text branches never turn the year into a number; the grammar requires four digits and the cell's own digits are kept. The year-first branch pads month and day and keeps the four-digit year. - **Calendar check: mirrored.** The write door's `namesRealCalendarDay` is private to `record-validator.ts`, and `packages/objectql` is read-only for this claim. It is copied here, word for word in its arithmetic, as one private helper that both readers share. ### H3 holds, in the direction expected, in both rounds **Round 1: removing the refusals.** The ablation restored the `new Date(s)` fallback for every cell the reader refuses, at head `9b31e7bc76`. - The mutation landed: anchor 1 → 0, blob `5d87c0ac825f` → `f4cfe0c42335`. - Result: `64 failed | 171 passed (235)`. - Red: exactly the refusal assertions. Green: every control. - Restored: blob equals HEAD, and `git diff HEAD` is empty. **Round 2: removing the year-first branch.** The anchor `readIsoTemporalCell(s) ?? readYearFirstCell(s);` became `readIsoTemporalCell(s);`, via `scripts/ablation-replace.mjs` in wrap mode at head `279ca425fa`. - **The mutation landed.** Anchor 1 → 0, and blob `4d5fb1969259` → `d8a032952d0a`. - **Result.** `Tests 22 failed | 237 passed (259)`. - **Red: exactly the year-first admissions, 22 tests.** - The unit table's 11 year-first rows. - The 5 restored fixtures and assertions: - the `2026/6/3` line; - the `coerceRow` fixture; - the business-timezone `2026/08/01 06:00:00` line; - the integration CSV cell; - the integration xlsx text cell. - The route file's 2 year-first admission pins and its same-instant pin, under each host zone. - **Green: every refusal and every other control.** - All 42 unit refusals, including the 10 year-first edges. - The route file's `2026/2/30` refusal pins. - **Restore proven.** The blob after restore equals HEAD (`4d5fb1969259`), and `git diff HEAD` is empty. - **No build needed.** The pins reach `import-coerce.ts` through relative imports, never a `dist/`. ## Tests - **`packages/rest/src/import-date-cell-iso-real-day.test.ts`** (new). It uses the real `/import` route over `SqlDriver` (better-sqlite3 `:memory:`), under `TZ=America/New_York` and `TZ=Asia/Shanghai`. The zone switch is asserted with `Intl` and with the July offset. There are 28 cases per zone: - each of 12 refused rows is refused as its own row's `invalid_date`, a sibling row is still written, and nothing is stored for the refused row. The rows are the card's 11 plus `2026/2/30`. - 10 admitted cells store their value: - `0500-01-01`, `0001-01-01`, and a datetime `0001-01-01`; - the 2026 ISO, offset and export-shape controls; - a `date` `2026/7/15` stored as `2026-07-15`; - a `datetime` `2026/7/15 9:00`. - a year-first `2026/7/15 9:00` stores the same instant as `2026-07-15 09:00:00`; - an imported padded year agrees with what the create door stores; - a real export → import round trip through `GET /export`; - an xlsx date cell reads as before; - quoted CSV cells get the same verdicts; - the dry run predicts the refusals and persists nothing. - **`packages/rest/src/import-coerce.test.ts`.** The `[objectstack-ai#20534]` table has 42 refused and 36 admitted cases, each asserted equal under both host zones. - The year-first rows are admission rows. - The refused edges are `2026/2/30`, `2026/7-15`, `2026/7/15 24:00`, `2026/7/15 9:60`, `2026/7/15T9:00`, `2026/7/15 9:00Z`, `2026/7/15 9:00:00.5`, `26/7/15` and `07/15/2026`. - **Fixtures restored to `main`'s spelling.** - The `coerceRow` fixture `due: '2026/07/01'`. - The `import-integration.test.ts` CSV cell `2026/06/30` and xlsx text cell `'2026/07/01'`. The xlsx row now also asserts its stored `due`, `2026-07-01`. - The assertions `parseDateCell('2026/6/3', 'date')` → `2026-06-03`, and the business-timezone `2026/08/01 06:00:00` → `CROSS_MONTH_UTC`. `import-business-timezone.test.ts` is byte-identical to `main` again. - **Package tests.** At `3b2e47349e`, `pnpm --filter @objectstack/rest test --maxWorkers=2` gave `Test Files 227 passed (227)` and `Tests 4382 passed | 50 skipped (4432)`. `test:repo` gave `1 passed (1)`, `8 passed (8)`. - **Typecheck.** `pnpm --filter @objectstack/rest typecheck` exits 0: `tsc --noEmit`, then `check:test-typecheck: OK`. - **Memory leg.** It is measured on base and head, not pinned (see Acceptance notes). Year-first cells were added: `2026/7/15`, `2026/7/15 9:00`, `2026/2/30`, `2026/7-15` and `2026-07-15 09:00:00`. Memory and SQLite agree on all 46 cells under both zones, and 0 cells differ between zones. ## Gates, at `3b2e47349e` - **Merges.** `origin/main` was merged twice this round: at `7a1faf1a5d`, then at `3a89d459af` (a version-packages commit and four others landed in between). - **Build.** `turbo run build` over all of `./packages/*` and `./packages/*/*`: 71/71 tasks. The tree was clean afterwards. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 61 commands. All 61 ran with exit 0, after the last commit. - Reconciled with `--ran`: `61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN`. - **Roster rows that could apply**, all exit 0: - `check-changeset-fixed`; - `check:authz-resolver`, `check:filter-alias-parity`, `check:error-status-conformance`; - `check:route-ledger-census`, `check:tenant-chokepoint`; - `check-published-list-mirrors`, `check:published-readme-exports`; - `check:select-shard-packages`, `check:select-gate-families`. - `check-single-claim-paths` ran as a read with `PR_NUMBER=20601`: `PR objectstack-ai#20601 modifies none of the 1 declared at-most-one-writer path(s)`. It read the PR's file list from GitHub. - **Other gates, exit 0.** - `pnpm lint`: the whole repository, no narrowing. - `node scripts/check-issue-citations.mjs --base origin/main`: 7 citations, all resolve. - **Changeset gates.** `check-adr-0087-registration` names the changeset `[BREAKING+clause-②-narrowing] not-required (no-migration-prescription)`. `check-changeset-no-major` reports no `major`. `check-empty-changeset` passes. **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for this run, nor for any sibling agent in this container while it ran. pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' build pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 --output-logs=errors-only pnpm --filter @objectstack/rest test --maxWorkers=2 pnpm --filter @objectstack/rest test:repo --maxWorkers=2 pnpm --filter @objectstack/rest typecheck pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 (the touched test files; both ablations' wrapped runs; the base run) (The entry point printed this wording once for each command above. It is pasted once here, with every command it covered.) ## Changeset `.changeset/20534-import-date-cell-iso-real-day.md` is `@objectstack/rest` `minor`, with a line-initial `Clause-②: no (narrowing)`. - **BREAKING paragraph.** Each refused shape gets a FROM spelling and an admitted TO spelling. The year-first edges are listed: a mixed separator, a `T` or a zone, a fraction, and `2026/7/15 24:00`. - **Kept.** A "Kept: year-first dates" paragraph says that `2026/7/15`, Excel's default in zh-CN and ja-JP, stays admitted, now with the real-day check and stored padded. It also names the `time` exception. - **ADR-0087 disposition.** `not-required (no-migration-prescription)`. The shape follows PR objectstack-ai#20517. The exported `coerceRow` narrows with the door, and the changeset says so. ## Acceptance notes - **The `InMemoryDriver` leg is measured, not pinned. This departs from triage's pin list, as PR objectstack-ai#20517 did.** - `@objectstack/driver-memory`'s test consumers are a ruled, ledgered set (`pnpm check:driver-memory-census`), so the census is not widened here. - The cell is judged by the import's own reader before any driver is reached, and memory and SQLite agree on every measured cell (H0, and the Tests section). - **Year-first dates stay admitted, by the maintainer ruling (`5885066497`).** They are Excel's default in zh-CN and ja-JP. They now keep the rules every other admitted cell keeps, and they read the same as on `main` except for the ruled refusals and the `time` exception in the census. - **The `time` branch is covered too.** Its fallback was the same `new Date(s)`: `07/15/2026 10:00` into a `time` field was stored as `14:00:00` on a New York host and `02:00:00` on a Shanghai host. It is in `parseDateCell`, inside the claim's file surface, and it is the same host-zone reading triage ruled out. - **A zone-naive `24:00` is now refused, in both the ISO and the year-first form.** It used to fall through to `new Date(s)`, in the host zone. `2026-07-15T24:00:00Z` names its instant and reads as before. - **objectui's Import Wizard preview disagrees with the server on non-ISO, non-year-first dates.** - The preview judges a `date` or `datetime` cell with a bare `Date.parse` (`packages/plugin-grid/src/ImportWizard.tsx` `validateValue` in objectui), so it marks `07/15/2026` valid while the server refuses it. - The wizard's server-side "Validate data" dry run gives the server's verdict. - I read this in objectui's source and did not measure it through the UI. There is no carrier, so it is noted here only. - **The import-mappings doc could name the accepted spellings.** `content/docs/data-modeling/import-mappings.mdx` says date cells are "parsed to storage form" and lists no spellings. There is no carrier, so it is noted here only. - **Two year-below-1000 defects outside this claim's surface are filed, not fixed here.** - **objectstack-ai#20602:** `/export` writes a year-500 row unpadded (`500-01-01`), so the export does not re-import. - **objectstack-ai#20599:** core's `zonedWallClockToUtcMs` stores a zone-naive `0050-01-01 10:00:00` as `1950-01-01T10:00:00.000Z`. This PR leaves that reading as it was, for ISO and year-first cells alike: `packages/core/**` is read-only for this claim. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20497
Clause-②: no
What changes
parseNumberCellinpackages/rest/src/import-coerce.tsremoved every comma before parsing, as if every comma grouped thousands. SoPOST /api/v1/data/:object/importstored a decimal-comma cell as a different number and reported success.A comma is now accepted only in a well-formed thousands group: 1 to 3 leading digits, then groups of exactly three, and only before any
.(1,000,12,345.67). One anchored pattern,THOUSANDS_GROUPED_INTEGER, is tested before the strip, and the strip now runs only for that form. Every other comma makes the cell unparseable, so the row gets the importer's existinginvalid_numbererror. That is the same code the plain write doors already answer for these cells. No locale is guessed, and there is no new error code and no decimal-separator option, as triage directed (5877481993). The rule is stated in theparseNumberCelldocblock, where the reader's tolerances are listed.Measured through the real route (JSON rows,
writeMode: 'insert') onInMemoryDriverand onSqlDriver(better-sqlite3). Both drivers answered the same on every cell, at base9449512a31and at headc76a3c95f2:POST(engine insert), both trees'3,14'314· ok 1, errors 0invalid_number, nothing storedVALIDATION_FAILED/invalid_number'1,5'15· ok 1, errors 0invalid_number, nothing stored'1.000,5'1.0005· ok 1, errors 0invalid_number, nothing stored'1,2,3'123· ok 1, errors 0invalid_number, nothing stored'1,000'10001000(unchanged)'12,345.67'12345.6712345.67(unchanged)'(1,234)'-1234-1234(unchanged)PM hypotheses, measured
main(9449512a31), the four cells imported as314,15,1.0005and123withok 1, errors 0, onInMemoryDriverand on SQLite. The table above has the readings.s.replace(/,/g, '')is the whole cause. Before/after census throughcoerceRow: before is rest's builtdistat the base, after is the head'ssrc. It covers 79 rows: the spec grammar's 41NUMERIC_STRING_GRAMMAR_CASESrows, 18 documented or control forms, and 20 comma probes.'1.000,5'went from1.0005to refused. The other 7 grammar-refused rows the reader admits keep their reading:' 12 ','12\n','\t-3','1,000','+5','.5'and'007'.1,234,$1,000,¥2,500.75,€1,000,£1,000,¥1,000,25%,(1,234),(100),1,234.5,12,345.67,1,000,-1,234,+1,234,1,234,567.89,$ 1,000,1,234%and1,000e3.3,14,1,5,1.000,5,1,2,3,0,5,1,23,1234,567,1,0000,,123,-,123,.5,000,1,000,,12,345.6,7,12,34,567,1,00,000,(3,14),$1,5and1,5%. The other 2 (1,000.and1 ,000) were already refused.code: 'invalid_number', the code the importer already uses forabc, with the importer's existing sentence (Amount: "3,14" is not a number, from the catalog'simport_invalid_numberkey). The plain create door answers400 VALIDATION_FAILEDwith field codeinvalid_numberfor the same cells, and that is pinned beside the import pins. No new code.scripts/ablation-replace.mjsin wrap mode, at headc76a3c95f2. The anchor went 1 to 0 and the blob wentafaf602da192toa06963b8c44c, so the mutation landed. Result:Tests 24 failed | 46 passed (70).parseNumberCellrefusal cases, the 4 per-cell import pins, the CSV leg and the dry-run leg.parseNumberCelladmitted cases, and the plain-door parity pin.afaf602da192),git diff HEADis empty, and the marker count is 0. No build was needed: the pins reachimport-coerce.tsthrough relative imports (./rest-server,./import-coerce), never through adist/.Tests
packages/rest/src/import-number-thousands-group.test.ts(new) goes through the real/importroute overSqlDriver(better-sqlite3:memory:). It has 10 cases:invalid_number, with a sibling row still written;1,000,12,345.67,(1,234)) is stored as its number;packages/rest/src/import-coerce.test.tsgets theparseNumberCellcase table: 8 admitted groupings and 18 refused comma forms.c76a3c95f2, run aspnpm --filter @objectstack/rest test --maxWorkers=2:Test Files 220 passed (220)andTests 4211 passed | 40 skipped (4251).test:repopassed 1 file with 8 tests.pnpm --filter @objectstack/rest typecheckexits 0:tsc --noEmit, thencheck:test-typecheck: OK, with 0 errors. Both test files are in thetsconfig.test.jsonprogram (--listFilesOnly).Gates
turbo run buildran first for@objectstack/rest..., then for all of./packages/*and./packages/*/*, because two gates read the whole built tree. Result: 71/71 tasks.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 61 commands atc76a3c95f2. All 61 ran with exit 0. Reconciled with--ran:61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN.pnpm lint(the whole repository, 29 s, no narrowing) andnode scripts/check-issue-citations.mjs --base origin/main(origin/mainis still9449512a31, the branch point, so there was nothing to merge).check-adr-0087-registrationnames this PR's changeset as[BREAKING+clause-②-narrowing] not-required (no-migration-prescription).check-changeset-no-majorreports nomajor.Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usable
flock. The sharedverify lock is declared Linux-only (
flockis util-linux, and a stock macOS doesnot ship it), so the command below was run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held for this
run, nor for any sibling agent in this container while it ran.
(The entry point printed this wording once for each command above. It is pasted once here, with every command it covered.)
Changeset
.changeset/20497-import-number-thousands-group.md:@objectstack/restminor, with a line-initialClause-②: no (narrowing), a BREAKING paragraph giving each refused cell shape FROM → TO, and the ADR-0087 dispositionnot-required (no-migration-prescription). The shape follows PR #20218 and PR #20231.Acceptance notes
InMemoryDriverleg is measured, not pinned. This departs from triage's pin list. Triage asked for/importpins on memory and SQLite.@objectstack/driver-memory's test consumers are a ruled, ledgered set (scripts/driver-memory-census.ledger.json, gated bypnpm check:driver-memory-census), and the gate says a new consumer is a maintainer ruling. A first cut added the driver as apackages/restdevDependency with a source alias. The census gate refused it by name:x LEDGERED: packages/rest/src/import-number-thousands-group.test.ts:31 binds @objectstack/driver-memory (import) and the ledger does not cover it. That cut was withdrawn, so the diff is back to the claim's file surface. The cell is judged by the importer's reader before any driver is reached, so one verdict holds on every driver. The memory readings at base and head are recorded in the table above and in the pin's header. If a permanent memory arm is wanted, it goes through the census ruling first.12,34,567,1,00,000). These used to import as the number they denote. The changeset names this as the one case where the narrowing refuses a cell that was read correctly before, because a two-digit group cannot be told apart from a decimal comma.numberlists1,234among the tolerated forms. It should say that a comma is read only as a thousands group (1 to 3 leading digits, then groups of exactly three, only before any.), and that a decimal comma such as3,14is refused, never guessed. That card is assigned elsewhere and is not edited here.packages/spec/src/data/filter-number-comparand-declared-type.tssays, in the parenthetical under its refused digit-separator forms, that the CSV import route's cell reader strips such punctuation before it parses. For1.000,5that is no longer true. It was already untrue for1_000and1 000, which the reader refused before this change too. This is a comment in the spec seat's surface. There is no carrier, so it is noted here only.Number()(packages/plugin-grid/src/ImportWizard.tsxin objectui, the number/currency/percent case). So it flags1,000as invalid while the server admits it. That disagreement predates this PR and is unchanged by it. For the four cells here, preview and server now agree: both refuse. I read this in objectui's source and did not measure it through the UI. There is no carrier, so it is noted here only.Generated by Claude Code