Skip to content

fix(objectql)!: a date string is written in its YYYY-MM-DD form, or refused with VALIDATION_FAILED / invalid_date (#20481) - #20524

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20481-date-write-iso-only
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20481-date-write-iso-only

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20481

Clause-②: no (narrowing)

A date field's write door now stores a day or refuses. A date string is accepted only when it carries a leading YYYY-MM-DD, the date storage rule's own reading, which temporalStorageForm collapses to that day. Every other spelling is refused with VALIDATION_FAILED / 400 (invalid_date) before any driver write. This executes triage's ruling on the card (5875651303): "Direction (triage's call, as the card asks): refuse." No spelling is canonicalised: "07/08/2026 is ambiguous between locales, and a guess stores a wrong day silently."

Measured head: 4c5740d2d. It is the last change commit 9f0d29239 plus a true merge of origin/main fb194c70e (two parents). The merge brought PR #20517 (packages/rest/src/import-coerce.ts and a test). It touched no file under packages/objectql, packages/core or packages/drivers/driver-memory.

The change

One source file changes: packages/objectql/src/validation/record-validator.ts, the date / datetime arm, +26 / −3.

  • For a date string, the arm also asks isUninterpretableTemporalComparand('date', value) from @objectstack/core. The engine's temporal-comparand door already refuses these same strings on where with that predicate. It trims the string and tests for a leading YYYY-MM-DD, the same reading temporalStorageForm makes. So the write door and the comparand door agree on which date strings the rule reads, and no second copy of the leading-day regex is written.
  • Date.parse readability and the 0001..9999 year range still apply on top.
  • A Date, a number, every datetime and every time go through the arm exactly as before.
  • packages/core is not edited and gains no export, so the claim's Clause-②: no (narrowing) holds and only @objectstack/objectql carries a changeset.

Before and after, at the public door

POST /api/v1/data/:object, then a read-back. The process ran in America/New_York. PostgreSQL 16.13 was a local server at Asia/Shanghai, DateStyle ISO, MDY. Readings are from base 0bbe4005e and head.

written to a date memory SQLite PostgreSQL head, all three
"2026/07/15", "07/15/2026", "15 July 2026", "2026-7-15", "2026.07.15", "July 15, 2026" 201, read back verbatim 201, read back verbatim 201, "2026-07-15" 400 invalid_date
"07/08/2026" 201, verbatim 201, verbatim 201, "2026-07-08" (ISO, DMY reads August 7, measured in psql) 400 invalid_date
"+002026-07-15" 201, verbatim 201, verbatim 500 DATABASE_ERROR 400 invalid_date
"2026-07-15", "2026-07-15T10:00:00Z", "2026-07-15 10:00", " 2026-07-15" 201, "2026-07-15" the same the same unchanged
"20260715", "15/07/2026" 400 400 400 unchanged
an epoch-millisecond number 400 invalid_date 400 400 unchanged
a Date 201, its UTC day 201 201 unchanged

The dispatch's hypotheses

  • H1 holds. Memory and SQLite answered 201 and read "2026/07/15" back verbatim. PostgreSQL does not store it verbatim. Its DATE input parser reads the spelling by the server's DateStyle, so the stored day is a property of the server's configuration. "07/08/2026" is July 8 under MDY and August 7 under DMY. A spelling it cannot parse ("+002026-07-15") was a 500.
  • H2. Today readable is Date.parse-readable. These pass today and are refused now: "2026/07/15", "07/15/2026", "15 July 2026", "2026-7-15". These have a leading YYYY-MM-DD, are accepted and are stored as "2026-07-15" before and after: "2026-07-15T10:00:00Z", "2026-07-15 10:00", " 2026-07-15". The last one is accepted because the rule trims. "20260715" has no Date.parse reading, so it was refused at the base and still is. The predicate is core's exported isUninterpretableTemporalComparand (its date branch, private readsAsCalendarDay in temporal-comparand.ts), so no new predicate was added.
  • H3, producers. No shipped producer generates a non-ISO date string on the shipped composition. The one path that forwards raw text runs only when /import is unreachable. Details are in the census section below.
  • H4, the datetime arm. It does not have this defect: no spelling was stored verbatim on any of the three drivers. It has a different one, reported and not edited: a zone-naive non-ISO spelling is read in the server process's zone. See Acceptance notes.
  • H5 holds. A Date and an epoch number keep the behaviour they had. A Date is stored as its UTC day. A number is refused with invalid_date on the write door, as it was at the base. Both are pinned as controls.

