Skip to content

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

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20602-export-year-pad
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20602-export-year-pad

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20602
Clause-②: no

What changes

GET /api/v1/data/:object/export wrote a date or datetime cell's year unpadded, so a day in the years 0001 to 0999 left the export short (500-01-01, 999-12-31 21:03:58) and POST /api/v1/data/:object/import, which reads a four-digit year only, refused the platform's own file.

One source file changes, packages/rest/src/export-format.ts, on the three paths triage named:

  • formatDate's date branch, utcWallClock and zonedWallClock now take the day from one private helper, calendarDay.
  • The pad rule: core's temporalStorageForm, date rule, imported from @objectstack/core (not mirrored). It pads 0001..0999 and leaves a year outside 0001..9999 unpadded. @objectstack/rest already depends on @objectstack/core; no packages/core/** edit.
  • zonedWallClock no longer reads the year from Intl's year part, which is an ERA year (year 0, 1 BC, reads 1): padding it would spell 0001-01-01T03:00:00Z in New York as 0001-12-31, a day a year later than the instant's. The zone's year is the instant's UTC year, plus one when the zone has reached January while UTC is in December, minus one the other way round. The day is built with setUTCFullYear, never Date.UTC.
  • packages/rest/src/rest-server.ts is untouched; the pins drive the real routes.

This round: merge of main, and what it changed for this PR

The seat held this PR behind #20599 (answer 5895522655, option B). #20599 has landed (PR #20746, a6866da0c), and this round merged origin/main 9509ea106a into the branch as c0c254921a (a merge commit; no rebase, no force-push; git merge-tree was clean).

main also carries PR #20843 (05a7547c9f, #20280): a datetime names a year from 1000 to 9999 at both engine doors, and a date keeps 0001..9999. That moved this PR's own pin: at c0c254921a the route layer of export-date-year-pad.test.ts went red (80 passed | 63 skipped, every route beforeAll failed), because the create door now refuses the pin's datetime rows for 0500 and 0999 (400 VALIDATION_FAILED, field dt, code invalid_date). So the dispatched step, "drop the dt: undefined exclusion for 0001 and 0050", cannot be taken as written: those rows cannot be created at all. What 81b61a6b18 does instead:

  • The pin (packages/rest/src/export-date-year-pad.test.ts): the route rows before year 1000 carry a date only; 0099 joins the years (formatter census and routes); a boundary row pins the one datetime cell the export's padding still reaches at the routes, the instant 1000-01-01T02:00:00.000Z, which America/New_York reads on 0999-12-31 (exported 0999-12-31 21:03:58, re-imported as the same instant). The module note says why.
  • Two comments (one in export-format.ts, one in the pin) no longer say the import "would take" the era-year spelling: after driver-sql on MySQL reads a year 0..99 back a century late — REST create stores placed_on: "0009-03-04" correctly, and …/query returns "1909-03-04"; a datetime 0009-03-04T10:00Z returns 2004-09-03T10:00Z #20280 the import refuses it, as the write doors do. Comment-only; no behaviour in export-format.ts moved (its blob went 8904e5c4b1b4 to d318ab58eb98 on that one comment).
  • The changeset no longer says a datetime 0500-01-01 10:00:00 re-imports: it names the date cell and the zone-boundary datetime cell that do, and says a datetime stored before year 1000 exports padded and is refused by the import, as by the write doors.

Measured, on the merged tree

Harness: the real POST /api/v1/data/:object, GET /api/v1/data/:object/export and POST /api/v1/data/:object/import handlers of a RestServer over ObjectQL plus SqlDriver (better-sqlite3 :memory:) and the real metadata protocol, driven in-process, business timezone from the resolved ExecutionContext, into a fresh stack for the import. A throwaway probe (deleted, not in the diff), at c0c254921a, CSV, xlsx and JSON, business timezone none / Asia/Shanghai / America/New_York, host TZ unset (UTC) and TZ=America/New_York.

