Skip to content

fix(rest): /import reads a time cell by core's one time rule, so an exported 10:00:00.250 re-imports (#20722) - #20829

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20722-import-time-fraction
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20722-import-time-fraction

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #20722
Clause-②: no (narrowing)

POST /api/v1/data/:object/import now reads a time cell by @objectstack/core's one time rule. That is the rule the write door has asked of a written time since #20671. So the 10:00:00.250 that /export writes for a time with milliseconds re-imports as itself. A cell that @objectstack/core's time rule reads is now admitted exactly when the write door admits it. The one reading the import keeps and the write door refuses is the year-first date-time (#20534), named below. A fraction is admitted on both. A Z / offset suffix on a time of day is refused on both, with the same field code, invalid_time.

Base 96e724475 (claim) → merged with origin/main at d2b188fb5 → 0e624ca2b → patch round 1 99eb702d9 (changeset only).

Reproduced first, then after

Measured through the routes: POST /api/v1/data/:object for the write door, POST .../import for the import, GET .../export for the round trip. The process ran in TZ=America/New_York. Backends were InMemoryDriver, SqlDriver over SQLite, and SqlDriver over a private PostgreSQL 16.13 at Asia/Shanghai. All three answered alike on every row.

time value write door (unchanged here) /import at base /import now
10:00:00.250, 23:59:59.999 (the card) 201, as written row failed, invalid_date, Clock: "10:00:00.250" is not a valid time as written
10:00:00.5, 10:00:00.000 10:00:00.500, 10:00:00 row failed, invalid_date the write door's value
2026-07-15T10:00:00.250Z, 2026-07-15 10:00:00.250 10:00:00.250 10:00:00, fraction silently dropped 10:00:00.250
9999-12-31T23:00:00-02:00 (UTC year 10000) 400 invalid_time stored 01:00:00 row failed, invalid_time
10:00Z, 10:00+08:00, 10:00:00.250Z, +010000-01-01T10:00:00Z, 25:00, 9:00, 07/15/2026 10:00 400 invalid_time row failed, invalid_date row failed, invalid_time
10:00:00, 10:00, 2026-07-15T18:00:00+08:00 10:00:00 10:00:00 unchanged
export → import (CSV and JSON) of 10:00:00.250, 23:59:59.999 — both rows failed stored as exported
export → import of the 10:00:00 control — stored as exported unchanged

Base readings: the new pin run on the unfixed tree gave 48 failed / 30 passed on SQLite plus live PostgreSQL. On memory, an uncommitted copy of the pin with InMemoryDriver gave 31 failed / 26 passed at base. For the memory base leg, the base import-coerce.ts was restored under a trap. The restore was proved by git hash-object equal to the HEAD blob 944f05589 and an empty git diff HEAD. At head: 78 / 78 on SQLite plus PostgreSQL, and 57 / 57 on memory.

The change

  • packages/rest/src/import-coerce.ts
    • The private TIME_OF_DAY pattern is deleted. It had no fractional part.
    • A new readTimeOfDayCell(s) does two things. Its verdict is isUninterpretableTemporalComparand('time', s). Its stored value is temporalStorageForm(s, 'time'). A {placeholder} is refused by classifyFilterToken, the same readable gate the write door applies. ⛔ There is no second regex.
    • temporalStorageForm alone is not a verdict, measured on core's dist. It is total and never throws. For junk it hands the value back unchanged. But it also returns a clock for values the verdict refuses: 07/15/2026 10:00 gives 10:00:00 through Date.parse in the host zone, and 2026-02-30T10:00:00Z gives 10:00:00 after rolling the date over.
    • A time cell that core's rule refuses is read in the year-first form alone (2026/7/15 9:00 gives 09:00:00). That is the one reading the import has beyond the write door, from the maintainer ruling on /import: parseDateCell emits a date cell year below 1000 unpadded (0500-01-01 → 500-01-01), so after PR #20524 a valid ISO date cell is refused per row as invalid_date, and before it a non-day was stored #20534. It is pinned as such. The ISO reader (readIsoTemporalCell) no longer answers for a time cell. Core's rule is the whole verdict there.
    • A refused time cell now reports invalid_time, the write door's code for the same value, where it reported invalid_date. date / datetime keep invalid_date. The row sentence (import_invalid_time) is unchanged.
  • packages/runtime/src/dispatcher-error-vocabulary.ts: one foreign-vocabulary row for the new invalid_time stamp at import-coerce.ts. It is the twin of that file's existing invalid_date row. check:dispatcher-error-vocabulary was red without it. The table is not in @objectstack/runtime's published output. On a fresh build, its text has 0 hits in packages/runtime/dist, against a positive control of 105 hits for HttpDispatcher. So runtime takes no changeset.
  • packages/rest/src/import-date-cell-iso-real-day.test.ts (the /import: parseDateCell emits a date cell year below 1000 unpadded (0500-01-01 → 500-01-01), so after PR #20524 a valid ISO date cell is refused per row as invalid_date, and before it a non-day was stored #20534 pin): its one t row (07/15/2026 10:00) now expects invalid_time. That is the code the write door gives the same value. d and dt rows keep invalid_date.
  • New pin packages/rest/src/import-time-cell-fraction.test.ts:
    • the reader on two host zones;
    • both doors cell for cell on SQLite, plus PostgreSQL where OS_TEST_POSTGRES_URL is set (a named skip otherwise);
    • the dry run;
    • export → import in CSV and JSON of 10:00:00.250, 23:59:59.999 and the 10:00:00 control.
  • .changeset/20722-import-time-fraction.md: @objectstack/rest: minor, Clause-②: no (narrowing), a **BREAKING** banner and an ADR-0087 not-required (no-migration-prescription) disposition (patch round 1, 99eb702d9).

Clause-② reading: no (narrowing)

  1. A narrowing half. The import now refuses a time cell that is an ISO instant whose UTC year has no four-digit spelling. For example, 9999-12-31T23:00:00-02:00 was stored as 01:00:00. The write door has refused that value since record validator: a time field written "+010000-01-01T10:00:00Z" is stored verbatim (201 on SQLite, 500 on PostgreSQL), and "10:00Z" reads back differently per backend — the write-side twin of #20480 #20671. This is the half the claim's Clause-② reading asked to hear about. The other half widens toward what the write door already admits: fractions, and a zone-naive 2026-07-15 24:00 into a time, which is now 00:00:00 on both doors.
  2. The row code for a refused time cell moves from invalid_date to invalid_time. Triage 5899707588 asked for this: "refused with the same code the write door gives". No code is minted. invalid_time is already in FieldErrorCode. ADR-0114 records that nothing branches on a field code: objectui reads code only as a last-resort text.

The seat took option B: body line 2 and the changeset both read Clause-②: no (narrowing). The options are in the os-dev-report on #20722.

PM hypotheses

  • H1 held. At 96e724475 import-coerce.ts:273 was the pattern the card names, and :506 was its only use. The round trip failed as described, on all three backends.
  • H2. canonicalTimeOfDay is a private function in packages/core/src/utils/temporal-storage-form.ts. It is exported nowhere. The public doors are temporalStorageForm(value, kind) and isUninterpretableTemporalComparand(kind, value). Both come from @objectstack/core's root: index.ts re-exports temporal-storage-form.js and temporal-comparand.js. packages/rest already depends on @objectstack/core and already imported temporalStorageForm. No core edit was needed. temporalStorageForm never throws and hands back unchanged what it cannot read, but it is not a verdict (see above).
  • H3. A live PostgreSQL 16.13 ran in this container: a private cluster on a random port, at Asia/Shanghai, started and stopped by this run. No CI job provisions PostgreSQL for @objectstack/rest. The Temporal Conformance (live PG + MySQL) job runs driver-sql, metadata-protocol and one runtime file. So the PostgreSQL cell of this pin is a named skip in CI, as it is for the existing data-temporal-write-real-day-iso.test.ts.
  • H4. Measured on dist:

Tests

On head 0e624ca2b unless stated:

  • pnpm --filter @objectstack/rest exec vitest run --project local, with live PostgreSQL, on the merged tree 992b08a49: 232 files, 4588 passed / 30 skipped. 0e624ca2b differs from it only in the runtime table.
  • pnpm --filter @objectstack/rest typecheck: exit 0. check:test-typecheck OK, 0 debt.
  • pnpm --filter @objectstack/runtime exec vitest run --project repo src/error-envelope.conformance.test.ts: 53 / 53. This file is the only importer of the edited table (git grep).
  • pnpm --filter @objectstack/runtime typecheck: exit 0.
  • The new pin, verbose: 78 / 78. That is 21 cases on PostgreSQL, 21 on SQLite and 36 reader cases.
  • require('@objectstack/rest') and require('@objectstack/runtime') from the freshly built CJS dist load. dist/index.cjs of rest carries the new reader.

Gates

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran on 0e624ca2b gives 62 derived. 61 exited 0. One is NOT MEASURED: check:dual-build-cjs-loads, exit 3, PREREQUISITE NOT MET. It needs every package's dist, and this run built the rest and runtime closures only. The targeted require reading above covers the two packages this diff touches.

pnpm lint (the whole repo, eslint . --no-inline-config) exited 0 on 0e624ca2b.

The derivation's last read printed a stale-tree note: origin/main has since moved past d2b188fb5 by .github/**/half-state-patrol* and four docs / objectql commits.

Acceptance notes

Noted, not filed:


Generated by Claude Code

…xported 10:00:00.250 re-imports

The import's time reader was a private pattern with no fractional part, so
the 10:00:00.250 that /export writes for a time with milliseconds failed its
row as invalid_date on every backend. It now asks the rule the write door
asks of a written time: isUninterpretableTemporalComparand('time', ...) for
the verdict and temporalStorageForm(..., 'time') for the stored value. The
year-first date-time stays the import's own reading. A refused time cell
reports the write door's code for the same value, invalid_time.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…n memory, SQLite and PostgreSQL

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

import-coerce.ts now refuses a time cell with the write door's field code,
invalid_time, through coerceError; the table carries the same
foreign-vocabulary row its invalid_date twin has (ADR-0114 field catalog,
never error.code).

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
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 30, 2026
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/runtime, touching 8 documentable anchor(s).

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

  • content/docs/api/error-catalog.mdx (via invalid_date (literal, a string literal in coerceFieldValue), invalid_time (literal, a string literal in UNREGISTERED_CODE_SITES; a string literal in coerceFieldValue))
  • content/docs/protocol/objectql/types.mdx (via invalid_time (literal, a string literal in UNREGISTERED_CODE_SITES; a string literal in coerceFieldValue))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via invalid_date (literal, a string literal in coerceFieldValue))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 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 — 33 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 73155fedcacc215565c4eef6d9899977e0707010 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 73155fedcacc215565c4eef6d9899977e0707010

⚠️ 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 73155fedcacc215565c4eef6d9899977e0707010 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… — minor, Clause-② no (narrowing), BREAKING banner and ADR-0087 disposition

The reader now refuses a time cell that is an ISO instant with no
four-digit UTC year, as the write door does, and a refused time cell's
row code moves from invalid_date to invalid_time. The changeset states
both, ships as minor under the launch-window convention, and records that
the import keeps a non-zero fraction where #20671's entry said HH:MM:SS.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 99eb702d917ffbf0c2a58e91d60e8aa59436b86d
Local-runs: none

Inputs read: card #20722 (body; triage 5899707588; the claim 5906562422 as amended; os-dev-report 5908478016 and 5908614045), PR #20829 (body, file list, net diff against merge-base d2b188fb5: 6 files, +430/-20, matching the PR file list; the merge commit 992b08a49 brings no foreign hunk), the check-runs on the head, and at the merge-base .changeset/20671-time-write-zone-less.md, docs/adr/0087-metadata-protocol-upgrade-contract.md, packages/core/src/utils/temporal-storage-form.ts, packages/core/src/utils/temporal-comparand.ts, packages/objectql/src/validation/record-validator.ts (the write door's time arm), packages/spec/src/api/errors.zod.ts, packages/runtime/src/dispatcher-error-vocabulary.ts, packages/runtime/tsup.config.ts, packages/rest/src/import-prepare.ts, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, .github/workflows/pr-automation.yml and lint.yml.

① Derived judgments

(a) The import's time cell against the write door — right, with one named residual. The new readTimeOfDayCell(s) refuses on classifyFilterToken(s) !== null || isUninterpretableTemporalComparand('time', s) and returns temporalStorageForm(s, 'time'). The write door's time arm at the merge-base (record-validator.ts 1310-1313) admits on readable && !isUninterpretableTemporalComparand('time', value) with readable = value instanceof Date || (typeof value === 'string' && classifyFilterToken(value) === null), and the drivers store temporalStorageForm(value, 'time'). For a string cell that is the same predicate and the same stored form, so equality holds by construction, not only by measurement. Checked cell by cell against core's readsAsWallClock / readsAsInstant / keepsTimeOfDay and the base's TIME_OF_DAY and readIsoTemporalCell:

  • Newly admitted by the import and admitted by the write door: 10:00:00.250, 23:59:59.999, 10:00:00.5 (stored .500), 10:00:00.000 (stored 10:00:00); the fraction of an ISO instant or a zone-naive stamp (2026-07-15T10:00:00.250Z, 2026-07-15 10:00:00.250, both now 10:00:00.250 where the base's utcClock and wall path dropped it); a zone-naive 2026-07-15 24:00, which the base's readIsoTemporalCell refused (an hour past 23) and core reads through Date.parse('...T24:00Z') as the next midnight, 00:00:00 — the write door admits it by the same rule since a string passes readable. Right. The dev measured that last cell on core's verdict and form only, not through a route write; it holds by construction and the changeset says it in prose, not in the route table. Acceptable.
  • Newly refused by the import and refused by the write door: an ISO instant whose UTC year has no four-digit spelling (9999-12-31T23:00:00-02:00): the base's Date.parse path stored 01:00:00; core's keepsTimeOfDay hands it back unchanged, so isUninterpretableTemporalComparand is true; readYearFirstCell cannot match a T, so the row fails invalid_time. The write door has refused it since the record validator: a time field written "+010000-01-01T10:00:00Z" is stored verbatim (201 on SQLite, 500 on PostgreSQL), and "10:00Z" reads back differently per backend — the write-side twin of #20480 #20671 change. Right. This is the only accept-set narrowing the diff delivers: base's ISO_TEMPORAL_CELL and core's ISO_DATETIME_WRITE_FORM name the same spellings, both require a real calendar day, and every other divergence between them widens toward the write door (the fraction, 24:00).
  • A Z / offset suffix on a time of day (10:00Z, 10:00+08:00, 10:00:00.250Z) and +010000-01-01T10:00:00Z: refused by both doors, and — precisely — NOT newly refused by the import: the base's TIME_OF_DAY and readIsoTemporalCell refused them too, with invalid_date. What moves for these cells is the row code only. The PR body's table and the changeset's table say exactly that. Right.
  • A number cell: the import reads String(raw) through the same rule and refuses (invalid_time); the write door's readable refuses a number. Same verdict, different mechanism. Right.
  • Residual difference, named and pinned: the two doors are NOT cell-for-cell equal. A time cell core's rule refuses falls through to readYearFirstCell (2026/7/15 9:00 stored 09:00:00; a bare year-first day stored 00:00:00), which the write door refuses invalid_time. That reading predates this PR (/import: parseDateCell emits a date cell year below 1000 unpadded (0500-01-01 → 500-01-01), so after PR #20524 a valid ISO date cell is refused per row as invalid_date, and before it a non-day was stored #20534, maintainer ruling), is kept deliberately, is named in the PR body, the changeset and the reader's doc comment, and is pinned in the new test ("reads the year-first date-time (/import: parseDateCell emits a date cell year below 1000 unpadded (0500-01-01 → 500-01-01), so after PR #20524 a valid ISO date cell is refused per row as invalid_date, and before it a non-day was stored #20534) as its wall clock, which the write door refuses"). A second residual is dormant: a Date object reaching parseDateCell(…, 'time') takes utcClock (drops milliseconds, ignores the four-digit-year rule) where the write door takes core's rule; no public door hands the reader a Date (JSON carries none; import-prepare.ts:117 renders an xlsx Date to naive text through xlsxDateToNaiveCell before coercion). So the PR body's opening sentence "The two doors now agree cell for cell" and the changeset's "A cell is admitted exactly when the write door admits the same value" are each overbroad as one sentence and each corrected by the same text within a paragraph ("the one reading the import has that the write door has not"). Judged: the delivered behaviour is right; the summary sentences overreach and their own texts carry the correction. Not a contract breach; the seat may tighten the two sentences to "a cell core's rule reads is admitted exactly when the write door admits it" if it wants the CHANGELOG line to stand alone.

(b) The row code invalid_date to invalid_time and the vocabulary row — right. invalid_time is a member of FieldErrorCode at the merge-base (packages/spec/src/api/errors.zod.ts:254, the closed ADR-0114 D2 catalog); coerceError takes code: FieldErrorCode, so no code is minted. The move is what triage 5899707588 required ("refused with the same code the write door gives"); date / datetime keep invalid_date; the three sentence keys are unchanged; the #20534 pin's single t row now expects invalid_time and its d / dt rows still invalid_date. The packages/runtime/src/dispatcher-error-vocabulary.ts +14 hunk adds one UNREGISTERED_CODE_SITES row (code: 'invalid_time', file: packages/rest/src/import-coerce.ts, shape: 'objlithelper', door: 'none', verdict: 'foreign-vocabulary'), the twin of the file's existing invalid_date row at base lines 1022-1033, which check:dispatcher-error-vocabulary requires for a new lowercase literal at a stamp site. It is not a public surface change: the table is imported by src/error-envelope.conformance.test.ts only (no product import; src/index.ts does not reach it) and runtime's tsup entry is src/index.ts alone with splitting: false, so the row is not in @objectstack/runtime's published output — which is why runtime correctly takes no changeset. Accurate as to the load-bearing claims (a FieldErrorCode by construction; never error.code, so no ADR-0112 ledger row is owed). One imprecision, inherited verbatim from the twin row and not minted here: its why says the code "reaches ApiError.details.fields[].code", while an import row's coerce error reaches the import summary's results[].code (import-runner.ts:791). Noted; not this PR's to repair, and it does not change the verdict the row records.

(c) The changeset .changeset/20722-import-time-fraction.md — accurate. Frontmatter @objectstack/rest: minor; summary fix(rest)!: … naming the fix and the code move; a bare line-initial Clause-②: no (narrowing) on line 7; exactly one ADR-0087 marker (HTML comment, invisible in the published CHANGELOG). The **BREAKING** banner names exactly the narrowing the diff delivers — the ISO instant whose UTC year has no four-digit spelling, with the measured base value 01:00:00 and the new invalid_time row — plus the row-code move, and nothing else; checked above that no other accept-set narrowing exists. The route table's rows each correspond to a pinned case (ADMITTED / REFUSED / the year-first row / the dry run / the export round trip) in import-time-cell-fraction.test.ts. "Who is affected" is right (a caller whose file carries such an instant; a client matching code). The supersession note is true of both doors: the write door has stored HH:MM:SS.fff when non-zero since before #20671 (core's time storage form; #20671's own "Unchanged" list reads 10:00:00.250 back as written), and the import now keeps it too; the #20671 sentence's other half ("already refused a zone-suffixed time-of-day cell") stays true, and the base changeset is untouched, which check-empty-changeset requires. Tracker numbers: (#20722) on the summary line is the repo's changeset convention (the #20671 entry is spelled the same), and #20671 in the body is a cross-card citation in the same shape the pending stock already carries (20549-… cites #20480, 20553-… cites #20611, 20647-… cites #20644); check:doc-authoring (runtime strings) is not implicated. The prose claims nothing the diff does not deliver, with the one overbroad sentence judged under (a).

(d) The export to import round trip — right. /export writes a time cell as the stored string (no time arm in export-format.ts; the pin asserts the exported JSON and CSV cells equal the stored 10:00:00.250, 23:59:59.999, 10:00:00), and the pin re-imports them in both formats and asserts the re-imported rows store the same wall clock, with the 10:00:00 control. The SQLite cell of that pin runs in CI; the PostgreSQL cell is a named skip without OS_TEST_POSTGRES_URL (dev-reported green on a live PG 16.13); the InMemoryDriver leg was measured by an uncommitted copy, as the test header discloses.

Other derived facts: classifyFilterToken is exported from @objectstack/spec/data (context-tokens.zod.ts:355); isUninterpretableTemporalComparand and temporalStorageForm come from @objectstack/core's root, which packages/rest already imported — packages/core and packages/spec untouched, as the claim required; no second regex.

② Semver level

  • (narrowing) is the right arm. Clause-② asks about a new key on a published payload: none. The import's own accept set shrinks (the base stored 9999-12-31T23:00:00-02:00 as 01:00:00; the head refuses it), so the change narrows a published route's accept set. That the write door already refused the value does not make the import's narrowing a no alone: AGENTS.md makes the arm a statement of fact ("(narrowing) is BREAKING"). The dev's option B was right and the seat adopted it.
  • minor is the right level. Under the launch-window rule (check-changeset-no-major.mjs: "a breaking change ships as minor"; ADR-0087 main body: "Pre-GA, a metadata-facing retirement or break ships minor, carrying the BREAKING banner and its ADR-0087 disposition entry") major is refused, and a no (narrowing) does not stand the level axis down (the gate's [finding] An accept-set narrowing owes a **BREAKING** banner in core but not in platform-objects — and the ADR-0087 classifier reads the banner #16421 lane), so the moved published package owes at least minor. @objectstack/rest is that package; patch would have been refused, major too. The Check Changeset run on the head, which runs the real scans of check-changeset-no-major (with the PR event payload's Clause-② line — the body leg the dev could not run locally), check-empty-changeset and check-adr-0087-registration against the merge base, concluded success.
  • The ADR-0087 disposition not-required (no-migration-prescription) is right by the ADR's own text. The ledger exists for what objectstack migrate meta, spec-changes.json and the upgrade guide can project: authorable keys, spellings and stored metadata shapes. This diff moves none (packages/spec untouched; an import cell is caller input; the field code moves between two existing catalog members and ADR-0114's "Nothing branches on the value" section records that no product code branches on a field code). The other five categories are closed on the facts the marker states: unpublished (rest publishes), already-registered (no id covers an import cell check), runtime-interface-only / type-surface-only (no TS declaration moves), and registered would write a ledger id for a change the metadata upgrader cannot project — the false-data shape the ADR's fix(service-package): classify a publish driver fault as 5xx and stop returning driver text as caller data (#8131) #8277 table refuses. The body carries no FROM/TO rewrite line (the code move is prose, no arrow between backticked operands), so the category's one mechanical check holds and the gate admitted it. No migration prescription is owed: there is no authorable FROM/TO; the consumer-facing guidance a narrowing owes is in the banner's "Who is affected".

③ Boundary flags

Report 5908478016 — open_questions[0] (Clause-② A or B): resolved. The seat took B; head 99eb702d9 carries minor, the banner, the marker and the bare Clause-②: no (narrowing) on changeset line 7, and PR body line 2 reads the same. Judged right in ②.

Report 5908478016 — deviations:

  1. PostgreSQL cluster under /tmp, not the scratchpad — environment only (the scratchpad is 0700 root); started and stopped by the run, directory removed; nothing of it is in the diff. Answered.
  2. File surface beyond the claim's named lines — the claim was amended in place to adopt each (its "Added in the same round" block): the time kind's ISO fall-through, the coerceFieldValue code, the two test files, the one vocabulary row. Every one of the diff's six files is inside the amended surface; core and spec untouched. Answered.
  3. Merged origin/main (d2b188fb5) before opening the PR — sanctioned (AGENTS.md multi-agent §7 names git merge origin/main; §9 prescribes the refresh, which the dev ran: install, rebuild, suite and typecheck rerun). The merge commit brings no foreign hunk: the net diff against the merge base is exactly the six files. Answered.
  4. A harness order assumption in the pin fixed before the base reading — product code untouched between the runs. Answered.
  5. One suite run moved to the background, exit code read from its log — tooling, nothing left running. Answered.
  6. Model-free trailer pair instead of the harness reminder's model-named line — right: AGENTS.md (the repo's source of truth, which outranks a harness reminder) prescribes Claude-Session: plus Co-authored-by: Claude; the four authored commits carry exactly that, and the merge commit carries none, which check-commit-card-trailers exempts by its own self-test. Answered.
  7. Worktree cleanup after the comment — housekeeping. Answered.