Producer census (H3)

Read at objectstack 0bbe4005e and objectui origin/main 797a30f.

  • objectui DateField (packages/fields/src/widgets/DateField.tsx): an input of type date, and onChange emits e.target.value, which is YYYY-MM-DD or empty. ISO.
  • objectui calendar and gantt drag / quick-create writers (plugin-calendar/src/ObjectCalendar.tsx toStoredDateValue / toMovedDateValue, plugin-gantt/src/ObjectGantt.tsx toStoredDateValue): toDateInputValue or toISOString().slice(0, 10) for a date field. ISO.
  • Server /import cell reader (packages/rest/src/import-coerce.ts parseDateCell): it always returns YYYY-MM-DD for a date cell. Both the bulk path and the per-row path of import-runner.ts call coerceRow first (:775). ISO. Not edited.
  • objectui Import Wizard's legacy per-row fallback (plugin-grid/src/ImportWizard.tsx legacyImport → validateRow :561, validateValue :486): it sends the raw cell text, checked only by Date.parse. It runs only when the data source has no importRecords, or the client has no data.import (isUnsupportedImport :626). objectui's data-objectstack adapter implements importRecords (src/index.ts:4430). On this path a non-ISO cell is now a per-row refusal instead of a stored non-day. See open question 1 in the report.
  • Seeds under examples/: CEL daysAgo(n) / daysFromNow(n) (a Date, normalised to YYYY-MM-DD) and ISO literals. A regex census of non-ISO date literals over examples/** finds 0, with a control regex for ISO literals finding hits in 6 files.
  • AI / MCP writers: packages/mcp/src/mcp-http-tools.ts create_record / update_record forward data values unchanged, as z.unknown(). They are pass-through, and a model-written non-ISO date now gets the 400 back as the tool error.

Tests

  • packages/objectql/src/engine-date-write-iso-only.test.ts (new, 4 tests, recording driver): 8 refused spellings plus 5 already refused (including {today} and an epoch number), on insert, update, a multi-row update and engine.validate. Each asserts code VALIDATION_FAILED and fields [placed_on, invalid_date], with zero driver writes. The accepted leading-day spellings and a Date reach the driver. One test holds both doors to one verdict per string: validate validity equals where acceptance.
  • packages/rest/src/data-date-write-iso-only.test.ts (new, 3 tests per cell): POST and PATCH over a real SqlDriver. SQLite always runs. Live PostgreSQL runs where OS_TEST_POSTGRES_URL is set and is a named skip otherwise. Each refused spelling asserts status 400, code VALIDATION_FAILED and the field code, with no write. There is an epoch-number control, and the ISO spellings read back as "2026-07-15".
  • packages/drivers/driver-memory/src/memory-20481-date-write-iso-only.test.ts (new, 2 tests): each spelling the door admits, and a Date, is stored as 2026-07-15, found by it, and ordered after 2026-07-14 on InMemoryDriver.

Reverse verification:

  • objectql: the fix was committed first. scripts/ablation-replace.mjs put the base condition back in record-validator.ts (anchor 1 → 0, blob b5c6bb81dc72 → d501d34ad6bc), and the new file went 3 failed / 1 passed. It passes 4/4 at head. The tool restored the file: blob equals HEAD and git diff HEAD is empty.
  • REST: the rest suite reads @objectstack/objectql from dist. Against the base dist (readsAsDay count 0), the new file went 2 failed / 4 passed on SQLite and live PostgreSQL, because "2026/07/15" answered 201. After pnpm --filter @objectstack/objectql build (count 2), it passed 6/6.

Suites:

  • objectql: at 9f0d29239, 332 files / 6632 tests passed. test:repo 1 / 5. typecheck exit 0; check:test-typecheck compiles the new file with the debt unchanged at 40 files.
  • driver-memory: at 9f0d29239, 59 / 1380, and typecheck exit 0.
  • rest: at 4c5740d2d, --project local 221 files, 4214 passed / 43 skipped. test:repo 1 / 8, and typecheck exit 0.
  • REST file with live PostgreSQL, at 4c5740d2d: 6 / 6.

The objectql and driver-memory trees are byte-identical between 9f0d29239 and 4c5740d2d.

Gates at 4c5740d2d

  • dispatch-gates --commands --repo objectstack-ai/objectstack: 65 commands, each run with its exit code captured before any pipe. 63 exit 0.
  • NOT MEASURED: check:dual-build-cjs-loads, check:type-check-debt. Reason: both exit 3 PREREQUISITE NOT MET and need the whole tree built (lint.yml builds it first); only the rest and driver-memory closures are built here. Scoped reading: both require entries of @objectstack/objectql (. and ./core) load from the rebuilt dist.
  • --ran reconciliation: 65 derived, 63 run, 2 NOT-MEASURED, 0 UNRUN.
  • Rosters in the touched directories: check-changeset-fixed, check:authz-resolver, check:filter-alias-parity, check:object-def-param-keys and check:tenant-chokepoint each exit 0.
  • Narrowed lint: eslint --no-inline-config --format json over the 4 changed TS files, which are inside eslint.config.mjs's population: 4 files, 0 errors, 0 warnings. --print-config shows no parserOptions.project or projectService, so type-aware linting is off and this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint is CI's.

Acceptance notes

  • REST on memory is not a cell of the committed REST file. @objectstack/driver-memory has no binding in packages/rest, and a new binding is a check:driver-memory-census disposition, not a test's. Memory is covered by the engine pin (the refusal reaches no driver), by the driver-memory pin (admitted spellings are stored as the day), and by the before / after table above, measured through RestServer over InMemoryDriver with a scratch script that was not committed.
  • The invalid_date message still reads "must be a valid date (ISO-8601)". It lives in packages/spec (system/validation-message.ts), outside this card's file surface. "20260715" (ISO basic) and "+002026-07-15" (ISO extended year) are ISO-8601 spellings refused with that message. Carrier: none.
  • Out of scope, measured and not edited (reported to the seat): a date "2026-02-30" is Date.parse-readable and has a leading day shape. It is still 201 and read back verbatim on memory and SQLite, and a 500 on PostgreSQL. On the datetime arm, a non-ISO zone-naive spelling is read in the process zone ("2026/07/15 10:00" became 14:00Z under America/New_York, while "2026-07-15 10:00" reads as UTC), "07/08/2026" is read month-first, and "2026-02-30T10:00:00Z" rolls over to 2026-03-02T10:00Z.
  • driver-mongodb keeps its own copy of the storage rule and is unmeasured here. The refusal sits in the engine in front of it.

Generated by Claude Code

…— any other spelling is VALIDATION_FAILED / invalid_date

The record validator's date arm now asks the date storage rule's own
reading of a string (core's isUninterpretableTemporalComparand, the
temporal-comparand door's predicate) on top of Date.parse and the year
range: a date string is accepted only when it carries a leading
YYYY-MM-DD, which temporalStorageForm collapses to that day. Every other
spelling (2026/07/15, 07/15/2026, 15 July 2026, 2026-7-15) is refused
with invalid_date instead of being stored verbatim on memory and SQLite
or read by PostgreSQL's DateStyle. No spelling is canonicalised.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…the engine and at REST on SQLite and PostgreSQL

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…or admits are stored as their day; objectql minor, BREAKING

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…dict per date string

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 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
  • 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 — 17 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 397572ed5da04c14eed7b4bf934a4c353822df6c → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 397572ed5da04c14eed7b4bf934a4c353822df6c

⚠️ 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: 4c5740d2d598ce1a6f64b2def69c848a2909e524
Local-runs: none

Inputs read: card #20481 (body and all 4 comments: triage 5875651303, claim 5879527319, os-dev-report 5880394252, seat answer 5880487779), PR #20524 (body, 5-file list, net diff against merge-base fb194c70e: +491 / −3), the check-runs on the head (read twice), objectui main at 12a6688c (raw reads; the PR read 797a30f, every cited line re-verified at the newer head), and card #20525. Read-only git and REST GETs only; nothing built, run or re-run.

① Derived judgments

  1. The one accept-set change, and it is the ruling's. packages/objectql/src/validation/record-validator.ts date/datetime arm, +26 / −3: for t === 'date' and a STRING value, the arm now also requires !isUninterpretableTemporalComparand('date', value); the verdict line becomes readable && readsAsDay && !isOutsideTemporalYearRange(value, t) (line 1246). So a date string is admitted only with a trimmed leading YYYY-MM-DD (core's private readsAsCalendarDay, /^\d{4}-\d{2}-\d{2}/) whose year is 0001..9999, and is otherwise fail('invalid_date', …) before any driver call. RIGHT: triage 5875651303 verbatim ("accepts a string whose leading part is YYYY-MM-DD … every other string is refused with VALIDATION_FAILED / 400 invalid_date").
  2. The predicate is core's existing export, and the two doors now share it. isUninterpretableTemporalComparand is exported from @objectstack/core (packages/core/src/index.ts:98, export * from './utils/temporal-comparand.js') and is the same call the comparand door makes (packages/objectql/src/temporal-comparand-door.ts:322, judgeComparand). No second regex is written in objectql; packages/core is not in the diff. The doors agree on the rule's reading by construction. The write door stays strictly narrower by Date.parse (e.g. 2026-13-45: leading-day shape, no reading — refused on write, admitted as a comparand); the validator comment says so, the engine pin lists it under STILL_REFUSED, and the doors test's string set does not claim equality on it. RIGHT, and stated honestly.
  3. The predicate's two comparand-only exemptions change no write answer. A blank or whitespace-only string is isMissing and returns before the arm (validator line 866). A brace-wrapped {token} is stepped around by classifyFilterToken and so is judged by Date.parse alone, exactly as at the base ({today} is pinned refused). RIGHT.
  4. Everything else the arm admitted keeps its answer (each traced in code): a Date (not a string, readsAsDay true; the storage rule stores its UTC day); an epoch number (never readable, refused before and after); 2026-07-15T10:00:00Z, 2026-07-15 10:00, 2026-07-15 (the predicate trims; canonicalCalendarDay trims and slices to the day); 20260715, 15/07/2026 (no Date.parse reading, refused before and after); the year range (line 1246 keeps !isOutsideTemporalYearRange). RIGHT.
  5. Nothing is canonicalised. The arm returns null or fail(...); no value is rewritten; temporalStorageForm / canonicalCalendarDay are untouched. RIGHT.
  6. The datetime and time arms are untouched. readsAsDay short-circuits on t !== 'date'; the time branch is not in the diff; the wire code and message are unchanged. RIGHT.
  7. Every write door is behind the change. validateRecord runs on insert (single and array, engine.ts:12661; insertMany delegates to insert, engine.ts:12939), update (13933), multi-row update (14244) and the dry-run validate (11888). REST create / PATCH / bulk, /import (after coerceRow), MCP create_record / update_record (data: z.record(z.string(), z.unknown()), mcp-http-tools.ts:921 / :955, pass-through as the PR says) and flows all reach the engine through those methods. RIGHT.
  8. No public surface moves. No new export in @objectstack/objectql (only the validator file changed) or @objectstack/core (not in the diff); packages/spec untouched; invalid_date code and message unchanged (validation-message.ts:103). Docs: content/docs/api/error-catalog.mdx:207 ("a value a date … field cannot parse answers invalid_date") stays true; no page states the old Date.parse accept-set, so nothing is falsified (docs-drift: no anchor named). RIGHT.
  9. Producer census — verified, with one edge.
    • objectui main 12a6688c: packages/data-objectstack/src/index.ts:4430 importRecords exists and throws UNSUPPORTED_OPERATION only when client.data.import is not a function; @objectstack/client has data.import (packages/client/src/index.ts:7809, POST /:object/import); apps/console/src/dataSource.ts re-exports ObjectStackAdapter from @object-ui/data-objectstack; ImportWizard.tsx validateValue:486 (Date.parse), validateRow:561, isUnsupportedImport:626, legacyImport:1863, and legacyImport() is reached only after serverImport throws an unsupported error (:2090–:2105). The shipped console never takes the legacy per-row fallback. TRUE.
    • packages/rest/src/import-coerce.ts:352 parseDateCell: every date branch returns ${y}-${pad2(mo)}-${pad2(d)}, i.e. YYYY-MM-DD for every year 1000..9999. TRUE for the card's class. EDGE: the year is emitted unpadded on all three branches (:387 Number(ymd[1]), :392 wall.year, :400 getUTCFullYear()), so a cell naming a year 0001..0999 (0500-01-01) is emitted 500-01-01, which the head's write door refuses (invalid_date, a per-row import error). That is a padding defect in the import reader (the core temporalStorageForm: the date arm leaves a year outside 1000..9999 unpadded — over REST the epoch-ms number for 0999-06-15 counts $gt 0 / $lt 7 on InMemoryDriver and SQLite (correct 6 / 0); its ISO string counts 6 / 0 #20240 class), not an import format sending a non-ISO date, so open question 1 is not a needs_decision; escalated in ③.
    • DateField.tsx:115–:120: a native date input, onChange(e.target.value), YYYY-MM-DD or empty. Calendar toStoredDateValue:338 / toMovedDateValue:380 (toDateInputValue, toISOString().slice(0, 10)); Gantt toStoredDateValue:414. TRUE. The census did not name three further objectui date writers — plugin-detail/src/InlineFieldInput.tsx, components/src/renderers/complex/data-table.tsx:2663, fields/src/widgets/GridField.tsx:1184 — all native date inputs fed by toDateInputValue, ISO by construction; the omission changes no answer.
    • Examples: a regex sweep over examples/** (quoted literals, and unquoted CSV / JSON / YAML cells) finds 0 non-ISO date spellings and ISO literals in 6 files — the PR's counts reproduce. TRUE.
  10. The pins, read in full.
    • packages/objectql/src/engine-date-write-iso-only.test.ts (4 tests): a recording driver; 8 REFUSED plus 5 STILL_REFUSED on insert, update and multi-row update, code VALIDATION_FAILED, fields exactly [placed_on, invalid_date], writes.length === 0; the dry-run validate on every string; the positive control asserts each accepted value reaches the driver; the doors test asserts INVALID_FILTER / 400 on where for each refused string and one read per accepted one. RIGHT.
    • packages/rest/src/data-date-write-iso-only.test.ts (3 tests per cell): SQLite always; live PG via describe.skipIf(!config) with a label naming OS_TEST_POSTGRES_URL. Asserts 400, VALIDATION_FAILED and the exact field code on POST and PATCH, a zero write delta on the object, o1 kept its day, no refused row; an epoch control; the positive control reads back 2026-07-15 on create and on PATCH. RIGHT. CI: the only jobs setting OS_TEST_POSTGRES_URL (ci.yml Temporal Conformance) run driver-sql, metadata-protocol live files and runtime's cascade file — not packages/rest — so the PG cell is a named skip in CI, exactly as the file's header says and as three existing rest files already do (data-number-comparand-door, data-temporal-year-range, rest-aggregate-numeric-having). The dev's live-PG 6/6 is a local reading, unverified here.
    • packages/drivers/driver-memory/src/memory-20481-date-write-iso-only.test.ts (2 tests): every admitted spelling and a Date stored as 2026-07-15, found by $eq, ordered after 2026-07-14. RIGHT.
    • No existing assertion is removed or weakened: the diff adds three files and edits no existing test.
  11. Merge facts. 4c5740d2d has two parents, 9f0d29239 and fb194c70e (verified). 9f0d29239..4c5740d2d touches only .changeset/20497-…, import-coerce.ts, import-coerce.test.ts and import-number-thousands-group.test.ts (PR fix(rest)!: /import reads a comma in a number cell only as a thousands group, refusing the rest (#20497) #20517); the objectql, core and driver-memory trees are byte-identical (empty diff stat). TRUE.
  12. Sentence census, PR body and changeset. Every sentence about the diff, the arm, the predicate, the doors, the unchanged set, the census sites, the pins and the merge is TRUE against the code. Qualified: "parseDateCell … always returns YYYY-MM-DD for a date cell" and the changeset's "The server import … is not affected: it already turns a date cell into YYYY-MM-DD" are TRUE for years 1000..9999 and FALSE at 0001..0999 (item 9). "Read at … objectui origin/main 797a30f" — main is now 12a6688c; re-verified there. The suite counts, ablation readings, dispatch-gates 63 / 65, narrowed lint and the base before/after table are the dev's own readings, not reproducible read-only; they are consistent with the code and with 31 / 31 green check-runs, and recorded as unverified-here, not FALSE. The report's "31 check-runs, 11 success, 3 skipped, 17 in_progress" was a 23:10Z reading; the head now shows 34 / 31 / 3 / 0.

② Semver level

  • Changeset .changeset/20481-date-write-iso-only.md: "@objectstack/objectql": minor, a BREAKING banner, Clause-②: no (narrowing), ADR-0087 not-required (no-migration-prescription). The PR body and claim 5879527319 carry the same Clause-②: no (narrowing) line. Consistent.
  • no is right. No new export (objectql's index is untouched; core is not in the diff), no new key, no new union member, no accept-set widening anywhere — the diff removes strings from a write accept-set. (narrowing) is right and is BREAKING (AGENTS.md §Post-Task 3; check-changeset-no-major.mjs: "a declared narrowing is a BREAKING change; during the launch window it ships minor").
  • minor is the right level for a write door that now refuses input it accepted: patch would understate a breaking narrowing, major is refused during the launch window, and pr-automation's "WHICH LEVEL" rule has the act win over the fix( commit type. Breaking-ness is carried by the banner plus the ADR-0087 disposition, both present; Check Changeset concluded success on this head.
  • The narrowing is on the changeset's prose face: FROM (the eight Date.parse-readable spellings) → TO (VALIDATION_FAILED / 400 invalid_date, nothing written), the one-line fix (YYYY-MM-DD or a JS Date), "Who is affected", the before / after table, and the "Unchanged" list. A caller sees the before and after.
  • ADR-0087 arm no-migration-prescription is the honest one: "send YYYY-MM-DD" re-spells a value, it does not rewrite consumer code or metadata; no authorable key moves; the package publishes (unpublished would be false); no registry id (registered / already-registered would be false).
  • Only @objectstack/objectql moves and only it carries a changeset. Right.

③ Boundary flags

  1. Deviation 1 — no REST-on-memory cell. Answered: covered. packages/rest/package.json has no @objectstack/driver-memory binding (verified), and check:driver-memory-census gates a new consumer by ledger, so the cell is a disposition, not a test's call. The refusal sits in the engine before any driver, so a REST-over-memory cell would re-measure the engine pin; the ruling's intent (refusal on all three backends with an ISO control) is held by the engine pin (driver-agnostic, zero writes), the driver-memory pin (admitted spellings stored as the day) and REST on SQLite, with PG when provisioned. No needs_decision.
  2. Deviation 2 — a private PG cluster outside the scratchpad. Process hygiene; reported stopped and deleted; the diff carries no artefact. Accepted.
  3. Deviation 3 — an untracked scratch script under packages/runtime. Never committed (the diff is the 5 files listed); the base before / after table therefore remains the dev's reading. Accepted.
  4. Deviation 4 — --maxWorkers=2 probably dropped. Changes no reading's scope. Accepted.
  5. Deviation 5 — the harness's model-named trailer not followed. AGENTS.md's trailer pair governs the repo and the branch / card guards concluded success. Accepted.
  6. Deviation 6 — a true merge of origin/main fb194c70e. Two parents verified; the merge touched only PR fix(rest)!: /import reads a comma in a number cell only as a thousands group, refusing the rest (#20497) #20517's rest files, none under objectql, core or driver-memory. Accepted.
  7. Open question 1 — A, confirmed. Both load-bearing facts re-verified at objectui main 12a6688c and at this head (① item 9): the shipped console reaches /import through data-objectstack → @objectstack/client data.import, and parseDateCell emits YYYY-MM-DD. The seat's "no objectui card" for the legacy pre-check mismatch is agreed as an acceptance note with no shipped reach. Escalated from this review, not a block: parseDateCell emits an unpadded year for 0001..0999 on every date branch (import-coerce.ts:387, :392, :400), so after this lands such a cell is a per-row invalid_date on /import rather than an unpadded stored non-day. Producer-side (triage: "fixed at the producer"), on a file the claim marks ⛔ (import-coerce.ts, PR fix(rest)!: /import reads a comma in a number cell only as a thousands group, refusing the rest (#20497) #20517, landed). The seat should file it bare — class a; reach POST /api/v1/data/:object/import with a date cell naming a year below 1000; landing site packages/rest/src/import-coerce.ts parseDateCell; dedupe against core temporalStorageForm: the date arm leaves a year outside 1000..9999 unpadded — over REST the epoch-ms number for 0999-06-15 counts $gt 0 / $lt 7 on InMemoryDriver and SQLite (correct 6 / 0); its ISO string counts 6 / 0 #20240 / 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. The head's answer there is a loud refusal, which is the ruling's direction.
  8. Out-of-scope findings 1 and 2 — filed as 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 (open; bug, priority:p1, pm:queue, domain:engine, area:records; seat, 2026-09-28T23:18:16Z). Verified filed; both are Date.parse leniency at the same arm and outside this card's ruling. Answered.
  9. Finding 3 — the invalid_date message names ISO-8601 while 20260715 and +002026-07-15 are refused. Acceptance note, carrier none: agreed. packages/spec is outside the surface, the message was already imprecise at the base for 20260715, and the refusal carries the field code.
  10. Docs drift comment — no hand-written page names the anchor and the one page describing invalid_date stays true. Nothing owed.
  11. Check-runs on 4c5740d2d598ce1a6f64b2def69c848a2909e524, re-read 2026-09-29T00:14:51Z (the last step): 34 check-runs, all completed — 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in), each path- or opt-in-gated), 0 failure, 0 in progress. Green by name: Check Changeset, Lint & Repo Gates, Test Core (1/6)–(6/6) and its roll-up, Type Check · source gates / consumer gates / debt ledger / workspace and TypeScript Type Check, Temporal Conformance (live PG + MySQL), Dogfood Regression Gate (1/3)–(3/3) and its roll-up, Dogfood Verify CLI, Governed Surface Queue Guard, Auto Label, Check PR Size, Check Documentation Links, Flag docs affected by code changes, No other open PR may claim the same issue / the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch, Build Core, filter. Combined commit status success (Vercel). The PR head is unchanged, mergeable_state: clean, still a draft.

Implemented-by: claude/issue-20481-date-write-iso-only
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 00:21
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit b2b6a06 Sep 29, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20481-date-write-iso-only branch September 29, 2026 00:41
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