row create door exported cell (none / Shanghai / New York) /import stored back
date 0001, 0050, 0099, 0500 (-01-01) 201 0001-01-01 etc., padded, all zones ok identical, 72 of 72 legs
date 2026 201 2026-01-01 ok identical
datetime 0001, 0050, 0099, 0500 at 10:00Z 400 VALIDATION_FAILED, dt invalid_date (72 of 72) no row none none
datetime 1000 at 10:00Z 201 1000-01-01 10:00:00 / 18:05:43 / 05:03:58 ok identical
datetime 2026 at 10:00Z 201 2026-01-01 10:00:00 / 18:00:00 / 05:00:00 ok identical
datetime 1000-01-01T02:00:00.000Z 201 1000-01-01 02:00:00 / 1000-01-01 10:05:43 / 0999-12-31 21:03:58 ok identical
datetime 0050, 0500 at 10:00Z written through the driver (a row stored before the floor) not the door 0050-01-01 10:00:00, 0500-01-01 10:00:00 (and zone clocks), padded row refused, dt invalid_date (36 of 36) nothing stored

Every row the create door takes round-trips exactly: 144 of 144 legs. The seat's concern for this landing order, a padded datetime 0001..0099 stored 1900 years late with no error, has no path left: the create door refuses such a datetime, and a row stored before the floor exports padded and is refused loudly by the import, never stored.

Control, the same probe with export-format.ts at 9509ea106a (the merge's main parent; blob 5791dbaeb33b proven on disk, restored to HEAD 8904e5c4b1b4 with git diff HEAD empty): the date cells export 1-01-01, 50-01-01, 99-01-01, 500-01-01, the New York boundary cell 999-12-31 21:03:58, the pre-floor rows 50-01-01 10:00:00; the import refuses every one as invalid_date. Per format: ok 4, errors 6 with no zone and in Asia/Shanghai, ok 3, errors 7 in America/New_York; at the head ok 8, errors 2 everywhere (the two pre-floor rows).

Ablations at 81b61a6b18, each through node scripts/ablation-replace.mjs (anchor hit once, blob moved, restore proven: blob d318ab58eb98 equals HEAD and git diff HEAD empty); the subject resolves through src/ by relative import, so no dist leg:

  • calendarDay returns the unpadded spelling: 111 failed | 61 passed (172); no failing test names 1000, 2026 or 9999; the failures are the below-1000 formatter cells and every route row whose date is before 1000, the boundary row included.
  • Only the zone path unpadded (zonedWallClock spells the day's getUTCFullYear() unpadded, the era-year correction kept): 14 failed | 158 passed (172): the ten zoned formatter cells before 1000 (Asia/Shanghai and America/New_York), the year-boundary pin, and exactly the boundary route row in America/New_York in CSV, xlsx and JSON. That is the new route row's datetime half biting on its own.

Earlier readings by the predecessor round (head e739a50fa0, base 6981abfd26), still describing export-format.ts as it is: H0 (the base exported 500-01-01 and 500-01-01 10:00:00 and the import refused the row) and the one-shot H1 census of 1050 formatCellValue cells (years 1000, 2026, 9999 and +010000 byte-identical except 5 cells whose zone day is 0999-12-31, now padded; 0001..0999 padded; out-of-range zoned cells now spell the rule's year instead of the era year).

Tests

packages/rest/src/export-date-year-pad.test.ts, 172 tests: the formatter census (years 0001, 0050, 0099, 0500, 0999, 1000, 2026, 9999; date and datetime; zones none / UTC / Asia/Shanghai / America/New_York / unknown), the 2026 control's exact cells, Date and epoch-ms inputs, the year-boundary pin, and the route round trip per row, format and business timezone (the xlsx leg also asserts text cells).

All at 81b61a6b18, under scripts/pm/os-verify-lock.sh:

  • 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 under TZ=America/New_York.
  • pnpm --filter @objectstack/rest run test: Test Files 239 passed (239), Tests 4824 passed | 106 skipped (4930).
  • pnpm --filter @objectstack/rest run test:repo: Tests 8 passed (8).
  • pnpm --filter @objectstack/rest run typecheck: exit 0, check:test-typecheck: OK; tsc -p tsconfig.test.json --listFilesOnly lists the pin file.
  • The public surface of @objectstack/rest is byte-unchanged (calendarDay is private), so no downstream consumer owes a test.

Gates

At 81b61a6b18 (merge base 9509ea106a):

  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 60 commands, all exit 0. check:dual-build-cjs-loads and check:type-check-debt first exited 3 (PREREQUISITE NOT MET, no dist) and are green after pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2. --ran: 60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN.
  • NOT MEASURED locally, by the tool's own account: the six families whose argv takes a value from the workflow, and the eleven whole-root families; CI runs them.
  • pnpm lint (the whole tree, not narrowed): exit 0, 229 s.

Acceptance notes

  1. The landing order the seat set is met, and the round trip it asked about is not a case any more. [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 has landed, and driver-sql on MySQL reads a year 0..99 back a century late — REST create stores placed_on: "0009-03-04" correctly, and …/query returns "1909-03-04"; a datetime 0009-03-04T10:00Z returns 2004-09-03T10:00Z #20280's floor means a datetime in 0001..0999 is refused at the create door, so no such row exports. Every row the doors take round-trips exactly (table above).
  2. packages/rest/src/import-datetime-year-below-100.test.ts (PR fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) #20746, read-only here): its padExportYear step is now a byte-for-byte no-op on every cell this export writes for a day in 0001..9999, so on every cell its own round trip exports. Its round trip runs at 1000 and 2026 only (the floor), so it never carried a year below 1000 through the export; this PR's boundary row is the end-to-end datetime check below 1000 in a zone. Not edited.
  3. Out-of-range years in a business timezone. A datetime whose zone day falls in year 0 or before spells the rule's year (0-12-31) instead of the era year (1-12-31). The import refuses both, and the write doors refuse such years.
  4. Observed, not filed: the create door refuses a well-formed ISO datetime before year 1000 with the sentence "At must be a valid datetime (ISO-8601)", which names the spelling rather than the 1000..9999 range; the record validator chose one sentence per kind on purpose. No carrier.
  5. Not changed, dormant: packages/rest/src/import-prepare.ts xlsxDateToNaiveCell spells an xlsx date cell's year unpadded. An xlsx date cell is an Excel serial (from 1900, or 1904), and the export writes text cells, so no workbook reaches it with a year below 1000.

Taken over in this round by session session_01VvcEokUG1tvVxkceYfR5XB (claim 5916503658); the branch's first two commits are the predecessor seat's (session session_local_1d2a197c-c20e-4e90-9be8-413d4d432289).

hotlong and others added 2 commits September 30, 2026 01:19
…ear, so a year below 1000 re-imports

Every export date and datetime cell now takes its day from core's
temporalStorageForm date rule (imported), which pads 0001..0999 and
leaves a year outside 0001..9999 unpadded. The business-timezone path
reads the zone's year from the instant, never from Intl's era year.

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 29, 2026
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 4 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/protocol/kernel/i18n-standard.mdx (via formatDate (symbol, a top-level function))
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 6f1f1c1035b581ce37b957b5ea18bed4a0bb270b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5eda1f9dcbb3059f993386592e5cc903d2902e38 — the merge of head 81b61a6b188d20beda1d5aa79e2bc7df213e2dfa into base 6f1f1c1035b581ce37b957b5ea18bed4a0bb270b, 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 5eda1f9dcbb3059f993386592e5cc903d2902e38 && git checkout 5eda1f9dcbb3059f993386592e5cc903d2902e38
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6f1f1c1035b581ce37b957b5ea18bed4a0bb270b 81b61a6b188d20beda1d5aa79e2bc7df213e2dfa && git checkout -B drift-repro 6f1f1c1035b581ce37b957b5ea18bed4a0bb270b && git merge --no-ff 81b61a6b188d20beda1d5aa79e2bc7df213e2dfa

node scripts/docs-audit/affected-docs.mjs --json 6f1f1c1035b581ce37b957b5ea18bed4a0bb270b

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6f1f1c1035b581ce37b957b5ea18bed4a0bb270b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…port-year-pad

Brings in PR #20746 (core builds a UTC instant from parts with
wallClockToUtcMs), so the export -> import round trip for datetime years
0001..0099 can be measured against a base that reads the padded year right.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…floor

After merging main, the create door refuses a datetime before year 1000
(the floor landed with PR #20843), so the pin's route rows for 0500 and 0999
could no longer be created and the whole route layer went red. The route rows
before 1000 now carry a date only, 0099 joins the years, and a boundary row
pins the one datetime cell the export's padding still reaches at the routes:
1000-01-01T02:00:00.000Z, which America/New_York reads on 0999-12-31.

Two comments and the changeset no longer say the import would take a padded
datetime before year 1000: it refuses one now, as the write doors do.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot assigned huangyiirene and unassigned hotlong Sep 30, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 81b61a6b188d20beda1d5aa79e2bc7df213e2dfa
Local-runs: none

Inputs: card #20602 (body and all seven comments: triage 5886127452, the predecessor claim 5894935136 and report 5895487340, the seat answer 5895522655, the engine pointer 5902858739, the takeover claim 5916503658 and its report 5917691304), PR #20688 (body, the three-file list, git diff 9509ea106a..refs/review/pr-20688, the branch log: 9415820, e739a50, the merge c0c2549 with parents e739a50 and 9509ea1, then 81b61a6), PR #20746 with its at-tier review 5902551719, PR #20843, and the head's check-runs. Read-only: git reads by sha, nothing built, run or re-run. Every instant below was traced by hand through ECMA-262 (Date.parse of a four-digit ISO year never remaps; only Date.UTC and the multi-argument constructor do) and the tz database's local mean time before each zone's first transition (Asia/Shanghai +08:05:43, America/New_York −04:56:02).

Check-runs on the head, read by this act (2026-09-30, after the dev's 18:54Z reading): 41 runs, 34 distinct names after collapsing to latest-per-name — 29 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke), 0 failure, 0 in_progress. The seven required contexts are all success: Lint & Repo Gates, TypeScript Type Check, Test Core (and 1/6 to 6/6), Dogfood Regression Gate (and 1/3 to 3/3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. The two the dev saw in_progress (Check Changeset, Part-of PR must not also close its card) have since completed success. The file list holds no governed path.

① Derived judgments

  1. calendarDay (export-format.ts:252-254) is core's date storage rule imported, not mirrored. Right. temporalStorageForm(day, 'date') on a valid Date returns ${yyyy}-${m}-${d} with the year padStart(4, '0') from FIRST_SUPPORTED_YEAR.date (1) up and String(y) below it (packages/core/src/utils/temporal-storage-form.ts:229-233); the arm that hands the value back unchanged is reached only for null or an Invalid Date, which every caller has already excluded (toDate :327-334 returns null for an Invalid Date and formatDate :366 passes the raw value through; the zoned day is built from finite Intl parts), so String(…) never spells null or Invalid Date into a cell. packages/core/** is byte-identical between the merge-base and the head (empty diffstat), @objectstack/core is already workspace:* in packages/rest/package.json:31 and already imported by import-coerce.ts:39 and import-runner.ts:8.
  2. formatDate's date branch (:367). Right. For 0001..0999 the cell is padded; for 1000..9999 padStart is a no-op, so the bytes equal the old ${getUTCFullYear()}-…; for year 0, a negative year and a year past 9999 the rule spells String(y), which is exactly what getUTCFullYear() spelled (0-01-01, -1-01-01, 10000-01-01). Nothing outside 0001..0999 moves on this branch.
  3. utcWallClock (:257-262). Right. ymd through calendarDay, hms unchanged; reached with no zone, 'UTC' and an unknown zone through wallClock's fallback (:323-325). Same byte analysis as item 2.
  4. zonedWallClock (:286-314) stops reading Intl's year part and derives the zone's year from the instant's UTC year. Right, and it moves exactly the cells the changeset names. An offset in the tz database is under a day (LMT included), so the zone's calendar day is within one day of the UTC day and its year differs only across a December/January boundary: month === 1 && utcMonth === 12 adds one, month === 12 && utcMonth === 1 subtracts one, else equal (:307-309). The day is then built with setUTCFullYear on new Date(0) (:310-312), which does not remap 0..99. Traced: 1000-01-01T02:00:00.000Z in America/New_York is local 0999-12-31 21:03:58, year 1000 − 1 = 999, cell 0999-12-31 21:03:58 (the changeset's figure); 0999-12-31T23:30:00.000Z in Asia/Shanghai is 1000-01-01 07:35:43, year 999 + 1; 0001-01-01T03:00:00.000Z in New York is local 0000-12-31 22:03:58, whose Intl year part is the era year 1 (1 BC) while the derived year is 0, cell 0-12-31 22:03:58. For every year 1..9999 Intl's year part equals the ISO year, so the derived year equals what the old code read and the only zoned cells whose bytes change are days below 1000 (padded) and days in year 0 or before (era year to rule year, declared). exportContentDisposition (:104-106) reads the same helper; for any real now the filename stamp is byte-identical.
  5. One cell path, three formats, and the import reads each cell back as the same value. Right. CSV and xlsx go through formatRowCells (:462-469), JSON through formatRowForJson (:476-493), both into formatCellValue (:450-451). A date cell YYYY-MM-DD is read by readIsoTemporalCell (import-coerce.ts, ISO_TEMPORAL_CELL, a four-digit year kept as written) and parseDateCell returns cell.day, which the write door accepts for 0001..9999. A datetime cell YYYY-MM-DD HH:mm:ss is read as a wall clock and handed to core's zonedWallClockToUtcMs, which after fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) #20746 reads the year as written, so the instant returns when the import runs in the export's business timezone (the 【缺陷】数据导出(CSV/XLSX)日期时间列硬编码按 UTC 渲染,与界面时区不一致(@objectstack/rest export-format.ts formatDate) #8373/Bulk import reads a naive datetime cell in the process-local timezone, so an export/edit/re-import round trip shifts the instant #8485 contract, not moved here). The xlsx leg writes text cells (the pin asserts typeof d === 'string'), so no Excel serial is in the path. Two pre-existing limits this PR neither opens nor closes: the HH:mm:ss form drops sub-second precision, and an import under a different business timezone names a different instant.
  6. Public surface. Right. @objectstack/rest's exports are unchanged (calendarDay is module-private; nothing added or removed); no packages/core edit; rest-server.ts untouched, as the takeover claim's read-only line required.
  7. (b) The floor, read at the merge-base 9509ea106a. The dev's reading is right. packages/core/src/utils/temporal-storage-form.ts:167 FIRST_SUPPORTED_YEAR = { date: 1, datetime: 1000 }, :193-206 isOutsideTemporalYearRange takes the UTC year of the instant the datetime rule reads; temporal-comparand.ts:298-317 isUninterpretableTemporalComparand asks it; the write door asks that at packages/objectql/src/validation/record-validator.ts:1284-1286 (fail('invalid_date', …, 'invalid_datetime')), which covers insert, update and validate and so POST /api/v1/data/:object, PATCH, and every row of POST /import (PR fix(core,objectql)!: a datetime names a year from 1000 to 9999 at both engine doors; a date keeps 0001..9999 (#20280) #20843's body; its pin import-datetime-year-below-100.test.ts:193 at this head). So a datetime in 0001..0999 cannot be created at the door in any spelling, and the dispatched step (drop the dt: undefined exclusion for 0001 and 0050) had no row to create: infeasible as written.
  8. (b) The reshaped pin still proves the ruling's intent — no silent wrong data on export → import. Right. date rows 0001, 0050, 0099, 0500, 0999 (YEARS, ROWS) round-trip to identity at the real routes in csv/xlsx/json under no zone, Asia/Shanghai and America/New_York; datetime rows 1000, 2026, 9999 likewise; the boundary row 1000-01-01T02:00:00.000Z is the one creatable datetime whose cell the padding touches, and its dtDay per zone (0999-12-31 in New York, 1000-01-01 elsewhere) is what item 4 traces. Routes by which a datetime below 1000 still reaches the export: (i) a row stored before the floor or written through a driver — it exports padded (the formatter census pins YYYY-01-01 10:00:00 for 0001..0999 in every zone), the import reads year 50 as written (fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) #20746) and the write door behind it refuses invalid_date, storing nothing: loud, pinned at the import door by PR fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) #20746's file (STORED filter :95, :193, :206) and measured end to end by the dev (36 of 36 refused). (ii) MySQL presents a stored 0001..0099 datetime a century late before the export ever sees it (ADR-0053 D-F2, kept by ruling) — not a route this PR opens, and the 19xx cell it exports is byte-unchanged. No silent shift remains at this head. One residue, below the FAIL line: the driver-written pre-floor row is measured in the probe but pinned in halves (the formatter census for the cell, PR fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) #20746's file for the import door), not through /export on a driver-written row; the seat may add one such row when the pin is next touched.
  9. (c) The takeover's comment in zonedWallClock (:300-306). True. git show 81b61a6b18 -- packages/rest/src/export-format.ts is two comment lines (blob 8904e5c4b1 to d318ab58eb): "a date a year later that /import would take" became "a day a year later than the instant's". The old clause is false since driver-sql on MySQL reads a year 0..99 back a century late — REST create stores placed_on: "0009-03-04" correctly, and …/query returns "1909-03-04"; a datetime 0009-03-04T10:00Z returns 2004-09-03T10:00Z #20280 (0001-12-31 22:03:58 as a datetime cell is now refused by the write door behind the import); the new one states only the day, which item 4's trace confirms (the instant's zone day is 0000-12-31; the era-year spelling padded is 0001-12-31, one year later). "An offset is under a day" holds for every tz-database zone and is what the ±1-year rule rests on.
  10. (d) .changeset/20602-export-year-four-digits.md, rewritten in the takeover. Accurate against the head, and it claims nothing the code does not deliver. Each sentence: 0500-01-01 exported as 500-01-01 in all three formats (item 2); 1000-01-01T02:00:00.000Z in America/New_York as 999-12-31 21:03:58 at the base (item 4, with Intl's unpadded year); the import reads a four-digit year only (ISO_TEMPORAL_CELL, YEAR_FIRST_CELL); the new cells 0500-01-01 and 0999-12-31 21:03:58 read back as the same day and instant (items 5, 8); 1000..9999 byte for byte (items 2-4); the clock unchanged; a stored-before-floor datetime exports padded and is refused (item 8); year 0 or before spelled as the rule spells it, refused either way. Frontmatter '@objectstack/rest': patch; Clause-②: no in the body matches the PR body's line 2 and the claim. One imprecision in the headline only: "so an export of a year from 0001 to 0999 re-imports" is true of a date and of the zone-boundary datetime, not of a stored-before-floor datetime, which the body's second paragraph states; a summary wording, below the FAIL line — the seat may tighten it to "a date, or a datetime whose business-timezone day…".

② Semver level

.changeset/20602-export-year-four-digits.md: @objectstack/rest patch, Clause-②: no, no arm. Right. The criterion (does the card widen an accept set or enlarge the public surface): neither. The export's input domain is unchanged, the import's accept set is unchanged (the reader is untouched), and no export is added or removed (① item 6). What moves is the bytes of an output for days below 1000 and for zone days in year 0 or before — shipped output, not a published accept set — and it is the correction of a shipped defect in a released package, which AGENTS.md's changeset rule sets at patch. No arm applies: nothing previously accepted is refused (no narrowing) and nothing is newly accepted (no widening). A consumer that parsed the old unpadded cell is told in the changeset's first paragraph; that cell named no day the platform's own reader took. Check Changeset is success on the head.

③ Boundary flags

  • Dispatched step not taken (drop the dt exclusion for 0001 and 0050). Answered — accepted; ① items 7 and 8: infeasible at the create door since driver-sql on MySQL reads a year 0..99 back a century late — REST create stores placed_on: "0009-03-04" correctly, and …/query returns "1909-03-04"; a datetime 0009-03-04T10:00Z returns 2004-09-03T10:00Z #20280, verified at the merge-base by file and line, and the reshaped pin (datetime rows from 1000, 0099 added for date, the America/New_York boundary row) keeps the ruling's intent whole.
  • export-format.ts changed, comment only. Answered — accepted; ① item 9: two comment lines, named in the report as the takeover claim's surface line required; behaviour identical at both blobs.
  • Mechanism assumption partly falsified (the 0001..0099 datetime round trip is not a case). Answered — accepted. The seat's condition in 5895522655 ("re-check that the 0001..0099 round trip is exact") is met for date (0001, 0050, 0099 exact at the routes in three formats and three zones) and is moot for datetime (no such row can be created; a pre-floor row is refused loudly, ① item 8). The concern the seat held the PR for — a padded 0001..0099 datetime stored 1900 years late with ok — has no path at this head.
  • PR body rewritten through the relay; the predecessor's session-URL footer block is gone. Answered, with one note. The body's claims were checked above and hold. The stroke sits outside the dev's four-write budget (os-dev.md: the dev writes the body once at pr_create and never PATCHes; a later change is named in the report for the seat to write), and it is disclosed in api_writes and deviations, so it is an audited deviation, not a hidden one. Attribution is durable in the body's last line, which AGENTS.md accepts ("Durable attribution lives in body prose or a comment"); the body carries no appended footer, so the seat may send the session-URL form once if it wants it. Below the FAIL line.
  • Throwaway probe created and deleted. Answered — accepted: the file list has three entries and the net diffstat three files; no probe path is in the diff.
  • Harness attribution declined in favour of the model-free pair. Answered — accepted: 81b61a6b18 carries Claude-Session: and Co-authored-by: Claude with no model identifier.
  • Out-of-scope finding: the create door refuses a well-formed ISO datetime before 1000 with "must be a valid datetime (ISO-8601)" (validation-message.ts:104), naming the spelling, not the range. Escalated to the seat, with the answer: it already has a card. [finding] the write door refuses a readable ISO datetime outside 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 (open, filed 2026-09-30T11:37Z during PR fix(core,objectql)!: a datetime names a year from 1000 to 9999 at both engine doors; a date keeps 0001..9999 (#20280) #20843's round: the write door refuses a readable ISO datetime outside its years with a false sentence for that value; the comparand door names the range, the write door does not). The dev's "not filed, carrier none" is per the dev rule (no dedupe by the dev); the seat's act is to append this round's measurement as a reach on [finding] the write door refuses a readable ISO datetime outside 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 if it is not already there (POST /api/v1/data/:object, 400 VALIDATION_FAILED, dt invalid_date, 72 of 72 legs) and to open no new card.
  • Out-of-scope finding: xlsxDateToNaiveCell (import-prepare.ts:97-103) spells a year unpadded, dormant. Answered — accepted. It is reached only from xlsxCellToString :117 on a Date cell, which ExcelJS produces from an Excel date serial (1900 or 1904 epoch), and the export writes text cells (the pin asserts it), so no reach at a public door was measured and none is plausible for a year below 1000. Under the filing gate (a class plus a measured reach:) a dormant site is Acceptance-notes only; carried since the predecessor round, still right.
  • Acceptance note on PR fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) #20746's import-datetime-year-below-100.test.ts (read-only here). Answered — accepted. padExportYear (:218) rewrites only a one- to three-digit year at the start of the cell, so on every cell this export now writes for a day in 0001..9999 it is a byte-for-byte no-op, and its round trip runs at 1000 and 2026 only (:221), where it was a no-op even before. Leaving the file unedited is right under the takeover claim; the dead step is a cleanup for whoever next touches that file, not a card.
  • open_questions: the takeover report lists none.

Implemented-by: claude/issue-20602-export-year-pad
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 19:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 67c1b11 Sep 30, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20602-export-year-pad branch September 30, 2026 19:43
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

Development

Successfully merging this pull request may close these issues.

[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

3 participants