Report 5908478016 — out_of_scope_findings:

  1. parseDateCell(Date, 'time') still takes utcClock — dormant, confirmed: no public door hands the reader a Date (import-prepare.ts:117 renders an xlsx Date to naive text first). Leave it; carrier none; the PR's Acceptance notes carry it. Not escalated.
  2. A zone-suffixed time cell gets the import's own sentence, not invalid_time_zoned — the contract (the wire code invalid_time) agrees on both doors; the sentence is a rendering variant the write door keeps private in objectql. Polish, not a defect with reach. Not escalated.
  3. record validator: a time field written "+010000-01-01T10:00:00Z" is stored verbatim (201 on SQLite, 500 on PostgreSQL), and "10:00Z" reads back differently per backend — the write-side twin of #20480 #20671's pending changeset sentence "turns a time cell into HH:MM:SS" — carried by the seat through option B: the new entry states the supersession, the base changeset is untouched (modifying it is refused by check-empty-changeset). Answered.
  4. The PostgreSQL cell is a named skip in CI — pre-existing for every rest PG cell (data-temporal-write-real-day-iso.test.ts records the same); the Temporal Conformance job covers driver-sql, metadata-protocol and one runtime file, not rest. The SQLite cell runs in CI; PG ran green locally. Not this card's to fix; not escalated.

Report 5908478016 — gates: check:dual-build-cjs-loads NOT MEASURED locally (needs every package's dist) — answered by the head's Type Check · workspace run (the job that runs it), concluded success.

Report 5908614045 — open_questions and out_of_scope_findings: none.

Report 5908614045 — deviations:

  1. PR body lines 40, 42 and 47 not edited by the dev — the seat has since applied the suggested replacements (body updated 09:47:25Z): line 40 names minor, the banner and the disposition at 99eb702d9; the Clause-② section reads "The seat took option B"; no stale "patch" or "carries no BREAKING banner" text remains. Answered.
  2. --help is not a flag of check-adr-0087-registration.mjs — the category list was read from CATEGORIES in the source, which is what the gate itself pins against the ADR. Answered.
  3. Worktree cleanup; the branch stays on the remote at 99eb702d9. Answered.

Report 5908614045 — gates: check-changeset-no-major's PR-body leg NOT MEASURED locally — answered by the head's Check Changeset run, concluded success.

Check-runs on 99eb702d9, latest per name, converged: 34 names, all completed, none failed. SUCCESS (29): Build Core · Check Changeset · Check Documentation Links · Dogfood Regression Gate and its three shards · Dogfood Verify CLI · filter · Flag docs affected by code changes · Governed Surface Queue Guard · Lint & Repo Gates (runs check:dispatcher-error-vocabulary, check:doc-authoring and the repo gates) · No other open PR may claim the same issue · No other open PR may claim the same single-writer path · Part-of PR must not also close its card · Temporal Conformance (live PG + MySQL) · Test Core and its six shards · The card this PR closes must claim this branch · Type Check consumer gates · Type Check debt ledger · Type Check source gates · Type Check workspace · TypeScript Type Check. SKIPPED (5): Auto Label · Build Docs · Check PR Size · Console Pin Gate · Packed-tarball smoke (opt-in). Those conclusions are the gate verdicts for every derived family; nothing was run locally.

Implemented-by: claude/issue-20722-import-time-fraction
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 10:06
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 22e584c Sep 30, 2026
50 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20722-import-time-fraction branch September 30, 2026 10:26
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

2 participants