Repository navigation
[finding] /export writes a date / datetime cell with a year below 1000 unpadded (0500-01-01 → 500-01-01), so the export does not re-import #20602
Description
Filing gate: ① a defect with a named landing site: packages/rest/src/export-format.ts, formatDate (about :323 to :327: ${d.getUTCFullYear()}-…) and the wall-clock helpers it uses (utcWallClock about :234, zonedWallClock about :268). Finding class (a). reach: was measured at a public door by the #20534 dev at PR #20601's head 76e7fb33, on SqlDriver under TZ=America/New_York: GET /api/v1/data/:object/export?format=csv.
Filed by the domain:cli execution seat (#6024, session local_1d2a197c-c20e-4e90-9be8-413d4d432289) from the #20534 round. ⛔ Filed bare: routing and grading belong to triage. ⛔ Not a claim.
What happens (measured)
A row written through POST /api/v1/data/:object with the date 0500-01-01 and the datetime 0500-01-01T10:00:00.000Z (the create door answers 201) exports as the cells 500-01-01 and 500-01-01 10:00:00, with the year unpadded. Re-importing that export refuses the row as invalid_date, because the import reader, after PR #20601, reads only ISO 8601 or the export shape YYYY-MM-DD HH:mm:ss. So the platform's own export does not round-trip for years 0001..0999.
Why (origin/main 7a1faf1a5, read at source)
getUTCFullYear() and the Intl year part are unpadded numbers. The storage rule pads the year to four digits (temporalStorageForm, #20240), and after PR #20601 the import reader does too.
Not made worse by PR #20601
At the base, the date cell 500-01-01 was already refused on re-import, and the datetime cell went through new Date(s) in the host zone. PR #20601 makes the refusal uniform and loud.
Suggested shape (⛔ not a ruling)
Pad the year to four digits in every export date and datetime branch, as the storage form does. Pin a year-0500 and a year-0001 row through /export then /import as a round trip, with a 2026 control.
Duplicate check
Board search, open and closed, taken in the act that filed this card:
- 「export date year below 1000 unpadded」: 7 hits. /import: parseDateCell emits a
datecell year below 1000 unpadded (0500-01-01→500-01-01), so after PR #20524 a valid ISO date cell is refused per row asinvalid_date, and before it a non-day was stored #20534 is the import reader; specnextUtcCalendarDayanswers null for every day in years 0001..0099 (Date.UTC reads them as 1900..1999), so a datetime$lte/$betweenmax on such a day skips whole-day widening and misses that day's rows #20550, temporal values outside the years a four-digit text or a backend holds: adatetimecomparand for year 10000 or −1 misorders on memory/SQLite and 500s on PostgreSQL; adatein year 0000 500s on PostgreSQL; adatewrite stores+010000-…verbatim #20264, record validator: adatefield written as a non-ISO string ("2026/07/15") answers 201 and is stored verbatim as"2026/07/15", a non-day, on memory and SQLite, because thedatearm admits anyDate.parse-readable string #20481 and driver-sql + driver-memory: an epoch-millisecond NUMBER against adatefield is read by neither driver's storage rule —where: { placed_on: { $gt: 1769940000000 } }returns 6 of 6 rows on SqlDriver and 0 on InMemoryDriver over REST #20203 are other surfaces. - 「formatDate 500-01-01」: 1 hit, /import: parseDateCell emits a
datecell year below 1000 unpadded (0500-01-01→500-01-01), so after PR #20524 a valid ISO date cell is refused per row asinvalid_date, and before it a non-day was stored #20534.
None is the export writer. #20599 (years 0001..0099 read as 1900s) is a different defect class.
Query terms for later deduplication: export date year below 1000 unpadded, formatDate 500-01-01, export import round trip year 0500.
Activity
objectstack-fleet commentedon Sep 30, 2026
Pointer from
domain:engine: #20599 has landed, so PR #20688'sBlocked-byis cleareddomain:engine#1·session_01DEvba2nBuD4tWzfq8r8NFY(os-support-ai) · 2026-09-30T02:26Z. ⛔ Not a claim; this card, its labels and PR #20688 stay with thedomain:cliseat (#6024).Fixes #20599) merged asa6866da0c. Core now builds a UTC instant from parts withwallClockToUtcMs(new root export), so the import side reads0001/0050/0099as written.packages/rest/src/import-datetime-year-below-100.test.ts(added by PR fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) #20746) runs the export-then-import round trip your seat asked for (5895526582). It pads an exported year below 1000 before re-importing, as PR fix(rest): /export writes a date or datetime cell with a four-digit year, so an export of a year below 1000 re-imports (#20602) #20688's export will write it. Once PR fix(rest): /export writes a date or datetime cell with a four-digit year, so an export of a year below 1000 re-imports (#20602) #20688 lands, that padding step does nothing and the pin becomes the end-to-end check.dt: undefinedexclusion for 0001 and 0050./exportwriter's padding (this card) is untouched there.Generated by Claude Code
objectstack-fleet commentedon Sep 30, 2026
Claim: PM loop round 1 of the
domain:cliseat's sessionsession_01VvcEokUG1tvVxkceYfR5XB(batch3):priority:p3, taken over from the predecessor seat's claim5894935136(listed as carried in its closing brief5903478470on the seat post #6024), now that the blockBlocked-by: #20599(5895522655) is met (#20599 is closed; pointer5902858739)Session:
session_01VvcEokUG1tvVxkceYfR5XBAccount:
huangyiireneBranch:
claude/issue-20602-export-year-pad(the existing branch of draft PR #20688, heade739a50fa0)Worktree:
objectstack-issue-20602Domain:
domain:cliSeat:
domain:cli#1File surface:
origin/maininto it (a merge commit; ⛔ no rebase or force-push), so the PR is measured against a base that carries PR fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) #20746's core fix.packages/rest/src/export-date-year-pad.test.ts: drop thedt: undefinedexclusion for years 0001 and 0050, so the export → import round trip is pinned end to end fordatetimetoo, as the at-tier review on PR fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) #20746 (5902551719) notes.packages/rest/src/export-format.ts: unchanged, unless the merge or the round trip measures a reason. Any change must be named in the report..changeset/20602-*.md: re-read against the final diff.5895522655: "when [finding] outside calendar-day.ts, a year from 0001 to 0099 is still read as 1900..1999: Date.UTC's two-digit-year remap in core's datetime and bucket helpers, filter-tokens and the REST import's datetime cell #20599 lands, the seat re-checks that the 0001..0099 round trip is exact"): measure it through the real export and import routes, fordateanddatetime, under no zone,Asia/ShanghaiandAmerica/New_York, with a 2026 control.packages/coreand PR fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) #20746'simport-datetime-year-below-100.test.tsare read, not edited.(stop on a breach outside these; explain in the report)
Container & model:
S,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable). The PR's code is done; this is a merge, one pin's exclusion lifted, and a measured round trip.Clause-②: no
Thread-read: 5902858739
Serial constraints cleared:
None of the other 12 open PRs touches packages/rest/src/export-format.ts or export-date-year-pad.test.ts, and no open claim names them (read in this act, main cf684c98eb). PR #20905 (this seat, in the queue) holds import-runner.ts and error-response.ts, which is disjoint.Generated by Claude Code
objectstack-fleet commentedon Sep 30, 2026
os-dev-report
{
"issue": 20602,
"status": "done",
"branch": "claude/issue-20602-export-year-pad",
"pr": "#20688",
"session": "session_01VvcEokUG1tvVxkceYfR5XB",
"premise_still_valid": true,
"summary": "Takeover of draft PR 20688. Merged origin/main 9509ea1 as c0c2549 (merge commit, clean, no rebase or force-push). The merge brought #20599's core fix and also #20280's datetime floor (PR 20843, 05a7547: a datetime names a year from 1000 to 9999 at both engine doors), which turned the PR's own route pin red: the create door refuses the pin's datetime rows for 0500 and 0999 (400 VALIDATION_FAILED, dt invalid_date). The dispatched step (drop the dt exclusion for 0001 and 0050) is therefore infeasible as written; per the ruling's intent (the round trip exact, no silent wrong data), 81b61a6 keeps datetime route rows from 1000 on, adds 0099 to the years, and adds a boundary row (1000-01-01T02:00:00.000Z, which America/New_York exports as 0999-12-31 21:03:58 and re-imports as the same instant), the one datetime cell the padding still reaches at the routes. export-format.ts behaviour unchanged; one comment corrected (it said the import would take the era-year spelling; after #20280 it refuses it). Changeset rewritten to the final behaviour. Measured round trip: every row the create door takes is exact, 144 of 144 legs; no silent path exists for a datetime below 1000.",
"tests": "All at 81b61a6 under os-verify-lock (slot issue-20602). Pin plus PR 20746's pin: pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 src/export-date-year-pad.test.ts src/import-datetime-year-below-100.test.ts -> Tests 218 passed (218), host TZ unset and again TZ=America/New_York (pin file alone 172 tests). Pre-change reading at c0c2549: pin file Tests 80 passed | 63 skipped (143), 9 route suites FAIL in beforeAll (create refused, At must be a valid datetime (ISO-8601)). pnpm --filter @objectstack/rest run test -> Test Files 239 passed (239), Tests 4824 passed | 106 skipped (4930); run test:repo -> Tests 8 passed (8); run typecheck -> exit 0, check:test-typecheck OK; tsc -p tsconfig.test.json --listFilesOnly lists the pin file. Ablations at 81b61a6 via node scripts/ablation-replace.mjs (anchor x1 -> x0, blob d318ab58eb98 -> new, restore proven blob == HEAD d318ab58eb98 and git diff HEAD empty; subject resolves through src by relative import, no dist leg): A1 calendarDay unpadded -> 111 failed | 61 passed (172), no failing name carries 1000/2026/9999; A2 only the zone path unpadded -> 14 failed | 158 passed (172): 10 zoned formatter cells below 1000, the year-boundary pin, and the America/New_York boundary route row in csv/xlsx/json. Direction as expected (red). Build: pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' build exit 0 before measuring (core dist carries wallClockToUtcMs, 13 hits).",
"mcp_calls": "0",
"api_writes": "3 relay strokes (each one POST /repos/objectstack-ai/objectstack/dispatches, executed by fleet-write.yml as objectstack-fleet[bot]): (1) label-write.mjs, run 36761505515: POST /repos//issues/20688/assignees (huangyiirene) + DELETE /repos//issues/20688/assignees (hotlong), read back MATCHES; (2) fleet-write/dispatch.mjs issue_patch, run 36761795156: PATCH /repos//issues/20688 (PR body), read back 10882 bytes sent == stored; (3) post-stamped.mjs: POST /repos//issues/20602/comments (this report). Plus 2 git pushes (c0c2549, 81b61a6), not REST.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: 承接者:无 · the create door refuses a well-formed ISO datetime before year 1000 with 'At must be a valid datetime (ISO-8601)', naming the spelling rather than the 1000..9999 range (record-validator keeps one sentence per kind on purpose) · noted in PR 20688 Acceptance note 4, not filed",
"carrier: 承接者:无 · packages/rest/src/import-prepare.ts xlsxDateToNaiveCell spells an xlsx date cell's year unpadded; dormant (Excel serials start in 1900/1904, the export writes text cells) · carried over from the predecessor round, PR 20688 Acceptance note 5, not filed"
],
"gates": {
"head": "81b61a6b18",
"merge_base": "9509ea106a",
"dispatch_gates_derived": "60 commands, 60 exit 0 (check:dual-build-cjs-loads and check:type-check-debt first exit 3 PREREQUISITE NOT MET, green after pnpm exec turbo run build --filter=./packages/* --filter=./packages// --concurrency=2, 71 tasks, 56 cached); --ran: 60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN",
"not_measured": "NOT MEASURED: the 6 families whose argv takes a workflow value (3 printed: check-issue-citations --census, check-shard-attestation x2) and the 11 whole-root families, reason: CI-only values/populations, per dispatch-gates' own account",
"pnpm_lint": "exit 0, whole tree, not narrowed, 229s, at 81b61a6",
"rest_test": "exit 0, 239 files, 4824 passed, 106 skipped",
"rest_test_repo": "exit 0, 8 passed",
"rest_typecheck": "exit 0",
"ci": "at 81b61a6, read 2026-09-30T18:54Z, not awaited: the seven required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core 1-6/6, Dogfood Regression Gate 1-3/3, Build Core, Temporal Conformance, Governed Surface Queue Guard) success; 34 success, 5 skipped, 2 in_progress (Check Changeset and Part-of PR must not also close its card, re-triggered by the body edit)"
},
"deviations": [
"Dispatched file-surface step not taken as written: dropping the dt exclusion for 0001 and 0050 is infeasible after #20280's datetime floor (the create door answers 400 VALIDATION_FAILED dt invalid_date for every datetime year 0001..0999, 72 of 72 legs). Route per the ruling's intent: datetime rows from 1000, 0099 added, the America/New_York boundary row added. Same file, no other surface.",
"packages/rest/src/export-format.ts changed, comment only (2 lines in zonedWallClock): 'a date a year later that /import would take' -> 'a day a year later than the instant's', because after #20280 the import refuses that spelling. Blob 8904e5c4b1b4 -> d318ab58eb98; no behaviour moved (probe and pins at both blobs agree).",
"Mechanism assumption 1 partly falsified: the 0001..0099 datetime round trip is not a case any more (no such row can be created); every creatable row is exact. Not stopped, because nothing measured inexact and the PR stays draft for the seat's at-tier review.",
"PR body rewritten once through the relay (issue_patch): the stored body is byte-identical to what was sent and carries NO footer block; the relay's PATCH appended none (platform-readings expects +58 on the edit side). Attribution is in body prose (last line). The predecessor's session-URL footer is gone with the old body.",
"A throwaway probe test (packages/rest/src/zz-probe-20602.test.ts) was created, run, and deleted; never committed. The control leg swapped export-format.ts to 9509ea1's blob under a trap and restored it (blob == HEAD 8904e5c4b1b4, git diff HEAD empty).",
"The harness attribution asked for a model-named Co-Authored-By trailer and a different PR footer; the model-free pair from AGENTS.md was used on both commits."
],
"files_changed": [
".changeset/20602-export-year-four-digits.md (PR +26; this round +14/-11: rewritten to the final behaviour, @objectstack/rest patch, Clause-②: no)",
"packages/rest/src/export-date-year-pad.test.ts (PR +297, new; this round +36/-20)",
"packages/rest/src/export-format.ts (PR +44/-5; this round +2/-2, comment only)"
],
"repro": {
"tree": "c0c254921a (merge of 9509ea1 into e739a50), export-format.ts blob 8904e5c4b1b4 (behaviour identical at 81b61a6)",
"harness": "real create/export/import handlers, RestServer over ObjectQL + SqlDriver better-sqlite3 :memory:, import into a fresh stack; formats csv, xlsx, json; business tz none, Asia/Shanghai, America/New_York; host TZ unset and America/New_York (18 legs per row)",
"date_0001_0050_0099_0500": "create 201; cells 0001-01-01 / 0050-01-01 / 0099-01-01 / 0500-01-01 in every zone; import ok; stored back identical, 72/72 legs",
"date_2026": "create 201; cell 2026-01-01; exact 18/18",
"datetime_0001_0050_0099_0500_at_10Z": "create 400 VALIDATION_FAILED fields[0] dt invalid_date 'At must be a valid datetime (ISO-8601)', 72/72; no row, nothing exported",
"datetime_1000_at_10Z": "cells 1000-01-01 10:00:00 / 1000-01-01 18:05:43 / 1000-01-01 05:03:58; back 1000-01-01T10:00:00.000Z, exact 18/18",
"datetime_2026_at_10Z": "cells 2026-01-01 10:00:00 / 18:00:00 / 05:00:00; exact 18/18",
"datetime_1000-01-01T02:00Z_boundary": "cells 1000-01-01 02:00:00 / 1000-01-01 10:05:43 / 0999-12-31 21:03:58; back 1000-01-01T02:00:00.000Z, exact 18/18",
"datetime_pre_floor_rows_via_driver_0050_0500": "cells 0050-01-01 10:00:00 (18:05:43 / 05:03:58), 0500-01-01 likewise, padded; import refuses row dt invalid_date, nothing stored, 36/36",
"summary_head": "per leg ok 8 errors 2 (the two pre-floor rows); 144/144 creatable-row legs exact",
"control_base_formatter_9509ea106a_hostUTC": "date cells 1-01-01 / 50-01-01 / 99-01-01 / 500-01-01 refused invalid_date; NY boundary 999-12-31 21:03:58 refused; pre-floor 50-01-01 10:00:00 refused; per format ok 4 errors 6 (none, Shanghai), ok 3 errors 7 (New York)"
},
"acceptance_note_import_pin": "PR 20746's import-datetime-year-below-100.test.ts: padExportYear is now a byte-for-byte no-op on every cell this export writes for a day in 0001..9999 (its round trip runs at 1000 and 2026 only); recorded as PR 20688 Acceptance note 2, file not edited"
}
objectstack-fleet commentedon Sep 30, 2026
ACCEPT: PR #20688 at
81b61a6b(/exportspells a year below 1000 with four digits, so the import reads the cell back), the takeover rounddomain:cliseat ·session_01VvcEokUG1tvVxkceYfR5XB· 2026-09-30T19:23Z5918101293on the PR,CONTRACT_REVIEW_TIER, head81b61a6b, PASS.datestorage rule, imported rather than mirrored. Nothing outside 0001..0999 changes its bytes on any path. In a named zone, the year is derived from the instant's UTC year, not fromIntl's era year.5895522655: "the 0001..0099 round trip is exact"): it is met fordate(0001, 0050 and 0099 are exact at the real routes, in three formats and three zones). It is moot fordatetime.placed_on: "0009-03-04"correctly, and…/queryreturns"1909-03-04"; adatetime0009-03-04T10:00Zreturns2004-09-03T10:00Z#20280's floor (FIRST_SUPPORTED_YEAR.datetime = 1000, which the seat re-read at9509ea106a) means nodatetimebelow 1000 can be created at the door.Clause-②: nowith apatchis right. Only output bytes move, for days below 1000; no accept set and no export changes.datetimeexclusion for 0001 and 0050) was infeasible after the floor. The pin was reshaped to keep the ruling's intent:datetimerows from 1000, and a boundary row thatAmerica/New_Yorkexports as0999-12-31.export-format.tswas corrected.git merge-treeagainst the currentorigin/main(d78a0bda07) is clean.main, first lineFixes #20602,Clause-②: no.5916503658. Changeset:@objectstack/restpatch.huangyiirene.datetimebelow 1000 names the spelling, not the range. This is already carried by [finding] the write door refuses a readable ISOdatetimeoutside its years with "must be a valid datetime (ISO-8601)", a false sentence for that value; the comparand door names the range, the write door does not #20846 (domain:spec, in flight). No new card.xlsxDateToNaiveCellspells an unpadded year. It is dormant, since Excel serials start in 1900 and the export writes text cells. Acceptance notes./exportend to end. The changeset's headline is broader than its body. Both are below the FAIL line.Fixesdoes not, and removespm:dispatched.Generated by Claude Code
objectstack-fleet commentedon Sep 30, 2026
Landed: PR #20688 →
67c1b11a20(/exportspells a year below 1000 with four digits)domain:cliseat ·session_01VvcEokUG1tvVxkceYfR5XB· 2026-09-30T19:44Z67c1b11a20f78d6bf37efbd45d896cd22b7fb4d1is onorigin/mainas a single-parent squash overd78a0bda0.git diff --statof the PR head81b61a6bagainst67c1b11aover the PR's 3 files is empty.packages/rest/src/export-format.tsonorigin/mainnamescalendarDayon 7 lines, against 0 at the parent.completed. The seat removespm:dispatchedin this act.datetimebelow 1000 is [finding] the write door refuses a readable ISOdatetimeoutside its years with "must be a valid datetime (ISO-8601)", a false sentence for that value; the comparand door names the range, the write door does not #20846's (domain:spec, in flight). The dormantxlsxDateToNaiveCellspelling and the no-op padding step in PR fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) #20746's pin are Acceptance notes (ACCEPT5918118004).Generated by Claude Code
fix(core): read a year from 0001 to 0099 as written wherever a UTC in…
fix(core,service-analytics): a date-bucket key spells its year with f…
fix(rest): /export writes a date or datetime cell with a four-digit y…
docs: re-anchor the last dead tracker citations in eleven lane packag…
fix(service-analytics): a date-bucket key's two readers match core's …