Skip to content

fix(rest)!: /import reads a comma in a number cell only as a thousands group, refusing the rest (#20497) - #20517

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20497-import-decimal-comma
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20497-import-decimal-comma

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20497
Clause-②: no

What changes

parseNumberCell in packages/rest/src/import-coerce.ts removed every comma before parsing, as if every comma grouped thousands. So POST /api/v1/data/:object/import stored 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 existing invalid_number error. 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 the parseNumberCell docblock, where the reader's tolerances are listed.

Measured through the real route (JSON rows, writeMode: 'insert') on InMemoryDriver and on SqlDriver (better-sqlite3). Both drivers answered the same on every cell, at base 9449512a31 and at head c76a3c95f2:

cell base: stored · import answer head plain POST (engine insert), both trees
'3,14' 314 · ok 1, errors 0 row refused, invalid_number, nothing stored VALIDATION_FAILED / invalid_number
'1,5' 15 · ok 1, errors 0 row refused, invalid_number, nothing stored same
'1.000,5' 1.0005 · ok 1, errors 0 row refused, invalid_number, nothing stored same
'1,2,3' 123 · ok 1, errors 0 row refused, invalid_number, nothing stored same
'1,000' 1000 1000 (unchanged) refused (the import-only tolerance stays)
'12,345.67' 12345.67 12345.67 (unchanged) refused
'(1,234)' -1234 -1234 (unchanged) refused

PM hypotheses, measured

  • H0 holds. On current main (9449512a31), the four cells imported as 314, 15, 1.0005 and 123 with ok 1, errors 0, on InMemoryDriver and on SQLite. The table above has the readings.
  • H1 holds. The one line s.replace(/,/g, '') is the whole cause. Before/after census through coerceRow: before is rest's built dist at the base, after is the head's src. It covers 79 rows: the spec grammar's 41 NUMERIC_STRING_GRAMMAR_CASES rows, 18 documented or control forms, and 20 comma probes.
    • Grammar rows. 1 of 41 changed: '1.000,5' went from 1.0005 to refused. The other 7 grammar-refused rows the reader admits keep their reading: ' 12 ', '12\n', '\t-3', '1,000', '+5', '.5' and '007'.
    • Documented and control forms. 0 of 18 changed: 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% and 1,000e3.
    • Comma probes. 18 of 20 changed, each from a stripped number to a refusal: 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,5 and 1,5%. The other 2 (1,000. and 1 ,000) were already refused.
    • Overall. 19 rows changed, every one from a stored number to a refusal. No row moved the other way, and no admitted value changed.
  • H2 holds. A refused cell's row carries code: 'invalid_number', the code the importer already uses for abc, with the importer's existing sentence (Amount: "3,14" is not a number, from the catalog's import_invalid_number key). The plain create door answers 400 VALIDATION_FAILED with field code invalid_number for the same cells, and that is pinned beside the import pins. No new code.
  • H3 holds, in the direction expected. The ablation removed the grouping guard, which restores the unconditional strip. It ran via scripts/ablation-replace.mjs in wrap mode, at head c76a3c95f2. The anchor went 1 to 0 and the blob went afaf602da192 to a06963b8c44c, so the mutation landed. Result: Tests 24 failed | 46 passed (70).
    • Red: every refused-cell assertion and nothing else. That is 18 parseNumberCell refusal cases, the 4 per-cell import pins, the CSV leg and the dry-run leg.
    • Green: every admitted control. That is 3 import pins, 8 parseNumberCell admitted cases, and the plain-door parity pin.
    • Restore proven. Blob after restore equals HEAD (afaf602da192), git diff HEAD is empty, and the marker count is 0. No build was needed: the pins reach import-coerce.ts through relative imports (./rest-server, ./import-coerce), never through a dist/.

Tests

  • packages/rest/src/import-number-thousands-group.test.ts (new) goes through the real /import route over SqlDriver (better-sqlite3 :memory:). It has 10 cases:
    • each of the four cells is refused as its own row's invalid_number, with a sibling row still written;
    • each admitted control (1,000, 12,345.67, (1,234)) is stored as its number;
    • the same verdicts hold for quoted CSV cells;
    • the dry run predicts the refusals and persists nothing;
    • the plain create door answers the same code.
  • packages/rest/src/import-coerce.test.ts gets the parseNumberCell case table: 8 admitted groupings and 18 refused comma forms.
  • At c76a3c95f2, run as pnpm --filter @objectstack/rest test --maxWorkers=2: Test Files 220 passed (220) and Tests 4211 passed | 40 skipped (4251). test:repo passed 1 file with 8 tests.
  • pnpm --filter @objectstack/rest typecheck exits 0: tsc --noEmit, then check:test-typecheck: OK, with 0 errors. Both test files are in the tsconfig.test.json program (--listFilesOnly).

Gates

  • Build. turbo run build ran first for @objectstack/rest..., then for all of ./packages/* and ./packages/*/*, because two gates read the whole built tree. Result: 71/71 tasks.
  • Derived gates. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 61 commands at c76a3c95f2. All 61 ran with exit 0. Reconciled with --ran: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN.
  • Other gates, all exit 0. pnpm lint (the whole repository, 29 s, no narrowing) and node scripts/check-issue-citations.mjs --base origin/main (origin/main is still 9449512a31, the branch point, so there was nothing to merge).
  • Changeset gates. check-adr-0087-registration names this PR's changeset as [BREAKING+clause-②-narrowing] not-required (no-migration-prescription). check-changeset-no-major reports no major.

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 turbo run build --filter='@objectstack/rest...' --concurrency=2 --output-logs=errors-only
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 targeted pin files, and the ablation's wrapped run)

(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/rest minor, with a line-initial Clause-②: no (narrowing), a BREAKING paragraph giving each refused cell shape FROM → TO, and the ADR-0087 disposition not-required (no-migration-prescription). The shape follows PR #20218 and PR #20231.

Acceptance notes

  • The InMemoryDriver leg is measured, not pinned. This departs from triage's pin list. Triage asked for /import pins on memory and SQLite. @objectstack/driver-memory's test consumers are a ruled, ledgered set (scripts/driver-memory-census.ledger.json, gated by pnpm check:driver-memory-census), and the gate says a new consumer is a maintainer ruling. A first cut added the driver as a packages/rest devDependency 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.
  • Grouping by twos is now refused (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.
  • The import template card feat(rest): 导出接口新增 ?template=true —— 输出只含「可填列」的 xlsx 导入模板 #18386 should follow this wording. Its value-domain row for number lists 1,234 among 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 as 3,14 is refused, never guessed. That card is assigned elsewhere and is not edited here.
  • A spec comment now overstates the reader. The module header of packages/spec/src/data/filter-number-comparand-declared-type.ts says, in the parenthetical under its refused digit-separator forms, that the CSV import route's cell reader strips such punctuation before it parses. For 1.000,5 that is no longer true. It was already untrue for 1_000 and 1 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.
  • objectui's Import Wizard preview disagrees with the server on grouped numbers. The preview judges numeric cells with a bare Number() (packages/plugin-grid/src/ImportWizard.tsx in objectui, the number/currency/percent case). So it flags 1,000 as 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

hotlong and others added 4 commits September 29, 2026 05:07
… 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>
…-> 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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 9449512a31f61cfb85f6b6934f2bf0d556c8111f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0407d41f6ca81d3c5d27931689f30341fdcdcedc — the merge of head c76a3c95f27e053b6c9fe4f9066d7b06d5860325 into base 9449512a31f61cfb85f6b6934f2bf0d556c8111f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c76a3c95f27e053b6c9fe4f9066d7b06d5860325
Local-runs: none

① Derived judgments

Inputs 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 main at 9449512a31), and the check-runs on the head. The card carries no maintainer ruling; the binding direction is triage's grade 5877481993, and every judgment below is against it.

  • The one code change is the comma guard in parseNumberCell (packages/rest/src/import-coerce.ts): if (s.includes(',')) { if (!THOUSANDS_GROUPED_INTEGER.test(s)) return undefined; s = s.replace(/,/g, ''); } replaces the unconditional s.replace(/,/g, ''). THOUSANDS_GROUPED_INTEGER = /^[+-]?\d{1,3}(?:,\d{3})+(?![\d,])[^,]*$/, a module-private const, tested after the parentheses, currency-symbol and % strips and before the existing numeric grammar. Right.
  • (1) The accepted comma form is exactly triage's. Read off the pattern: an optional sign, 1 to 3 leading digits, one or more groups of exactly three digits, then no digit or comma directly after the last group and no comma anywhere later in the cell. So a group may be followed only by the end of the cell, a . fraction or an exponent, and a comma after any . is refused. Walked by hand: 1,000 12,345.67 1,234,567.89 -1,234 (1,234) $1,000 1,234% 1,000e3 admitted; 3,14 1,5 1.000,5 1,2,3 0,5 1,23 1,0000 1234,567 ,123 -,123 1,000, .5,000 12,345.6,7 12,34,567 1,00,000 (3,14) $1,5 1,5% refused. The lookahead is what refuses 1,0000 (four digits after a comma), which [^,]*$ alone would not. No locale is guessed and no decimal-separator option exists in the diff. Right.
  • (2) No previously admitted non-comma form is refused, and no refused form is admitted. Structural, not only measured: the guard sits inside if (s.includes(',')), so a cell without a comma takes the identical path as at the base; and when the guard passes, the strip is byte-identical to the old one, so the head's accept set is a subset of the base's. The only cells whose reading changed are comma cells that fail the pattern, every one from a stored number to a refusal. The one class that was read to the number the author meant and is now refused, two-digit grouping (12,34,567, 1,00,000), is refused by the grade's own terms (groups of exactly 3, no locale guessing), and the changeset names it. Right.
  • (3) The refused cell's row error is the existing shape, with no new code. parseNumberCell returning undefined reaches the untouched NUMBER_TYPES branch of the cell coercer, coerceError(meta, field, 'invalid_number', 'import_invalid_number', raw, ctx) (base line 444, outside every hunk). No error code, catalog key or sentence is added; the diff adds one private const and docblock text. Right.
  • Surface. parseNumberCell is not exported from packages/rest/src/index.ts and has no caller outside its own module; the published carriers of the narrowed reader are the /import route and the exported coerceRow. A JSON number and a numeric xlsx cell keep the typeof raw === 'number' early return, untouched. No export is added, removed or renamed. Right.
  • Docblock. The rule is written in the parseNumberCell docblock, where the reader's tolerances are listed, as the grade asked. Right.
  • Pins against the grade's cells. Unit table in import-coerce.test.ts: 8 admitted groupings, 18 refused comma forms, the card's four included. Route pins in the new import-number-thousands-group.test.ts, through the real RestServer route over SqlDriver (better-sqlite3 :memory:): each of 3,14 1,5 1.000,5 1,2,3 refused as its own row's invalid_number with the sibling row written and nothing stored; 1,000 / 12,345.67 / (1,234) stored as 1000 / 12345.67 / -1234; the same verdicts for quoted CSV cells; the dry run refuses and persists nothing; the plain create door answers VALIDATION_FAILED / invalid_number for the same cells. Every refused and every admitted cell the grade names is pinned. The memory arm is the one departure from the pin list, judged under ③. Right.
  • File surface equals the claim's (import-coerce.ts, its test, one new test under packages/rest/src/, one @objectstack/rest changeset). The withdrawn driver-memory cut (package.json, lockfile, vitest alias) is not in the net diff. Right.

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. Check Changeset success is the verdict of pr-automation's changeset-check job, which runs check-empty-changeset, check-adr-0087-registration and check-changeset-no-major against the merge base. No verdict is inferred for the two running checks.

② Semver level

  • .changeset/20497-import-number-thousands-group.md: '@objectstack/rest': minor, the only touched package, and it publishes. The body carries a line-initial Clause-②: no (narrowing), a **BREAKING for callers of the import door.** paragraph with a per-shape FROM/TO remedy, a "What is not affected" paragraph, and exactly one adr-0087: not-required (no-migration-prescription) marker.
  • (4) The level and the Clause-② narrowing line satisfy ADR-0087 and the lane's precedents. The diff narrows a published accept set (wire behaviour on /import, and coerceRow), which is no (narrowing) under scripts/pm/clause2-line.mjs (NOT a widening, but breaking) and BREAKING under AGENTS.md Post-Task Checklist step 3. ADR-0087's amended level half (2026-09-13, [Decision] 两条裁决援引同一个 launch-window convention,却给出相反的 changeset 等级(minor vs major)—— 退役一个可写键到底发哪一级? #18003): pre-GA a break ships minor carrying the BREAKING banner and its disposition, and major is refused by check-changeset-no-major. PR fix(runtime): the package install door parses its whole body through PackageInstallBodySchema #20218 (@objectstack/runtime minor, Clause-②: no (narrowing), a BREAKING paragraph with an inline FROM/TO remedy per refused shape, not-required (no-migration-prescription)) and PR fix(cli): refuse a present non-array packages in the stack-collection and docs readers #20231 (@objectstack/cli minor, the same lines) are the same shape in every line that matters. The disposition is legal by the gate's own reading: nothing authorable is removed, renamed or reshaped, and the body carries no FROM → TO placeholder label, no arrow rewrite and no rewrite table (the only arrow-shaped bytes are the marker's own comment close), so the prescription detector finds nothing, exactly as on fix(runtime): the package install door parses its whole body through PackageInstallBodySchema #20218's inline prose. Check Changeset success on this head is the gate's verdict.
  • The PR body's line is bare Clause-②: no, the claim's spelling; the arm rides on the changeset, which is the carrier check-adr-0087-registration reads. Both precedents also spelled no (narrowing) in the PR body; a bare no is a legal declaration (an absent arm declares no direction) and costs the level axis nothing during the window. A note, not a defect.
  • Level: right. Not patch, because (narrowing) is breaking; not major, because the launch window forbids it.

③ Boundary flags

Dev flags (os-dev-report 5879111895), each answered or escalated:

  1. The memory arm is measured, not pinned (deviation 1, and the one open question). Seat-answered A (5879135639): the driver-memory census is a maintainer-ruled gate and it refused the new consumer by name; the cell is judged in parseNumberCell before any driver is reached. Judged here on coverage only: the delivered pins cover every refused and every admitted cell of the grade at the real route (SQLite) plus the reader table, and the memory readings at base and head are recorded in the pin's header and the PR body. Answered.
  2. PR opened with gh pr create (GraphQL) rather than REST. Process only; no effect on the diff or its contract. Noted.
  3. One targeted vitest run outside os-verify-lock.sh, and 4. the UNLOCKED disclosure pasted once for six commands. Process only; the disclosure is in the PR body. Noted.
  4. H3 red set is 24, not "exactly four". The extra reds are the 18 unit-table refusals and the CSV and dry-run legs, all refused-cell assertions; every admitted control stayed green. The substance holds. Noted.

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: claude/issue-20497-import-decimal-comma
Reviewed-by: local_1d2a197c-c20e-4e90-9be8-413d4d432289

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 21:44
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit fb194c7 Sep 28, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20497-import-decimal-comma branch September 28, 2026 22:42
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant