Skip to content

fix(rest)!: /import reads a date, datetime or time cell only in ISO 8601, the export shape or a year-first date, on a real day, with a four-digit year (#20534) - #20601

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-20534-parse-date-cell-family
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-20534-parse-date-cell-family

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #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:

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 (Bulk import reads a naive datetime cell in the process-local timezone, so an export/edit/re-import round trip shifts the instant #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 [#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 #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 #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 fix(rest)!: /import reads a comma in a number cell only as a thousands group, refusing the rest (#20497) #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.

Generated by Claude Code

hotlong and others added 5 commits September 29, 2026 13:22
…601 or the export shape, on a real day (#20534)

parseDateCell refuses an impossible day on every branch instead of
rolling it over, refuses every text cell that is neither ISO 8601 nor
the export's YYYY-MM-DD HH:mm:ss instead of reading it in the server
process's zone and month-first, and pads a date's year to four digits.

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
… a real day, under two host zones (#20534)

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/l 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

11 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
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 c96beb2707e9d12fbc8abcf985c6dc93f957ef2c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4766625cdaea351988c0e3d20f4f01cb0cd1a72a — the merge of head 3b2e47349e98346c72d3f3cf9dfda3ed1c6eb8bf into base c96beb2707e9d12fbc8abcf985c6dc93f957ef2c, 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 4766625cdaea351988c0e3d20f4f01cb0cd1a72a && git checkout 4766625cdaea351988c0e3d20f4f01cb0cd1a72a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c96beb2707e9d12fbc8abcf985c6dc93f957ef2c 3b2e47349e98346c72d3f3cf9dfda3ed1c6eb8bf && git checkout -B drift-repro c96beb2707e9d12fbc8abcf985c6dc93f957ef2c && git merge --no-ff 3b2e47349e98346c72d3f3cf9dfda3ed1c6eb8bf

node scripts/docs-audit/affected-docs.mjs --json c96beb2707e9d12fbc8abcf985c6dc93f957ef2c

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 76e7fb3379a45e0a6feb8559e7099d63a6a1f52a
Local-runs: none

PR #20601 for card #20534, judged against triage's binding answer A (5883900872): a text cell that is neither ISO 8601 nor the export shape is refused per row, an impossible day is refused, no rollover, no host-zone reading, no month-first guess, xlsx Date cells unaffected, the year padded on every date branch. Inputs: the card body and all 7 comments, the PR body, its 6-file list, the net diff against main at this head, and the check-runs on this head (read 2026-09-29T06:13Z). Repository sources cited below were read at the checkout, not run.

Check-runs on this head at the read: 34 runs, one per name. 30 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 1 in_progress: Lint & Repo Gates (ESLint, doc anchors, ADR anchors, migration registry, slot-lookup ratchet). No verdict is inferred for the running one. Green includes Check Changeset (which carries check-adr-0087-registration and check-changeset-no-major; its only skip condition is a skip-changeset label, which this PR does not carry), Test Core 1/6 to 6/6, Temporal Conformance (live PG + MySQL), all four Type Check jobs, Dogfood Regression Gate 1/3 to 3/3, Dogfood Verify CLI, and the three claim guards (same issue, same single-writer path, card claims this branch).

① Derived judgments

Every accept-set and public-surface change the diff implies, each judged:

  1. The text grammar of parseDateCell narrows to ISO 8601 or the export shape — right. ISO_TEMPORAL_CELL admits YYYY-MM-DD; YYYY-MM-DDTHH:MM[:SS[.f]] with Z, +HH:MM/-HH:MM, +HHMM/-HHMM or nothing; YYYY-MM-DD HH:MM[:SS[.f]] zone-naive; plus a bare HH:MM[:SS] for a time cell. The regex admits a space-plus-zone spelling syntactically, but readIsoTemporalCell refuses it (sep must be T when a zone is present), so the admitted set is exactly the write door's ISO_DATETIME_WRITE_FORM (packages/objectql/src/validation/record-validator.ts, record validator: the temporal write arms trust Date.parse — date 2026-02-30 is stored verbatim (500 on PostgreSQL), datetime 2026-02-30T10:00:00Z rolls over to March 2, and a non-ISO datetime is read in the host zone #20525). Every other text cell returns undefined. This is the ruled set, one grammar with the write door.

  2. An impossible day is refused on every branch, by arithmetic — right. namesRealCalendarDay (month 01..12, day to the month's length, Feb 29 only in a leap year; the write door's private rule copied) runs before any Date call. The only Date.parse left is on a zone-bearing ISO cell whose day already passed that check, and a bare day into a datetime is built from ${day}T00:00:00.000Z, not Date.UTC(y, mo-1, d). Nothing rolls over.

  3. No host-zone reading — right. The new Date(s) fallback is deleted on all three kinds. A zone-naive wall clock still goes through core's zonedWallClockToUtcMs(wall, timezone) (business zone, else UTC; Bulk import reads a naive datetime cell in the process-local timezone, so an export/edit/re-import round trip shifts the instant #8485 unchanged); a zone-bearing cell is Date.parse of an ISO string, the same instant on every host. The time branch is included: its fallback was the same new Date(s), it sits in the claimed function, and the changeset names time fields. Right, and a declared deviation (③.1).

  4. No month-first guess — right. No locale, prose, RFC 1123 or slash spelling matches the grammar; none is read at all.

  5. Year padded on every date branch — right. A text cell keeps its own four digits (the grammar requires \d{4} and cell.day is the cell's text); a Date and a zone-bearing instant take core's temporalStorageForm(…, 'date'), imported from @objectstack/core (read-only for the claim; it pads 0001..0999 per its docblock). Year 0000 passes the grammar and is left to the write door's 0001..9999 range (temporal values outside the years a four-digit text or a backend holds: a datetime comparand for year 10000 or −1 misorders on memory/SQLite and 500s on PostgreSQL; a date in year 0000 500s on PostgreSQL; a date write stores +010000-… verbatim #20264), a refusal there as it was at base (0-01-01), never a stored value.

  6. A bare day into a datetime for years 0001..0099 moves from the 1900s to its own year — right. 0001-01-01 was 1901-01-01T00:00:00.000Z via Date.UTC; it is 0001-01-01T00:00:00.000Z now. A value change beyond the padding, and the old value was silently wrong; the changeset names it.

  7. Seat question (1): does any previously admitted form move to a different stored value, other than the year padding and the 1900s fix? No. Checked branch by branch against the removed code: ISO T forms and space wall-clock forms take the same wall path (fraction truncation identical: the old regex captured three digits, the new slices three); a zone-bearing cell is the same Date.parse instant; a date from an instant is the same UTC calendar day; a time from a wall clock is the typed clock and from an instant the UTC clock; T24:00:00Z is the next midnight as before; a Date argument reads as before save the padding. The dev's 588-row census (0 refused-to-admitted, 246 admitted-to-refused, 28 value changes all padding or 1900s) is consistent with this reading.

  8. Seat question (2): do the export shape and xlsx Date cells round-trip unchanged? Yes. The export writes YYYY-MM-DD, YYYY-MM-DD HH:mm:ss and HH:MM:SS; all three are in the admitted set, and the route pin drives GET /export into POST /import over SqlDriver under both host zones. An xlsx Date cell is rendered by import-prepare.ts's untouched xlsxDateToNaiveCell as YYYY-MM-DD HH:mm:ss, admitted and read in the business zone as before; pinned.

  9. Seat question (3): do the refusals reuse import_invalid_date / import_invalid_datetime / import_invalid_time with no new code? Yes. parseDateCell returns undefined and the untouched caller in coerceRow emits invalid_date with the existing three keys. The diff adds no error code, no message key, no date-format option.

  10. Public surface — right. @objectstack/rest exports coerceRow (not parseDateCell); coerceRow narrows with the door and the changeset says so. No export is added, removed or renamed; packages/core, packages/objectql, packages/spec and packages/drivers are untouched (file list).

  11. File surface — right. One source file: parseDateCell, its docblock, the private helpers beside it (ISO_TEMPORAL_CELL, namesRealCalendarDay, readIsoTemporalCell, utcClock), NAIVE_DATE_TIME and parseNaiveWallClock removed (module-private; Type Check green). Tests in packages/rest/src/. One changeset for @objectstack/rest.

  12. Fixture triage — right. Three fixtures re-spelled from year-first slash to ISO; the two assertions that pinned the slash branch itself replaced by refusal assertions. Consistent with the ruled set (③.7).

  13. Tests against triage's pin list (5883900872) — met on SQLite under both host zones, memory measured not pinned (③.2). Per zone: the fold's 11 rows refused as their own row's invalid_date with the sibling row written and nothing stored; 0500-01-01 and 0001-01-01 stored padded and equal to what the create door stores; the 2026 ISO, offset and export-shape controls unchanged; the export round trip; an xlsx Date cell; quoted CSV cells; the dry run. The zone switch is asserted by Intl and the July offset. The unit table (37 refused, 25 admitted) asserts equality under both hosts.

② Semver level

.changeset/20534-import-date-cell-iso-real-day.md: '@objectstack/rest': minor, line-initial Clause-②: no (narrowing), a BREAKING banner for callers of the import door, a FROM/TO pair for every refused class, and the adr-0087 marker not-required (no-migration-prescription) with its why.

③ Boundary flags

Every dev flag (deviations, acceptance notes, out-of-scope findings) and the open question, answered:

  1. time branch included (deviation). Answered: right. The claim's parenthetical named the date and datetime branches, but the ruling is "no host-zone reading" on parseDateCell, the time fallback was the same new Date(s) (07/15/2026 10:00 stored as 14:00:00 in New York and 02:00:00 in Shanghai), and the branch is inside the claimed function and file. Declared in the PR body and the changeset.
  2. InMemoryDriver leg measured, not pinned (departs from triage's "memory and SQLite"). Answered: accepted, not escalated. The verdict is the reader's, before any driver is reached; @objectstack/rest has no @objectstack/driver-memory dependency and scripts/driver-memory-census.ledger.json lists no packages/rest consumer, so a pin would widen the package's dependencies and the census ledger, outside the claimed file surface. PR fix(rest)!: /import reads a comma in a number cell only as a thousands group, refusing the rest (#20497) #20517 landed with the same shape for the same door. The memory readings are in the H0 table: memory and SQLite agree on all 41 cells under both zones, at base and at head. The seat may carry it as an acceptance note, as the dev did.
  3. A zone-naive 24:00 is now refused. Answered: right. It went to new Date(s) in the host zone at base (the old wall path already refused hour 24); refusing it loudly with a FROM/TO to 2026-07-16 00:00 is the ruled direction, 2026-07-15T24:00:00Z names its instant and reads as before, and reading hour 24 on the wall path would need core's zonedWallClockToUtcMs, read-only for this claim.
  4. objectui Import Wizard preview disagrees with the server on non-ISO dates (noted, no carrier). Answered: outside this repository; the server dry run gives the verdict, and objectui's xlsx reader sends ISO. The seat's Acceptance notes handling stands.
  5. import-mappings.mdx names no accepted spellings (noted, no carrier). Answered: a docs follow-up, not a defect of the ruled set; "Flag docs affected by code changes" is green. The seat's Acceptance notes handling stands.
  6. Two year-below-1000 defects outside the surface — /export writes 500-01-01 unpadded (landing export-format.ts), and core's zonedWallClockToUtcMs stores 0050-01-01 10:00:00 in 1950. Answered: rightly not fixed here (export-format.ts is outside the claimed file surface; packages/core is read-only). The seat filed [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 for the export and pointed [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 at the core reading (5884709824). Neither is made worse by this diff.
  7. Open question (year-first slash dates 2026/7/15, Excel's zh-CN/ja-JP default) — answered A by the seat (5884709824), as ruled. Judged only on execution: the diff refuses 2026/6/3, 2026/07/15, 2026-7-15, 2026/08/01 06:00:00 and 2026-07-15 9:00 (unit table), re-spells the three fixtures, and the changeset carries FROM 2026/7/15 TO 2026-07-15 with the Excel cell-format fix. The ruled set is executed; nothing to escalate. The declared date-format option stays outside the card per triage.
  8. Mechanics deviations — label-write under with-fleet.sh --read; two pushes against the order's one (probe push plus final); origin/main merged at c876a7426d with main since moved by e666636fd9 (a create-objectstack test only; the PR is mergeable: true); the AGENTS.md model-free trailer used instead of the harness reminder. None touches the diff's contract.
  9. check-single-claim-paths NOT MEASURED locally (needs a PR context). Answered by the head's check-run "No other open PR may claim the same single-writer path": success.
  10. Verification ran UNLOCKED (no flock on the host; declared). Answered by the check-runs above; the one still running is recorded as running.

Implemented-by: claude/issue-20534-parse-date-cell-family
Reviewed-by: local_1d2a197c-c20e-4e90-9be8-413d4d432289

VERDICT: PASS

hotlong and others added 4 commits September 29, 2026 14:45
…real day, stored padded (#20534)

Per the maintainer ruling on the card: YYYY/M/D and YYYY-M-D with the same
separator, optionally a space and a zone-naive H:MM[:SS], are read beside
ISO 8601 and the export shape. The day must exist, the clock is 0..23 /
00..59, the day is stored as the padded ISO day and a clock is a wall
clock read as the export shape's is. No zone, fraction or T in this form.

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3b2e47349e98346c72d3f3cf9dfda3ed1c6eb8bf
Local-runs: none

Delta review of PR #20601 for card #20534 at the patch-round head, after the full PASS at 76e7fb3379a45e0a6feb8559e7099d63a6a1f52a (5885068376). Binding direction: triage's answer A (5883900872) as amended by the maintainer ruling 5885066497, which re-admits year-first dates on one closed grammar and changes nothing else. Inputs: the card body and all 9 comments; the PR body (rewritten by the seat), its 5-file list and its 2 comments; the net diff against main at this head; the compare 76e7fb3379...3b2e47349e (13 commits: 8 brought in by two merges of main, the two merge commits, and the PR's own three, 46f80262 the reader, 279ca425 the tests, 1d82ef6e the changeset); the check-runs on this head, read 2026-09-29T07:11:58Z. The PR's own five files were judged; the compare's file listing (capped at 300 by the API) is main's movement. Blob shas of the touched files at main, at the PR base 3a89d459af, at the previous head and at this head were read through the contents API. Nothing was built, run or re-run.

Check-runs on this head at the read: 39 runs over 32 names (7 names ran twice: a suite at 07:03Z on the push and a suite at 07:09Z after the body edit; no completed duplicate disagrees). Newest per name: 21 success, 5 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in), and the newer Auto Label and Check PR Size runs, whose 07:03Z runs are success), 6 in_progress: Lint and Repo Gates, Test Core (1/6), (2/6), (4/6), (5/6), Type Check · workspace. No verdict is inferred for a running check. Green includes Check Changeset (07:10:21Z), Build Core, Temporal Conformance (live PG + MySQL), Test Core (3/6) and (6/6), Type Check · source gates / consumer gates / debt ledger, Dogfood Regression Gate 1/3 to 3/3 and its rollup, Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, Check Documentation Links, and the three claim guards. main has moved 3 commits past the PR base since the last merge; none touches packages/rest/src/import*, packages/core's datetime utils or the write door's validator; the PR is mergeable: true.

① Derived judgments

The brief's six questions, answered on the diff:

Q1. The admitted year-first set equals the ruled set exactly — yes. YEAR_FIRST_CELL is ^(\d{4})([/-])(\d{1,2})\2(\d{1,2})(?: (\d{1,2}):(\d{2})(?::(\d{2}))?)?$, and readYearFirstCell runs namesRealCalendarDay and the clock-range check on the match. Item by item against 5885066497: a four-digit year (\d{4}); a one- or two-digit month and day (\d{1,2}); the same separator in both places (the \2 back-reference, so 2026/7-15 does not match); exactly one space before the clock (the literal space inside the optional group; the cell is trimmed first, so surrounding whitespace is fine and a second inner space is not); a one- or two-digit hour (\d{1,2}) with two-digit minutes and optional two-digit seconds; hour 0..23 and minutes and seconds 00..59, enforced by the check that refuses an hour above 23 or a minute or second above 59, so 2026/7/15 24:00 and 2026/7/15 9:60 are refused; no T (the space is the only separator the group admits), no zone and no fraction (the pattern is anchored at $ right after the seconds); the day must exist (namesRealCalendarDay, so 2026/2/30 is refused and 2028/2/29 admitted); the stored day is padded (${y}-${pad2(month)}-${pad2(date)}). All six ruled examples are admitted and pinned: 2026/7/15, 2026/07/15, 2026-7-15, 2026/7/15 9:00, 2026/08/01 06:00:00, 2026-07-15 9:00 (unit ADMITTED rows; route pins for 2026/7/15 and 2026/7/15 9:00). Every "still refused" class is refused and pinned: 07/15/2026 (as date and as datetime), 26/7/15, 2026/7-15; 15/07/2026 cannot match a four-digit first field. No form is admitted that the ruling does not admit, and none the ruling admits is refused. The reader tries readIsoTemporalCell(s) ?? readYearFirstCell(s), and the fall-through leaks nothing: every ISO refusal (an impossible day, a zone after a space, an out-of-range clock, lower-case t / z, a T with a one-digit hour, an unparseable offset) is refused by the year-first grammar for the same or a stricter reason, checked shape by shape.

Q2. Beyond the padding, the 1900s fix and the named time exception, no form main admits moves to a different stored value — confirmed against main's reader (blob afaf602da1, identical at the base and at today's main), branch by branch. A Date argument: datetime and time unchanged, date padded only. A bare day (ISO or year-first, either separator, one- or two-digit fields) into date: the same padded day; into datetime: main's Date.UTC(y, mo - 1, d) and the head's Date.parse of ${day}T00:00:00.000Z name the same midnight for every year 0100..9999 and differ only for 0000..0099 (the 1900s fix). A clock cell that main's NAIVE_DATE_TIME read, in its one ruled form on a real day, takes the same zonedWallClockToUtcMs(wall, timezone) call with the same parts (the ISO fraction is truncated to three digits on both sides; the year-first form has no fraction, so millisecond: 0 where main also had 0); into date the same day, into time the same typed clock. A zone-bearing ISO cell: Date.parse on both sides, the same instant; date the same UTC day (padded), time the same UTC clock. T24:00:00Z unchanged. Everything else main admitted went to new Date(s) and is now refused, not re-valued. The one value change outside those classes is the named exception: a year-first bare day into a time field, which main read through new Date('2026/7/15') in the host zone (04:00:00 in New York, 16:00:00 in Shanghai) and the head reads as 00:00:00. The exception is right under the ruling. The ruling holds a year-first cell to "the same rules as every other admitted cell"; the admitted cell of the same kind is a bare ISO day into a time field, which main and the head alike read as 00:00:00 (ECMAScript reads a date-only form as UTC), and no host-independent value can equal a reading that differed by host. The changeset's Kept paragraph names it. The dev's 666-row census (0 refused → admitted, 240 admitted → refused, 42 value changes of which 30 are padding or the 1900s fix and 12 this exception) is the dev's measurement, not re-run here; it is consistent with this reading.

Q3. A year-first date-time is read through the same business-zone wall-clock path as the export shape — yes. Both readers return the one IsoTemporalCell shape, and parseDateCell has a single if (cell.wall) arm: date takes cell.day, time the typed clock, datetime zonedWallClockToUtcMs(cell.wall, timezone). Pinned three ways: the route pin "stores a year-first date-time as the same instant the export shape stores" asserts 2026/7/15 9:00 equals what 2026-07-15 09:00:00 stores under TZ=America/New_York and TZ=Asia/Shanghai, and equals 2026-07-15T09:00:00.000Z; the unit rows assert 2026/7/15 9:00 and 2026-07-15 9:00 equal under both hosts; and import-business-timezone.test.ts (byte-identical to main, its line 116) asserts 2026/08/01 06:00:00 in the Shanghai business zone under a Los Angeles host is CROSS_MONTH_UTC (2026-07-31T22:00:00.000Z), beside the export-shape line that gives the same instant.

Q4. The refusals reuse the existing keys, with no new code and no date-format option — yes. parseDateCell returns undefined; the caller in coerceRow is untouched by the diff and at the head still reads key = t === 'datetime' ? 'import_invalid_datetime' : t === 'time' ? 'import_invalid_time' : 'import_invalid_date' with coerceError(meta, field, 'invalid_date', key, raw, ctx). The diff adds no error code, no message key, no option, and touches no packages/core, objectql, spec or drivers file (file list).

Q5. The changeset is right, and the FROM 2026/7/15 line is gone — yes. '@objectstack/rest': minor; line-initial Clause-②: no (narrowing); the BREAKING paragraph's admitted list now carries the year-first form in the ruling's words; the FROM/TO pairs cover every class that moves admitted → refused, including the year-first edges the ruling refuses (mixed separator, T or zone, fraction, 24:00, two-digit year); the new Kept: year-first dates paragraph names the five ruled spellings, the real-day check, the hour range, the padded storage and the time exception; the four-digit-year and 1900s paragraphs stand; the adr-0087 marker is still not-required (no-migration-prescription), its why now ending "in ISO 8601 or as a year-first date". 2026/7/15 appears only as a TO (from 2026/7-15) and in Kept, never as a FROM. Judged in ② below.

Q6. The restored fixtures and assertions match main's spelling, and import-business-timezone.test.ts is byte-identical to main — yes. The net diff against main touches import-coerce.test.ts only by the afterEach import and the appended [#20534] block, and import-integration.test.ts only by two added lines (the two.due assertion), so main's parseDateCell('2026/6/3', 'date') line (main line 87), the coerceRow fixture due: '2026/07/01' (line 142), the CSV cell 2026/06/30 (line 193) and the xlsx text cell '2026/07/01' (line 350) are main's bytes at the head. import-business-timezone.test.ts is not in the PR's file list; its blob is 7780a08ce320 at this head, at the base 3a89d459af, at f11b5f20a2 and at today's main (it was dd2f60b77fd8 at the previous head).

Every accept-set and public-surface change the diff implies:

  1. Text grammar: ISO 8601, the export shape, or the ruled year-first form — right (re-judged). ISO_TEMPORAL_CELL and readIsoTemporalCell are unchanged from the previous head (carried from 5885068376 ①.1: the write door's ISO_DATETIME_WRITE_FORM, a space-plus-zone spelling refused); YEAR_FIRST_CELL is the ruling's grammar (Q1). The union is the ruled set.
  2. Impossible day refused on every branch, by arithmetic — right (re-judged for the new branch). readYearFirstCell calls namesRealCalendarDay before building anything; 2026/2/30 is pinned refused as date and datetime in the unit table and as a date row through the route. Nothing on the year-first branch reaches Date.UTC with the typed parts except through zonedWallClockToUtcMs after the day is checked.
  3. No host-zone reading — right (re-judged). The year-first branch yields a bare day (UTC midnight for datetime, the day itself for date, 00:00:00 for time) or a wall clock read in the business zone; no new Date(s) remains in the reader (main had three). Every unit row is asserted equal under America/New_York and Asia/Shanghai.
  4. No month-first guess — right (re-judged). The year-first form's first field is four digits, so the month and day positions are fixed, the ruling's own reason. 07/15/2026, 15/07/2026 and 26/7/15 are outside the grammar and pinned refused.
  5. Four-digit year on every date branch — right (re-judged). The year-first branch keeps the captured four digits and pads only month and day (0500/1/1 → 0500-01-01, pinned). The ISO and instant branches are unchanged (carried, 5885068376 ①.5); year 0000 in either text form is left to the write door's 0001..9999 range as before.
  6. 1900s fix — right (re-judged). A year-first bare day into a datetime takes the same ${day}T00:00:00.000Z spelling as an ISO bare day; 0050-01-01 and 0001-01-01 are pinned in their own year, and 0050/1/1 follows the same line.
  7. time branch — right (re-judged). A year-first cell with a clock gives its typed clock (2026/7/15 9:00 → 09:00:00, pinned); without a clock, 00:00:00 (Q2).
  8. Export shape and xlsx Date cells unchanged — carried (5885068376 ①.8). The patch touched neither path; both pins stay in the route file.
  9. Refusal keys — carried (5885068376 ①.9), re-read at the head (Q4).
  10. Public surface — carried (5885068376 ①.10). coerceRow narrows with the door; no export added, removed or renamed; the read-only packages are untouched (file list), and main's movement since the base touches none of them.
  11. File surface — right (re-judged). The patch adds two module-private helpers (YEAR_FIRST_CELL, readYearFirstCell) beside the existing ones and edits the docblocks; still one source file, tests in packages/rest/src/, one changeset. The claim's surface holds.
  12. Fixture triage — right (re-judged; reverses 5885068376 ①.12 as the ruling requires). The three fixtures and two assertions the first round re-spelled or replaced are main's bytes again (Q6), and the year-first edges the ruling refuses are pinned in their place.
  13. Tests against the pin list — met (re-judged). Triage's pins (5883900872) stand as in 5885068376 ①.13, now with 12 refused rows and 10 admitted cells per host zone through the route over SqlDriver, plus the same-instant pin, the padded-year parity with the create door, the export round trip, the xlsx cell, the quoted CSV verdicts and the dry run; the ruling's own examples are pinned per Q1 and Q3; the unit table is 42 refused and 36 admitted rows, counted. The memory leg stays measured, not pinned (③.4).

② Semver level

.changeset/20534-import-date-cell-iso-real-day.md: '@objectstack/rest': minor, line-initial Clause-②: no (narrowing), a BREAKING paragraph for callers of the import door, and the adr-0087 marker not-required (no-migration-prescription).

  • Level minor — still right. The patch round re-admits a form main already admitted, so it adds no accept set against main; the net change is still a narrowing (the census: 0 refused → admitted, 240 admitted → refused), and the reasons of 5885068376 ② hold: a narrowing on a published door and the exported coerceRow is BREAKING under AGENTS.md §3, ships pre-GA as minor with the banner under ADR-0087's amended level, and check-changeset-no-major refuses major. Check Changeset is success on this head (07:10:21Z).
  • Clause-②: line — right. The changeset carries no (narrowing), the arm check-adr-0087-registration reads (the dev's run: [BREAKING+clause-②-narrowing] not-required); the PR body carries the claim's Clause-②: no. Nothing widens against main.
  • FROM/TO coverage — right. Each newly refused class has a FROM and an admitted TO, including the year-first edges the ruling refuses: a mixed separator (2026/7-15 TO 2026/7/15 or 2026-07-15), a T or a zone (2026/7/15T9:00, 2026/7/15 9:00Z TO 2026/7/15 9:00 or the ISO instant), a fraction (2026/07/15 10:00:00.123 TO 2026-07-15 10:00:00.123), and 2026/7/15 24:00 TO 2026/7/16 0:00. 2026-07-15T9:00 (an ISO day, a T, a one-digit hour), which main admitted, is refused by both grammars (two-digit hour after T; space only in the year-first form) and falls under the "a T on the year-first form" line by class, though the example there is spelled with slashes. The TO wording moved from "the ISO one" to "an admitted one", matching the wider set.
  • Kept paragraph — right. It states what an upgrader keeps (2026/7/15, Excel's zh-CN / ja-JP default), the rules now applied to it, and the time exception with its old host-dependent value.
  • ADR-0087 disposition — still right. Nothing authorable is removed, renamed or reshaped; the FROM/TO lines prescribe edits to cells in a user's file, not metadata; objectstack migrate meta has nothing to reach. The same disposition as PR fix(rest)!: /import reads a comma in a number cell only as a thousands group, refusing the rest (#20497) #20517 and the write-door siblings.

③ Boundary flags

The newest os-dev-report (5885398033) has open_questions: []. Every deviation and out-of-scope entry, and the earlier flags the ruling touched:

  1. The PR body was not updated by the dev; the seat rewrote it (deviation). Answered: done. The live body cites 5885066497, states the ruled grammar in the ruling's words, carries the 666-row census, both H3 rounds and the updated Acceptance notes, and every count in it that this record could check matches the diff (42 / 36 unit rows, 28 route cases per zone, 22 red in the round-2 ablation as 11 + 5 + 6). One seat correction remains, outside the diff: the PR title still reads "only in ISO 8601 or the export shape". That is the first round's set; the head admits the year-first form too, and the changeset heading already says so. The adopting seat should retitle the PR to match the changeset heading before merge, since the title becomes the commit subject on main. Not a defect of the diff; the verdict below is on the diff and the changeset.
  2. origin/main merged twice (deviation). Answered: the compare's two merge commits bring in main only (its file listing is main's movement, mostly the version-packages commit consuming changesets); the PR's own files are the five in the file list, judged above. main has moved 3 commits past the base since, none on this surface.
  3. The xlsx fixture restore added one assertion (deviation). Answered: right. two.due was unasserted on main; the added line pins the year-first text cell through the real xlsx path at no cost to main's spelling.
  4. Memory leg measured, not pinned — carried from 5885068376 ③.2, unchanged by the patch (the dev re-measured 46 cells at the new head; memory equals SQLite under both zones, 0 cells differ between zones).
  5. Census value changes beyond padding and the 1900s fix (deviation). Answered in ① Q2: the 12 rows are the one time exception, right under the ruling and named in the changeset.
  6. Census at 666 rows (deviation). Reporting mechanics; the original 588-row subset is reported beside it. Nothing to escalate.
  7. Commit trailer (deviation). The commits carry the AGENTS.md model-free pair; mechanics, not contract.
  8. Out of scope, already filed: [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 (/export unpadded year) and [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 (core zonedWallClockToUtcMs reads 0..99 as the 1900s). Answered: rightly not re-filed. The year-first clock reaches [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 through the same call main used for it (0050/1/1 10:00 was 1950 on main and is 1950 at the head), so nothing is made worse; packages/core is read-only for the claim.
  9. objectui Import Wizard preview (noted, no carrier). Answered: outside this repository; with year-first admitted the preview and the server now agree on the Excel default, and the server dry run remains the verdict for 07/15/2026. The Acceptance-notes handling stands.
  10. import-mappings.mdx names no accepted spellings (noted, no carrier). Answered: a docs follow-up; the drift check on this head lists nothing and says that is not a clean bill. The Acceptance-notes handling stands.
  11. check-single-claim-paths — measured this round with PR_NUMBER=20601 (exit 0), and the head's check-run "No other open PR may claim the same single-writer path" is success in both suites.
  12. Verification ran UNLOCKED; CI not awaited (declared). Answered by the check-runs at the read: 21 green, 5 skipped, 6 still running, recorded as running.
  13. 5885068376 ③.7 (year-first refused, answered A) — superseded by the ruling, as 5885066497 says; re-judged as ① Q1 to Q3. The declined locale guess (07/08/2026) stays refused, pinned.

Implemented-by: claude/issue-20534-parse-date-cell-family
Reviewed-by: local_1d2a197c-c20e-4e90-9be8-413d4d432289

VERDICT: PASS

@objectstack-fleet objectstack-fleet Bot changed the title fix(rest)!: /import reads a date, datetime or time cell only in ISO 8601 or the export shape, on a real day, with a four-digit year (#20534) fix(rest)!: /import reads a date, datetime or time cell only in ISO 8601, the export shape or a year-first date, on a real day, with a four-digit year (#20534) Sep 29, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 07:26
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit eb4b17c Sep 29, 2026
50 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20534-parse-date-cell-family branch September 29, 2026 07:44
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
… to the commits that decided them (objectstack-ai#20632)

Part of objectstack-ai#20594
Clause-②: no

## What changed

This is stage 2 of the `domain:cli` lane of the dead-citation sweep:
`packages/rest/src/**`. Every comment or docblock site in scope that
cited a tracker number answering 404 now cites, in ruling C+D's form C
(comment 5749154545 on objectstack-ai#19123), the commit in this repository's history
that decided what the line describes, and says in its own words what
that commit decided. PR objectstack-ai#20533 is the method and PR objectstack-ai#20624 (stage 1,
`packages/runtime`) the precedent this follows line for line. Later
stages cover `cli`, `types` and the rest of the lane, so this PR says
`Part of` and the card stays open.

That is **457 comment sites on 445 lines in 85 files, covering 74
numbers**: the census's 191 sites, 256 more in test comments (which the
census defers), and 10 sites whose dead number is the second half of a
slash-joined pair the citation grammar does not read (`objectstack-ai#3984/objectstack-ai#6241`,
`objectstack-ai#9901/objectstack-ai#10255` four times, `objectstack-ai#10993/objectstack-ai#11235/objectstack-ai#11292`, `objectstack-ai#11235/objectstack-ai#11242`
twice, `objectstack-ai#10993/objectstack-ai#11242`, `objectstack-ai#7543/objectstack-ai#15071`). Each rewritten line cites one
of **70 distinct commits**.

ADR-0076 D11 is the only ADR that records any of these numbers, and it
records objectstack-ai#8850 only as the extraction it names as landed in `8664a2c99`,
so that commit is the anchor there. No other ADR or ruling-record file
in `docs/adr/` or `scripts/adr-anchors/` records the decision behind any
of these numbers, so every anchor is a commit. The anchors the landed
stages already gave the same numbers are reused where the rest sites
describe the same decision (30 numbers, for example `79c46da90` for
objectstack-ai#9934, `7986d973f` / `311433f6b` for the compound-name retirement,
`6a180e42d` for objectstack-ai#13279 and `cf6e0a193` for objectstack-ai#15071), so each number
carries one anchor across the tree.

Only comments changed. Every touched file keeps its line count (451
lines out, 451 in, over 85 files), so no line citation into these files
moves. Six of the 451 lines held no dead site; each is the other half of
a sentence that had to change:
- `discovery-schema-conformance.test.ts:343` (「(reaffirmed by」 to
「(which commits」, because line 344 now names the two commits that landed
the ruling),
- `package-door-16019-raw-statement-fault-code.test.ts:51` and
`error-response.ts:1485` (a trailing 「PR」 whose number wrapped onto the
next line),
- `error-response-structured-arm-door-parity.test.ts:463` (「That card
added the limb」 to 「That commit」, because line 459's tag now names the
commit),
- `rest-hook-script-fault-envelope.test.ts:331` (「both sides of that
card」 to 「that fix」),
- `rest-server.ts:908` (「(objectstack-ai#14409, landed」 to 「(landed as commit」, the
sha `3ecb7dc1a` already standing on line 909).

**No citation number is added.** Every tracker number on an added line
was already on the line it replaces. No PR number stands on an added
line. One of the 70 shas is on a removed line, and it was there before:
`rest-14078-invalid-date-total-arm.test.ts:19` read 「PR objectstack-ai#14409 (landed
`3ecb7dc1a`)」 and now reads 「Commit 3ecb7dc drove」. No code token
moves (see the guard below).

Three dead comment sites are left on purpose, listed under "The sites
left". One more file: a `patch` changeset for `@objectstack/rest`,
because the rewritten docblocks ship (see Changeset below).

## Census: `packages/rest`, before and after

**Instrument.** The gate's own `node scripts/check-issue-citations.mjs
--census --json`, read-only and unchanged, run with the fleet token. Its
surface is comment prose in `packages/**/src/**/*.ts` with string
literals blanked, and it defers `*.test.ts`. The count is its
`allocated-but-absent` findings under `packages/rest/`. Both runs
enumerated the whole board (185 pages), so neither read a truncated
board.

| reading | tree | board | whole-repo `allocated-but-absent` | rest
sites | lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `a186aea996`, run 2026-09-29T10:28:18Z to 10:36:06Z |
enumerated, 185 pages, frontier objectstack-ai#20628, 18,455 numbers | 2,015 | **191**
| 186 | 14 | 51 |
| after | head `93e4d69ba6`, run 11:11:30Z to 11:17:37Z | enumerated,
185 pages, frontier objectstack-ai#20630, 18,457 numbers | 1,764 | **0** | 0 | 0 | 0 |

The before count equals the card's 191 at `f11b5f20a2`. The whole-repo
drop is 251: this diff's 191, plus the 60 of PR objectstack-ai#20626
(`packages/plugins/plugin-sharing`, 63 to 3), which landed on `main` in
between and came in with the merge. No other package moved.

**Supplementary instrument, the whole scope.** The census does not read
test files or strings, and this stage's scope includes test comments. So
a second reading runs the gate's own exported `extractCitations`
(whole-file and comment-prose projections) and `classifyCitation` over
every `.ts` file under `packages/rest/src` (256 files), against the
board enumerated through the gate's own `enumerateBoard`. The lit
controls objectstack-ai#20594, objectstack-ai#19123 and objectstack-ai#20624 answered 200 and are on both boards;
the dead controls objectstack-ai#13214, objectstack-ai#14541 and objectstack-ai#15071 answered 404 and are on
neither.

| reading | tree | board | citations | dead | src comment | test comment
| src string | test string |
|---|---|---|---|---|---|---|---|---|
| before, 10:29Z | `a186aea996` | 185 pages, frontier objectstack-ai#20628 | 4,620 |
**577** | 191 | 259 | 1 | 126 |
| after, 11:21Z | `93e4d69ba6` | 185 pages, frontier objectstack-ai#20631 | 4,174 |
**130** | 0 | 3 | 1 | 126 |

Its src-comment column equals the census's 191 and 0, which is the
control on the second instrument, and a site-by-site comparison of the
two before-readings is identical. Resolving comment citations move by
one (1,364 to 1,365 in src): `(objectstack-ai#10993/objectstack-ai#11235/objectstack-ai#11292)` became `(objectstack-ai#10993,
commit 376c70f, objectstack-ai#11292)`, so the grammar now reads the live `objectstack-ai#11292`
that the slash hid. The drop is 447 grammar-read sites; the other 10
rewritten sites are the slash-joined ones the grammar never read.

Separately, every one of the 77 numbers was probed on its web endpoint:
76 answer 404 (deleted) and one, #14026, answers 302 to
objectstack-ai/objectui#10102 (transferred), which is why it is left
(see below).

## Per-number table

Sites and files are the dead comment sites in scope at the base, tests
and slash-joined halves included. `left` is a site with no deciding
commit (see below). `strings kept` counts string-literal sites, which
are tokens and stay as they were. Every anchor was read in its message
or its diff, not only in its subject: it is the commit that made the
change the line describes, and its own message or diff names the number
it replaces or adds the citation the line carries.

| number | comment sites / files | rewritten | left | strings kept |
anchor |
|---|---|---|---|---|---|
| `objectstack-ai#6037` | 5/3 | 5 | 0 | 0 | `18189983d` |
| `objectstack-ai#6122` | 2/2 | 2 | 0 | 0 | `64cd01082` |
| `objectstack-ai#6206` | 1/1 | 1 | 0 | 0 | `8e13ca876` |
| `objectstack-ai#6216` | 6/2 | 6 | 0 | 2 | `f586f1a89` |
| `objectstack-ai#6241` | 10/3 (1 slash-joined) | 10 | 0 | 1 | `83a3b1f2e` |
| `objectstack-ai#6259` | 2/1 | 2 | 0 | 0 | `6968885ef` |
| `objectstack-ai#6303` | 1/1 | 1 | 0 | 0 | `465c5fc14` |
| `objectstack-ai#6306` | 9/5 | 9 | 0 | 3 | `fec784863` |
| `objectstack-ai#6307` | 4/2 | 4 | 0 | 0 | `293476148` |
| `objectstack-ai#6349` | 4/2 | 4 | 0 | 4 | `2443bb4c4` |
| `objectstack-ai#6474` | 1/1 | 1 | 0 | 0 | `18189983d` |
| `objectstack-ai#6535` | 3/2 | 3 | 0 | 0 | `a92b1793c` |
| `objectstack-ai#6640` | 1/1 | 1 | 0 | 1 | `2ab1257c9` |
| `objectstack-ai#6704` | 5/1 | 5 | 0 | 1 | `c3f491626` |
| `objectstack-ai#8641` | 1/1 | 0 | 1 | 0 | — |
| `objectstack-ai#8850` | 3/3 | 3 | 0 | 0 | `8664a2c99` |
| `objectstack-ai#8885` | 6/3 | 6 | 0 | 3 | `30b1c636a` |
| `objectstack-ai#8919` | 7/3 | 7 | 0 | 7 | `b5378550e` |
| `objectstack-ai#9741` | 12/1 | 12 | 0 | 0 | `2a29caa53` |
| `objectstack-ai#9805` | 1/1 | 1 | 0 | 0 | `45862a53d` |
| `objectstack-ai#9934` | 19/10 | 19 | 0 | 4 | `79c46da90` |
| `objectstack-ai#9967` | 2/2 | 2 | 0 | 4 | `8f266f1cd` |
| `objectstack-ai#10063` | 2/2 | 2 | 0 | 1 | `9e04c3e35` |
| `objectstack-ai#10178` | 1/1 | 1 | 0 | 0 | `38cf397ea` |
| `objectstack-ai#10179` | 0/0 | 0 | 0 | 1 |  |
| `objectstack-ai#10255` | 18/4 (4 slash-joined) | 18 | 0 | 2 | `6ce58a735` |
| `objectstack-ai#10340` | 13/3 | 13 | 0 | 2 | `26f3588fb` |
| `objectstack-ai#10345` | 13/6 | 13 | 0 | 6 | `cad8b42f0` |
| `objectstack-ai#10350` | 1/1 | 1 | 0 | 0 | `490879ad0` |
| `objectstack-ai#10485` | 2/1 | 2 | 0 | 1 | `35ad101bc` |
| `objectstack-ai#10537` | 9/3 | 9 | 0 | 1 | `e634ecf6a` |
| `objectstack-ai#10888` | 2/2 | 2 | 0 | 0 | `d806081dd` |
| `objectstack-ai#11006` | 3/1 | 3 | 0 | 0 | `cccbe51bf` |
| `objectstack-ai#11130` | 1/1 | 1 | 0 | 0 | `851909530` |
| `objectstack-ai#11235` | 4/2 (1 slash-joined) | 4 | 0 | 0 | `376c70f98` |
| `objectstack-ai#11242` | 3/2 (3 slash-joined) | 3 | 0 | 0 | `98ea3443f` |
| `objectstack-ai#12144` | 1/1 | 1 | 0 | 0 | `3a04b0125` |
| `objectstack-ai#12176` | 11/7 | 11 | 0 | 2 | `7986d973f` |
| `objectstack-ai#12194` | 15/5 | 15 | 0 | 4 | `311433f6b` |
| `objectstack-ai#12195` | 35/16 | 35 | 0 | 7 | `7986d973f` |
| `objectstack-ai#13182` | 2/2 | 2 | 0 | 0 | `5b3ff63cc` |
| `objectstack-ai#13197` | 1/1 | 1 | 0 | 0 | `56c093c4d` |
| `objectstack-ai#13213` | 2/1 | 2 | 0 | 0 | `4801296e7` |
| `objectstack-ai#13214` | 18/6 | 18 | 0 | 14 | `cc837dbfe`, `889ec5b42`, `3d10755f0`
|
| `objectstack-ai#13244` | 5/2 | 5 | 0 | 1 | `889ec5b42` |
| `objectstack-ai#13255` | 4/1 | 4 | 0 | 6 | `43028a8f8` |
| `objectstack-ai#13258` | 1/1 | 1 | 0 | 0 | `3d10755f0` |
| `objectstack-ai#13279` | 23/5 | 23 | 0 | 5 | `6a180e42d` |
| `objectstack-ai#13280` | 13/4 | 13 | 0 | 2 | `add6a1b1c` |
| `objectstack-ai#13282` | 1/1 | 1 | 0 | 0 | `43028a8f8` |
| `objectstack-ai#13377` | 3/2 | 3 | 0 | 0 | `e10cf3444` |
| `objectstack-ai#13378` | 2/1 | 2 | 0 | 0 | `82faea03f` |
| `objectstack-ai#13454` | 1/1 | 1 | 0 | 0 | `7ad57e17a` |
| `#14026` | 1/1 | 0 | 1 | 0 | — |
| `objectstack-ai#14365` | 1/1 | 0 | 1 | 0 | — |
| `objectstack-ai#14366` | 14/4 | 14 | 0 | 2 | `53cbad9f7` |
| `objectstack-ai#14369` | 3/2 | 3 | 0 | 0 | `a3d5724c8`, `53cbad9f7` |
| `objectstack-ai#14389` | 7/3 | 7 | 0 | 7 | `10220a7bf` |
| `objectstack-ai#14390` | 1/1 | 1 | 0 | 0 | `9d7f7259f` |
| `objectstack-ai#14409` | 2/2 | 2 | 0 | 0 | `3ecb7dc1a` |
| `objectstack-ai#14541` | 27/4 | 27 | 0 | 5 | `6d178a408` |
| `objectstack-ai#14613` | 2/2 | 2 | 0 | 0 | `81208086a` |
| `objectstack-ai#14677` | 1/1 | 1 | 0 | 0 | `a4e4d2d78` |
| `objectstack-ai#14683` | 8/2 | 8 | 0 | 0 | `96326040f` |
| `objectstack-ai#14691` | 15/2 | 15 | 0 | 2 | `b3a63d32c` |
| `objectstack-ai#14704` | 9/3 | 9 | 0 | 2 | `1c7adc73d` |
| `objectstack-ai#14723` | 7/4 | 7 | 0 | 4 | `65846bc46` |
| `objectstack-ai#14725` | 3/3 | 3 | 0 | 2 | `f5cc78b63` |
| `objectstack-ai#14849` | 3/1 | 3 | 0 | 0 | `226e72443` |
| `objectstack-ai#14907` | 1/1 | 1 | 0 | 0 | `e1d4f9e3f` |
| `objectstack-ai#14908` | 1/1 | 1 | 0 | 0 | `d5cbb44f3` |
| `objectstack-ai#15021` | 2/1 | 2 | 0 | 8 | `cc238db8b` |
| `objectstack-ai#15034` | 6/2 | 6 | 0 | 0 | `abf9101f1` |
| `objectstack-ai#15065` | 1/1 | 1 | 0 | 0 | `1c7adc73d` |
| `objectstack-ai#15071` | 23/4 (1 slash-joined) | 23 | 0 | 3 | `cf6e0a193` |
| `objectstack-ai#16650` | 1/1 | 1 | 0 | 0 | `001a83b04` |
| `objectstack-ai#17058` | 3/1 | 3 | 0 | 4 | `94c930248` |
| `objectstack-ai#18546` | 3/2 | 3 | 0 | 3 | `58f60e37e` |
| **total** | **460** | **457** | **3** | **127** | **70 distinct
commits** |

Every cited sha matches exactly one object (`git rev-parse
--disambiguate`, count 1 for each of the 70), is a commit, has one
parent, and is an ancestor of the base (`merge-base --is-ancestor`, exit
0 for all 70). The checkout is not shallow (`--is-shallow-repository`
false); the control leg `13a6cb4ad` exits 0 and the negative control
(this branch's first WIP commit, not on `main`) exits 1. Several numbers
are the PR number of their own anchor commit (objectstack-ai#6122, objectstack-ai#6303, objectstack-ai#6474,
objectstack-ai#11242, objectstack-ai#13213, objectstack-ai#13244, objectstack-ai#13258, objectstack-ai#13282, objectstack-ai#14409, objectstack-ai#14677, objectstack-ai#14908, objectstack-ai#15065,
objectstack-ai#16650), so the sha is the same object the number named.

**Numbers with more than one anchor, by site:**
- `objectstack-ai#13214` (18 sites) was one card with three commits. `cc837dbfe` (the
ownership gate, the 2026-08-30 ruling) for the 11 sites that describe
the gate; `889ec5b42` for the 5 in
`ui-view-route-identity.measurement.test.ts`, the identity measurement
it created; `3d10755f0` for the tenancy file's header, the measurement
it created; and `rest-server.ts:2247`, 「Driven and reported on objectstack-ai#13214
(PRs objectstack-ai#13244, objectstack-ai#13258)」, now reads 「Measured in commits 889ec5b
(identity) and 3d10755 (tenancy)」: those PRs are exactly those two
commits.
- `objectstack-ai#14369` (3 sites): `a3d5724c8` (the liveness census it recorded) for
`rest-server.ts:1172` and `rest-sub-config-parse-not-cast.test.ts:48`.
`rest-server.ts:4092` said the zero read sites of `api.documentation` /
`api.responseFormat` came from 「the objectstack-ai#14369 census」, but `a3d5724c8`
explicitly left `api` out of that census; the zero was measured by
`53cbad9f7` (its changeset: no other read site for either key), which is
the anchor there.
- `objectstack-ai#11235` / `objectstack-ai#11242` / `objectstack-ai#10993`: `376c70f98` derives the discovery
`version` in metadata-protocol (objectstack-ai#11235), and `98ea3443f` is objectstack-ai#11242's own
squash, which landed the objectstack-ai#10993 ruling on `/health` and the dispatcher's
`/discovery`. So 「the objectstack-ai#10993 ruling … reaffirmed by objectstack-ai#11235/objectstack-ai#11242」 now
reads 「the objectstack-ai#10993 ruling, landed by commits 98ea344 and 376c70f」
(`rest-server.ts:4528`, `discovery-schema-conformance.test.ts:343-344`).
`objectstack-ai#10993`, `objectstack-ai#11292` and `objectstack-ai#11297` answer 200 and stay.
- `objectstack-ai#6037` / `objectstack-ai#6474`: one commit, `18189983d` (objectstack-ai#6474 is its PR number),
so 「(objectstack-ai#6037 / PR objectstack-ai#6474)」 became 「(commit 1818998)」.

**Wordings to check, each true of its commit:**
- A commit does not rule. Where a line said a number ruled, it now says
what the commit did with the ruling: 「the ruling commit 79c46da landed
says it does」, 「the ruling commit cf6e0a1 implemented fences it」, 「the
ruling commit 10220a7 implemented」, 「the 2026-08-20 ruling, landed as
commit 6ce58a7」, 「recorded in commit 6ce58a7's message (option A)」
(its message reads 「Ruled on objectstack-ai#10255 (2026-08-20, option A)」), and
「question was ruled on 2026-08-20 and landed as commit 6ce58a7」 where
the line said 「filed as objectstack-ai#10255」.
- `objectstack-ai#14541`'s contract review: 「the objectstack-ai#14541 contract review (condition N)」
now reads 「the contract review of commit 6d178a4 (condition N)」; that
commit's message lists the conditions it carries. 「objectstack-ai#14541's §4」 and
「objectstack-ai#14541 §5」 in
`error-response-generic-passthrough-object-parity.test.ts` are sections
of `error-response-structured-arm-door-parity.test.ts` (the file
`6d178a408` created), so they now name that file. 「measured on the
objectstack-ai#14541 branch」 reads 「on the branch that landed as commit 6d178a4」.
- A line that named a DEFECT by its number now says so: 「Before commit
9e04c3e the draft→active promotion door could not…」, 「Before commit
26f3588 the `/meta` doors decided ORGANIZATION SCOPE from the RAW
url」, 「the defect commit 2443bb4 fixed」 and 「would be the defect
commit 26f3588 fixed」.
- `objectstack-ai#13255`: 「As written for objectstack-ai#13255 this file repaired nothing」 reads 「As
first written (commit 43028a8)」, the commit that created the file and
answered the measurement; 「CONTEXT-LOST family (objectstack-ai#13255), still unruled」
reads 「first measured by commit 43028a8」 (the ruling on that family
never landed, which the line still says).
- `objectstack-ai#13214` in the identity file: 「the half objectstack-ai#13214 marks UNMEASURED」
reads 「the half left UNMEASURED until commit 889ec5b」, and 「objectstack-ai#13214
asks for an INDEPENDENT reproduction」 reads 「commit 889ec5b is an
INDEPENDENT reproduction」.
- 「the objectstack-ai#8885 sweep」 reads 「the sweep behind commit 30b1c63」, the
commit that registered the 9 codes the sweep found; 「objectstack-ai#14849 predicted」
reads 「The card behind commit 226e724 predicted」; 「the hazard objectstack-ai#13377
names」 reads 「the hazard commit e10cf34 was written to remove」; 「The
concrete harm objectstack-ai#6704 names」 reads 「removed」.
- Quoted ruling: `error-response-sandbox-arm-message.test.ts:340` sits
inside a verbatim ruling quote, so the commit stands in an editorial
bracket (「not from [commit 1c7adc7]'s list」), as PR objectstack-ai#20624 did.
- Two markdown tables in comments
(`meta-state-route-engine-outage.test.ts:76`,
`objectql-slot-consumer-census.test.ts:43`): the rewritten cell is wider
than its column, and its padding is reduced rather than widening the
four sibling rows.

## The sites left

**No deciding commit (3 sites, all in test files, so the census does not
see them):**
- `meta-object-owd-gate.test.ts:516` (objectstack-ai#8641): 「whether it should stay is
objectstack-ai#8641's question」, an open decision. The commit that added the citation
calls it a pointer to the open decision card, and no commit decides it.
- `rest-sub-config-parse-not-cast.test.ts:321` (objectstack-ai#14365): the
`z.partialRecord` question 「deferred to objectstack-ai#14365」 was never taken (`git
log -S partialRecord`); `b3a63d32c` made it moot by retiring the record,
which the other half of the same line now cites.
- `import-integration.test.ts:1043` (#14026): not deleted, TRANSFERRED.
The web endpoint answers 302 to objectstack-ai/objectui#10102, the REST
read follows the redirect, and the board enumeration does not list it,
so the census and the supplementary reading both class it
`allocated-but-absent`. The line says how an issue was raised; no commit
decides that, so form C has nothing to cite.

**String sites kept as tokens (127).** 126 are test titles and test-code
strings in 41 files. One is a non-test string: the `note` field of the
REST route ledger's `GET /api/v1/meta/object/:name/state/:field` row at
`rest-route-ledger.ts:290`, which ends 「(objectstack-ai#10179)」 (see Acceptance
notes).

## Mechanical guard: no code token moves

The check compares the TypeScript parser's leaf tokens (TypeScript
6.0.3, JSDoc nodes excluded, comments being trivia) of each touched file
at base `a186aea996` against the working tree at `93e4d69ba6`, over all
85 touched `.ts` files. Controls mutate the head text in memory only, so
nothing on disk moved for them.

- Real run: 272,653 base tokens, **0 files with a token change** (exit
0).
- Comment-insertion control (`error-response.ts`): 0 files changed (exit
0).
- Code-insertion positive control (a declaration in the same file):
DIFFER at token 0 (exit 1).
- String positive control (the first string literal past offset 2000 of
the same file, one character added inside it): DIFFER at token 26 (exit
1).

Line balance: every touched file is +N/−N (451/451), and every line
count is equal at base and head. A raw scan of the 86 changed files for
control bytes finds none (its positive control on a scratch file with a
U+0001 byte matches).

## Changeset

This change ships bytes, so a `patch` changeset for `@objectstack/rest`
is included, in PR objectstack-ai#20624's form and level. It says only that the
provenance comments were re-anchored.

Measured on the built package: `files[]` is `dist`, `README.md` and
`CHANGELOG.md`. After `pnpm --filter @objectstack/rest build`, the
rewritten docblocks reach `dist`: for example `53cbad9f7` appears 4
times in `dist/index.d.ts`, and `26f3588fb` 8 times and `b3a63d32c` 5
times in `dist/index.js`. The positive control, the unchanged sentence
「It was VALIDATE-ONLY from objectstack-ai#11637」 of the same `rest-server.ts` docblock
whose first line now reads 「[commit 53cbad9] The parsed output is
CONSUMED」, is in `dist/index.d.ts` beside it; a negative control phrase
appears nowhere.

## Gates (head `93e4d69ba6`)

This host has no `flock`, so `os-verify-lock.sh` ran in its declared
unlocked mode. Its disclosure, verbatim, from each locked run at this
head:

```text
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 47s · declare it in the PR body · pnpm --filter '@objectstack/rest...' build
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 102s (1m42s) · declare it in the PR body · pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=4
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 76s (1m16s) · declare it in the PR body · pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 2s · declare it in the PR body · pnpm --filter @objectstack/rest exec vitest run --project repo --maxWorkers=2
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 9s · declare it in the PR body · pnpm --filter @objectstack/rest typecheck
```

The branch merged `origin/main` once (`93e4d69ba6`, merging
`542670da6d`) before these runs, as the dispatch orders; `origin/main`
has not moved since (read at 11:19Z). The merge brought PR objectstack-ai#20626 and PR
objectstack-ai#20587 and touched none of this diff's files. The dependency closure was
built first (`pnpm --filter '@objectstack/rest...' build`, 26 packages),
then the whole workspace (`turbo run build --filter='./packages/*'
--filter='./packages/*/*'`, 71 tasks, 71 successful).

- **Tests:** `vitest run --project local`: 227 files, 4,382 tests
passed, 50 skipped. `--project repo` (which holds the touched
`meta-state-route-doc-spelling.test.ts`): 1 file, 8 tests passed.
Together they are all 228 test files of the package, so every touched
test file ran.
- **Typecheck:** `pnpm --filter @objectstack/rest typecheck` exits 0.
`tsc --listFiles` counts 28 `src` files (no tests) under `tsconfig.json`
and all 228 test files under `tsconfig.test.json`, which
`check:test-typecheck` judges: 0 files, 0 errors, 0 pinned signatures in
the ledger.
- **Lint:** the repo-wide `pnpm lint` (`eslint . --no-inline-config`)
exits 0 at `93e4d69ba6` (2026-09-29T11:19:30Z to 11:20:00Z). Not
narrowed.
- **Citation judging:** `node scripts/check-issue-citations.mjs --base
origin/main` exits 0: 19 citations judged across 14 files (18 resolve, 1
resolves as a pull request). These are the live numbers that stay on
rewritten lines. It defers `*.test.ts`, so the added-minus-removed count
over the whole diff covers the rest: 0 numbers added.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at `93e4d69ba6` derived 68
families. All 68 exit 0, and `--ran` over a record carrying each exit
code reads 「68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN」 (a derived
zero).
- `check:dual-build-cjs-loads` and `check:type-check-debt` first exited
3 (PREREQUISITE NOT MET, nothing measured) on the closure-only build;
after the whole-workspace build both exited 0.
- Among them: `check:doc-authoring`, `check:nul-bytes`,
`check:rest-log-declared`, `check:route-envelope`,
`check:system-context-census` (106 elevation read sites, the page's 102
symbols held) and `check:issue-citations` (self-test).
- **Artifact rosters:** 33 of the 36 non-self-test roster rows exit 0 at
`93e4d69ba6`, `check-changeset-fixed` (the one whose roster sits under
`.changeset/`) and `check:route-ledger-census` among them. The other
three, `check-closing-target-claim`, `check-partof-closing-keyword` and
`check-single-claim-paths`, answer 「NOT WIRED」 (exit 2) without a pull
request's context; they are run against this PR once it exists and
reported on the card.

## Hypotheses (measured first)

- **H0 holds.** The filtered census answers 191 dead sites at
`a186aea996` (186 lines, 14 files, 51 numbers), equal to the card's
count at `f11b5f20a2`: no net drift, although PR objectstack-ai#20601 (merged as
`eb4b17c346`, before this base) touches four files in `packages/rest`.
- **H1 holds.** After the rewrite the filtered census answers 0. The
supplementary reading leaves 3 test-comment sites, the three listed
above: an open decision, an untaken option and a transferred issue, none
with a deciding commit. No site was held for an open PR: the claim's
read and this stage's two reads of the open PRs' file lists (10:27:35Z,
7 open PRs; 11:30:34Z, 8 open PRs) found none touching `packages/rest`.
- **H2 holds, by the token guard.** A comment-stripped comparison of
every touched file (the parser's leaf tokens, JSDoc excluded) is empty,
and its code and string controls fire. The emitted `dist` is not
byte-identical, because the docblocks ship, which is why the changeset
is `patch`.

## Acceptance notes

- **Form D, not touched here.** 127 dead numbers stand inside string
literals: 126 in test titles and test-code strings, and one in the
`note` of the REST route ledger's legal-next-state row
(`rest-route-ledger.ts:290`, 「(objectstack-ai#10179)」), which is ledger data, not an
author-shown refusal. Ruling D (no number, the lesson in words) is a
string change outside this comment-only scope; the card already carries
a form-D stage for the lane.
- **A transferred issue among the 404s.** #14026 answers 302 to
objectstack-ai/objectui#10102 on its web endpoint. The census classes it
`allocated-but-absent` (deleted and transferred are only told apart
under `--probe-cause`), and `scripts/check-issue-citations.mjs`'s header
says the `transferred` arm has no positive specimen on this tree; this
is one. Noted, not filed.
- **The grammar does not read a slash-joined number.** `CITATION_RE`
refuses a `#` preceded by `/`, so the second number of `#A/#B` is never
judged. In `packages/rest/src` six such dead numbers stood at 10 comment
sites, all rewritten here; one more, `objectstack-ai#14389` in `objectstack-ai#14095/objectstack-ai#14389`, stands
inside a string
(`error-response-structured-arm-door-parity.test.ts:187`) and is kept.
The same shape PR objectstack-ai#20624 and PR objectstack-ai#20612 reported. Noted, not filed.
- **Outside the scope and the census surface.**
`packages/rest/vitest.config.ts:21` cites objectstack-ai#17853, which answers 404;
`packages/rest/test-typecheck-debt.json`, written by
`gen:test-typecheck-debt`, carries objectstack-ai#13470, objectstack-ai#13454, objectstack-ai#13377 and objectstack-ai#13378 in
its prose, all 404. Neither is under `src/**`. The other numbers in
`vitest.config.ts`, `tsconfig.json` and `tsconfig.test.json` answer 200.
- **Two comments stale on their own, not touched.** The anchor research
found `rest-server.ts`'s `api` docblock near `:1115` and the 「zero read
sites」 sentence at `:4092` both overtaken by `80153f5a4`, whose own
acceptance notes record it. This PR re-anchors their citations and
leaves their claims alone.
- **An attribution corrected by the anchor.** `rest-server.ts:4092`
credited its zero-read-site count to 「the objectstack-ai#14369 census」, which
(`a3d5724c8`) excluded `api`; it now cites `53cbad9f7`, the commit that
measured it.
- **Base.** One merge of `origin/main` (`93e4d69ba6`) before the `--base
origin/main` run, as the dispatch orders.

## Deviations

- Ten sites beyond the census's read grammar carry a slash-joined dead
number and are rewritten; six more lines are the other half of a
rewritten sentence (listed under What changed).
- The whole-workspace build ran with `--concurrency=4`, not 2, to stay
inside the ten-minute foreground cap on this host; it took 1m42s.
- Anchor research for 33 of the 77 numbers ran in three read-only
research subagents; every proposal was verified here against the
commit's message or diff, and the wording of each changed line was
reviewed and corrected by hand in a second pass.
- Commit trailers are AGENTS.md's model-free pair (`Claude-Session` plus
`Co-authored-by: Claude`), and the pre-push trailer check passed on
every push. The merge commit carries git's default message.

---
_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/l tests tooling

Projects

None yet

1 participant