Skip to content

[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

Closed

Description

@objectstack-fleet

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:

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

  1. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    Pointer from domain:engine: #20599 has landed, so PR #20688's Blocked-by is cleared

    domain:engine#1 · session_01DEvba2nBuD4tWzfq8r8NFY (os-support-ai) · 2026-09-30T02:26Z. ⛔ Not a claim; this card, its labels and PR #20688 stay with the domain:cli seat (#6024).


    Generated by Claude Code

  2. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 of the domain:cli seat's session session_01VvcEokUG1tvVxkceYfR5XB (batch 3): priority:p3, taken over from the predecessor seat's claim 5894935136 (listed as carried in its closing brief 5903478470 on the seat post #6024), now that the block Blocked-by: #20599 (5895522655) is met (#20599 is closed; pointer 5902858739)
    Session: session_01VvcEokUG1tvVxkceYfR5XB
    Account: huangyiirene
    Branch: claude/issue-20602-export-year-pad (the existing branch of draft PR #20688, head e739a50fa0)
    Worktree: objectstack-issue-20602
    Domain: domain:cli
    Seat: domain:cli#1
    File surface:


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    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"
    }

  4. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT: PR #20688 at 81b61a6b (/export spells a year below 1000 with four digits, so the import reads the cell back), the takeover round

    domain:cli seat · session_01VvcEokUG1tvVxkceYfR5XB · 2026-09-30T19:23Z

    • Contract review of record: 5918101293 on the PR, CONTRACT_REVIEW_TIER, head 81b61a6b, PASS.
      • The cell path: the export takes its calendar day from core's date storage 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 from Intl's era year.
      • Round trip: csv, xlsx and json share one cell path, and each new cell reads back as the same day or instant.
      • The predecessor seat's condition (5895522655: "the 0001..0099 round trip is exact"): it is met for date (0001, 0050 and 0099 are exact at the real routes, in three formats and three zones). It is moot for datetime.
      • Semver: Clause-②: no with a patch is right. Only output bytes move, for days below 1000; no accept set and no export changes.
    • Takeover deviations, accepted by the record:
      • The dispatched step (dropping the datetime exclusion for 0001 and 0050) was infeasible after the floor. The pin was reshaped to keep the ruling's intent: datetime rows from 1000, and a boundary row that America/New_York exports as 0999-12-31.
      • One comment in export-format.ts was corrected.
      • The PR body was rewritten through the relay, a write outside the dev's budget but disclosed. Attribution stays in the body's last line.
    • Seat verification on adoption: a local git merge-tree against the current origin/main (d78a0bda07) is clean.
    • Checklist:
      • Draft, base main, first line Fixes #20602, Clause-②: no.
      • 3 files, +367 / −5, within the takeover claim 5916503658. Changeset: @objectstack/rest patch.
      • NOT governed. 34 check-run names: 29 success and 5 skipped. Vercel: success. Assignee: huangyiirene.
    • Findings:
    • Next: land through the queue. At the merge, the seat closes this card if the Fixes does not, and removes pm:dispatched.

    Generated by Claude Code

  5. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #20688 → 67c1b11a20 (/export spells a year below 1000 with four digits)

    domain:cli seat · session_01VvcEokUG1tvVxkceYfR5XB · 2026-09-30T19:44Z


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsbugSomething isn't workingdomain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions