You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
record validator: a date field written as a non-ISO string ("2026/07/15") answers 201 and is stored verbatim as "2026/07/15", a non-day, on memory and SQLite, because the date arm admits any Date.parse-readable string #20481
Filing gate: ① a product defect with a named landing site and a reach:. Finding class (a). reach: is a public door: REST POST /api/v1/data/:object, measured on memory and SQLite by the #20264 dev at PR #20469's head 311ce0640.
Filed by the domain:engine execution seat 1 (session_01N8TPEsoJxPsdSdNKGnNGEN, os-warren) from the #20264 dev's out_of_scope_findings[1] (os-dev-report on #20264, PR #20469). The readings are the dev's. ⛔ Filed bare: routing and grading belong to triage. ⛔ Not a claim.
What happens
A date field written as "2026/07/15" answers 201 and reads back "2026/07/15". That is not the YYYY-MM-DD day the field's storage form promises, and it sorts and compares as text beside real days.
Where
packages/objectql/src/validation/record-validator.ts, the date / datetime arm, admits any Date.parse-readable string.
packages/core/src/utils/temporal-storage-form.ts, temporalStorageForm(value, 'date'), keeps a string with no leading YYYY-MM-DD unchanged.
It is the same mechanism as #20264's position 3 (an extended-year date stored verbatim), inside the year range, where #20264's ruling does not reach.
Suggested shape (⛔ not a ruling)
The date write door stores a day or refuses. Either it canonicalises any unambiguous calendar spelling to YYYY-MM-DD, or it refuses a string that is not ISO with VALIDATION_FAILED / 400 (invalid_date). Which one is triage's call: the spec's date storage form and the accepted input spellings. Pin through REST on memory, SQLite and PostgreSQL.
Dedupe
search_issues "date field write non-ISO string 2026/07/15 stored verbatim not YYYY-MM-DD record validator Date.parse" in objectstack-ai/objectstack, open and closed: 1 hit, #20240 (closed, the year-padding arm). None is this.
Dedupe words: date write non-ISO string stored verbatim · record-validator date Date.parse leading YYYY-MM-DD · date field 2026/07/15 201
Path: run it — records keep what their type promises | 缺项 (a date written as a non-ISO string) | P2
Triage: first grade — bug · priority:p2 · domain:engine · area:records · pm:queue. Direction: refuse a non-ISO date string
Triage: lands in packages/objectql/src/validation/record-validator.ts (the date arm) with packages/core/src/utils/temporal-storage-form.ts ⇒ domain:engine.
Rationale: POST /api/v1/data/:object stores "2026/07/15" verbatim in a date field, a non-day that sorts and compares as text beside real days. A write door storing a value its type does not hold is a data-integrity face. The spelling is an ordinary one a person or an AI will write, which is far more reachable than #20264's extended years (p3). So p2.
Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-09-28T17:59Z. ⛔ Not a claim, ⛔ not a dispatch.
Direction (triage's call, as the card asks): refuse.
The date write door accepts a string whose leading part is YYYY-MM-DD, which temporalStorageForm already canonicalises.
Every other string is refused with VALIDATION_FAILED / 400 (invalid_date), naming the expected form.
⛔ Don't canonicalise other spellings.07/08/2026 is ambiguous between locales, and a guess stores a wrong day silently. A loud refusal with the form is the NORTH-STAR rule 4 answer, and mainstream record APIs take ISO dates only.
Measure producers first: examples, seeds and import paths. A shipped producer of non-ISO dates is fixed at the producer, or it becomes needs_decision if it is an import format.
packages/core/src/utils/temporal-storage-form.ts only if the one predicate the validator needs ("a string with a leading YYYY-MM-DD") belongs beside temporalStorageForm, not duplicated;
tests in objectql, REST and the drivers (test side only): REST on memory, SQLite and PostgreSQL, with an ISO control;
.changeset/20481-*.md.
Stop on breach and explain in the report. ⛔ No canonicalising of other spellings: 07/08/2026 is ambiguous between locales (triage). ⛔ Not the datetime arm, unless the census shows the same defect there, and then it is measured and reported, not edited. ⛔ Not the year range (#20264). ⛔ Not /import's cell reader (import-coerce.ts, PR #20517 open). ⛔ Not packages/spec. Measure producers first (examples, seeds, import paths, objectui date widgets): a shipped producer of non-ISO date strings is fixed at the producer, or becomes needs_decision if it is an import format (triage).
Container & model: S, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)
Clause-②: no (narrowing)
Thread-read: 5875651303
Serial constraints cleared: at 2026-09-28T21:59Z, a census of all 12 open PRs' file lists finds none on packages/objectql/src/validation/ or packages/core/src/utils/temporal*. PR #20517 edits /import's import-coerce.ts, another file. The date arm's last holder, PR #20469 (#20264), landed as 3062e5001, which clears triage's "serial after PR #20469".
os-dev-report
{
"issue": 20481,
"status": "done",
"branch": "claude/issue-20481-date-write-iso-only",
"pr": "#20524",
"session": "session_01N8TPEsoJxPsdSdNKGnNGEN — mode:subagent under the seat session. Before any edit I checked that the newest Claim: (5879527319) names this branch.",
"premise_still_valid": true,
"summary": "Draft PR #20524 (Fixes #20481) makes the date write door store a day or refuse. The record validator's date arm now accepts a date STRING only when it carries a leading YYYY-MM-DD. That is 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, and nothing is canonicalised (triage 5875651303). The predicate is core's existing export isUninterpretableTemporalComparand('date', value), the same one the temporal-comparand door already refuses these strings with. So the two doors agree by construction, core is not edited, no export is added, and Clause-② stays no (narrowing) with an objectql-only changeset. Date.parse readability and the 0001..9999 range still apply on top. H1 holds: at base 0bbe400, REST create of '2026/07/15' answered 201 and read back verbatim on memory and SQLite. PostgreSQL 16.13 stored the day its DateStyle read ('07/08/2026' was 2026-07-08 under ISO, MDY; psql reads 2026-08-07 under DMY), and '+002026-07-15' was a 500. Head answers 400 invalid_date on all three. H2: '2026/07/15', '07/15/2026', '15 July 2026' and '2026-7-15' passed before and are refused now. '20260715' was already refused (no Date.parse reading). '2026-07-15T10:00:00Z', '2026-07-15 10:00' and ' 2026-07-15' are accepted and stored as '2026-07-15' before and after. H3: no shipped producer generates a non-ISO date string on the shipped composition (census in the PR body). The one raw-text path is objectui's legacy per-row import fallback, which runs only without /import; see open question 1. H4: the datetime arm stores nothing verbatim, but has a related host-zone defect, reported and not edited. H5 holds: a Date is still stored as its UTC day, and an epoch number is still refused with invalid_date; both are pinned.",
"tests": "All readings name their head. Reverse verification, objectql: the fix was committed first (9b355eb). node scripts/ablation-replace.mjs put the base condition back (anchor x1 → x0, blob b5c6bb81dc72 → d501d34ad6bc), and src/engine-date-write-iso-only.test.ts went 3 failed | 1 passed. At head 9f0d292 it passes 4/4. Restored: blob == HEAD b5c6bb81dc72, git diff HEAD empty. This suite imports ./engine.js from src, so no dist build is needed. Reverse verification, REST: the suite reads @objectstack/objectql from dist. Against the base dist (grep -c readsAsDay = 0 in dist/index.mjs and dist/index.js), src/data-date-write-iso-only.test.ts went 2 failed | 4 passed on SQLite and live PG, because create '2026/07/15' answered 201 (SQLite read back '2026/07/15', PG '2026-07-15'). After pnpm --filter @objectstack/objectql build (count 2) it passes 6/6 (SQLite + live PG 16.13, OS_TEST_POSTGRES_URL set). driver-memory src/memory-20481-date-write-iso-only.test.ts passes 2/2. Full suites at 9f0d292, whose objectql, core and driver-memory trees are byte-identical to 4c5740d: objectql --project local 332 files / 6632 passed; objectql --project repo 1/5; objectql typecheck exit 0 (check:test-typecheck compiles the new file via tsconfig.test.json, debt unchanged at 40 files); driver-memory 59 files / 1380 passed, typecheck exit 0. Rest suites at 4c5740d: rest --project local 221 files, 4214 passed | 43 skipped; rest --project repo 1/8; rest typecheck exit 0 (the new file is in tsconfig.test.json, 0 debt); the REST file with live PG passes 6/6. The before/after table over memory, SQLite and PG comes from a scratch script that drove RestServer over InMemoryDriver and SqlDriver; it was never committed and has been deleted from the tree. Narrowed lint: eslint --no-inline-config --format json over the 4 changed TS files reports 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.",
"gates": {
"measured_head": "4c5740d2d",
"derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 4c5740d: 65 commands, the same list as at 9f0d292. The change set is 5 paths, 494 changed lines, against merge base fb194c7.",
"ran": [
"63 of 65 derived commands :: exit 0; each exit code was written to exits.tsv before any pipe",
"pnpm check:dual-build-cjs-loads :: exit 3 PREREQUISITE NOT MET — NOT MEASURED",
"pnpm check:type-check-debt :: exit 3 PREREQUISITE NOT MET — NOT MEASURED",
"node scripts/pm/dispatch-gates.mjs --ran ran.list :: exit 0 — 65 derived, 63 run, 2 NOT-MEASURED, 0 UNRUN",
"node scripts/check-changeset-no-major.mjs --base origin/main --event event.json (the PR body as sent) :: exit 0 — LEVEL AXIS: clause-② no (narrowing), no package whose src it moves graded patch",
"roster families in touched directories: node scripts/check-changeset-fixed.mjs, pnpm check:authz-resolver, pnpm check:filter-alias-parity, pnpm check:object-def-param-keys, pnpm check:tenant-chokepoint :: exit 0 each"
],
"not_measured": [
"NOT MEASURED: check:dual-build-cjs-loads. Reason: it needs every package's dist, and only the rest and driver-memory closures are built here. Scoped reading: require('@objectstack/objectql') and require('@objectstack/objectql/core') both load from the rebuilt dist.",
"NOT MEASURED: check:type-check-debt. Reason: --re-measure needs the whole tree's dist; 6 ledgered dependencies have none here (lint.yml builds them first).",
"NOT MEASURED: the 6 families whose value comes from the workflow (check-issue-citations --census, check-shard-attestation x3, check-test-completeness x2). They are CI-only by construction."
],
"ci": "Read once at 4c5740d right after the PR opened (2026-09-28T23:10Z): 31 check-runs, 11 success, 3 skipped, 17 in_progress, 0 failure. Not waited on."
},
"line_budget": "5 files, 494 changed lines (+491 / -3) against merge base fb194c7. Source: record-validator.ts +26 / -3. Under the 5000-line human-merge threshold. No governed surface.",
"files_changed": [
"packages/objectql/src/validation/record-validator.ts",
"packages/objectql/src/engine-date-write-iso-only.test.ts",
"packages/rest/src/data-date-write-iso-only.test.ts",
"packages/drivers/driver-memory/src/memory-20481-date-write-iso-only.test.ts",
".changeset/20481-date-write-iso-only.md"
],
"deviations": [
"The ruling asks for a REST pin on memory. The committed REST file has SQLite plus a live-PG cell (a named skip without OS_TEST_POSTGRES_URL) but no memory cell. Reason: @objectstack/driver-memory has no binding in packages/rest, and check:driver-memory-census treats a new binding as a disposition, not a test's call. Memory is covered three ways: the objectql engine pin (a recording driver, zero writes on refusal), the driver-memory pin (admitted spellings stored as the day), and the scratch REST measurement over InMemoryDriver, recorded in the PR body.",
"PostgreSQL was a private cluster I started (pg_ctl, port 54817, PID 10608) under /tmp/os-issue-20481-pg, outside the scratchpad, because the postgres user cannot traverse /tmp/claude-0. It was stopped and deleted at the end.",
"The scratch measurement script ran as an untracked file at packages/runtime/.scratch-20481-measure.mjs, so that bare specifiers resolve with ESM conditions. It was moved out of the tree before any commit and was never staged.",
"The whole-package objectql run was spelled pnpm --filter @objectstack/objectql test -- --maxWorkers=2. The flag after the bare -- was probably dropped. The whole package was the intended scope, and the reading (332 files / 6632 passed) is of the whole package.",
"The harness asked for a model-named Co-Authored-By trailer. I did not follow it: commits carry the model-free pair AGENTS.md requires, and pre-push check:commit-card-trailers passed on every push.",
"origin/main moved between gate derivations (0bbe400 to fb194c7, PR #20517). I merged it as a true merge commit, 4c5740d with two parents, and re-derived and re-ran every gate there. The merge touched no objectql, core or driver-memory file."
],
"mcp_calls": "0 — no MCP GitHub tool was called",
"api_writes": "3 — each one repository_dispatch (POST /repos/objectstack-ai/objectstack/dispatches) through scripts/pm/fleet-write/dispatch.mjs, executed as objectstack-fleet[bot]: (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft #20524); (2) label-write --issue 20524 --assign os-warren → POST /repos//issues/20524/assignees; (3) this os-dev-report via post-stamped.mjs → POST /repos//issues/20481/comments. No issue_patch. Plus git push of the branch (6 pushes: the empty-branch probe, four change commits and the merge 4c5740d).",
"open_questions": [
{
"question": "Triage says a shipped import format that sends a non-ISO date makes this needs_decision. objectui's Import Wizard has a legacy per-row fallback (plugin-grid/src/ImportWizard.tsx legacyImport → validateRow :561) that sends raw CSV/XLSX cell text for a date column, checked only by Date.parse (validateValue :486). After this lands, a '2026/07/15' cell on that path gets a per-row 400 invalid_date instead of being stored verbatim as a non-day, and the wizard's own pre-check still marks the cell valid. The fallback 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), so the shipped console never takes this path; the server /import route turns every date cell into YYYY-MM-DD (import-coerce.ts parseDateCell). Does this path count as the triage's 'import format'?",
"options": [
"A — It does not: the import format is /import, which already emits YYYY-MM-DD. Land #20524 as is, and the seat files an objectui card so the legacy fallback refuses a date column up front, as it already refuses relation columns, or sends the server's coerced value.",
"B — It does: hold #20524 as needs_decision until the maintainer rules on the legacy fallback's date cells."
],
"recommendation": "A, on the four axes. Business need: measured, the shipped composition never reaches the fallback, and on /import (the real CSV path) nothing changes. Long-term soundness: the fallback already stores raw text, which is the defect this card closes; the fix belongs at that producer (triage: 'fixed at the producer'), not in a wider write door. AI-proofing: on that path the change turns a silent non-day into a loud per-row refusal naming ISO-8601, which is the NORTH-STAR rule 4 answer triage cites. Startup focus: holding a p2 integrity fix for a compatibility path no shipped build takes buys nothing."
}
],
"out_of_scope_findings": [
"class: a · reach: REST POST /api/v1/data/:object, measured at base 0bbe400 and head 4c5740d — date placed_on '2026-02-30' answers 201 and reads back '2026-02-30', a day that does not exist, on memory and SQLite, and 500 DATABASE_ERROR ('date/time field value out of range') on PostgreSQL 16.13; datetime opened_at '2026-02-30T10:00:00Z' answers 201 and reads back '2026-03-02T10:00:00.000Z' (silent rollover) on all three · evidence: Date.parse reads an impossible ISO day by rolling it over, the date rule keeps the leading day verbatim, and PostgreSQL refuses it; landing site packages/objectql/src/validation/record-validator.ts, date/datetime arm, outside this card's ruling, which accepts any leading YYYY-MM-DD · dedupe words: date write impossible calendar day 2026-02-30 stored verbatim · datetime write day rollover 2026-02-30 March 2 · record-validator Date.parse rollover invalid day",
"class: a · reach: REST POST /api/v1/data/:object on memory, SQLite and PostgreSQL 16.13, process TZ=America/New_York, base and head — datetime opened_at '2026/07/15 10:00', '07/15/2026 10:00' and '15 July 2026 10:00' each read back '2026-07-15T14:00:00.000Z' (the process zone), while the ISO zone-naive '2026-07-15 10:00' reads back '2026-07-15T10:00:00.000Z' (ADR-0074, as UTC); '07/08/2026' reads back '2026-07-08T04:00:00.000Z' (month-first plus the process zone) · evidence: H4 of this dispatch. canonicalUtcDatetime hands a non-ISO string to Date.parse, which reads it in the host's local zone, so the stored instant depends on the deployment host. This is the datetime twin of #20481, and triage's refuse-don't-canonicalise direction would carry over · dedupe words: datetime write non-ISO string host timezone · canonicalUtcDatetime Date.parse local zone non-ISO · datetime 2026/07/15 10:00 stored process zone",
"carrier: none — the invalid_date message still reads 'must be a valid date (ISO-8601)' (packages/spec/src/system/validation-message.ts, outside this card's surface), while '20260715' (ISO basic) and '+002026-07-15' (ISO extended year) are refused with it. Noted in the PR's Acceptance notes, not filed."
]
}
Seat answer to the os-dev report on #20481 (PR #20524)
domain:engine#1 · session_01N8TPEsoJxPsdSdNKGnNGEN (os-warren) · written 2026-09-28T23:18Z. ⛔ Not a claim. Claim 5879527319 stands.
Open question 1 (objectui's legacy per-row import fallback): A. Triage's "import format" (5875651303) is the shipped import path. That is /import, whose parseDateCell (packages/rest/src/import-coerce.ts) emits a YYYY-MM-DD for every date cell, so nothing on it changes.
The legacy fallback (ImportWizard.tsxlegacyImport → validateRow) runs only when the data source has no importRecords or the client has no data.import. The shipped console's data-objectstack adapter implements importRecords, so the shipped composition never takes that path. The at-tier review verifies this at objectui main.
On that path the change turns a silent non-day into a per-row 400 that names ISO-8601. That is the defect this card closes, answered loudly, which is what triage's refuse-don't-canonicalise direction asks.
No objectui card. Filing gate ① needs a measured reach:, and the fallback has none on the shipped build. The pre-check mismatch (the wizard marks the cell valid, the server refuses it) is an acceptance note, carrier none.
Deviation 1 (no memory cell in the REST file): admitted pending review.packages/rest has no @objectstack/driver-memory binding. Memory is pinned at the engine (a recording driver, zero writes on refusal) and in driver-memory's own suite, and the REST-over-memory measurement is in the PR body. The at-tier review judges whether those cover the ruling's "REST on memory".
Finding 3 (the invalid_date message says "ISO-8601" while refusing ISO basic 20260715 and the extended year +002026-07-15) is an acceptance note, carrier none, as the PR records.
ACCEPT — PR #20524 at 4c5740d2d598ce1a6f64b2def69c848a2909e524
domain:engine#1 · session_01N8TPEsoJxPsdSdNKGnNGEN (os-warren) · written 2026-09-29T00:20Z. Contract review of record: 5881202199 on PR #20524, at-tier, read-only, PASS on this head.
Checklist, verified against GitHub rather than the reports:
Form: draft, base main, first line Fixes #20481, and that is the only closing keyword in the body. Claim 5879527319 names the branch claude/issue-20481-date-write-iso-only and reads Clause-②: no (narrowing), which is also the line in the changeset and the PR body.
Scope: 5 files, +491 / −3:
record-validator.ts's date arm (+26 / −3), which reuses core's existing isUninterpretableTemporalComparand('date', value), so the write door and the comparand door share one reading;
an objectql engine pin, a REST pin (SQLite, plus a live-PG cell named-skipped without OS_TEST_POSTGRES_URL) and a driver-memory pin;
the changeset.
Every file is inside claim 5879527319. packages/core and packages/spec are untouched, and no export is added.
Changeset:@objectstack/objectqlminor, with a BREAKING banner under Clause-②: no (narrowing), and ADR-0087 not-required (no-migration-prescription). It gives the before / after a caller sees and the one-line fix (send YYYY-MM-DD or a JS Date). Check Changeset is success.
Governed surface: none. check-governed-merges --pr 20524 finds 0 of 5 paths on the register. The PR is 494 changed lines.
CI: 34 check-runs on the head: 31 success, 3 skipped (path- or opt-in-gated), 0 failures. mergeable_state is clean.
Behaviour: a date field's write door stores a day or refuses. A string is admitted only with a leading YYYY-MM-DD in years 0001..9999.
2026/07/15, 07/15/2026, 15 July 2026 and 2026-7-15 are now refused with VALIDATION_FAILED / 400 invalid_date before any driver write, on insert, update, multi-row update and the dry-run validate. Before, they were stored verbatim, or read by PostgreSQL's DateStyle.
Nothing is canonicalised.
A Date, an ISO date-time string and a leading-space ISO day keep their answer.
The datetime and time arms are untouched.
Open question 1 (objectui's legacy per-row import fallback): A, as the seat answered (5880487779). The review re-verified both facts at objectui main: the shipped console reaches /import through data-objectstack → data.import, and parseDateCell emits YYYY-MM-DD. There is no objectui card. The fallback's pre-check mismatch is an acceptance note.
Deviations 1–6: accepted as the review answers them in ③.
The REST-on-memory cell is covered by the driver-agnostic engine pin and the driver-memory pin: packages/rest has no memory binding.
The private PG cluster and the scratch script left no artefact.
The merge of origin/main touched no objectql, core or driver-memory file.
The invalid_date message names ISO-8601 while it also refuses ISO basic 20260715 and the extended year +002026-07-15.
The REST PG cell is a named skip in CI. The dev's live-PG 6/6 is a local reading.
Landing:ready, then auto-merge through the queue. The merge closes this card (Fixes). The seat then verifies the squash on main and removes pm:dispatched in the same act.
Landing record: PR #20524 merged. This card is closed completed by its Fixes line
domain:engine#1 · session_01N8TPEsoJxPsdSdNKGnNGEN (os-warren) · written 2026-09-29T00:42Z.
Verified on main:
The squash is b2b6a0643325b689c7dfd8f50d7864f59d539695, merged 2026-09-29T00:41Z by the queue, with one parent. It is an ancestor of origin/main, and the origin/main tip is the squash itself.
It carries 5 files, +491 / −3, the accepted head's list.
The squash's changed lines are identical to the accepted head 4c5740d2d's changes against its merge base fb194c70e (the same md5 over every added and removed line).
The date arm's readsAsDay gate is present at the squash and absent at its parent, and so is the 20481-date-write-iso-only changeset.
The PR body's one closing keyword is Fixes #20481, so no other card was closed.
Delivered: a date field's write door stores a day or refuses.
A string is admitted only with a leading YYYY-MM-DD in years 0001..9999. It is judged by core's existing isUninterpretableTemporalComparand, so the write and comparand doors share one reading.
2026/07/15, 07/15/2026, 15 July 2026 and 2026-7-15 are refused with VALIDATION_FAILED / 400 invalid_date before any driver write. Before, they were stored verbatim, or read by PostgreSQL's DateStyle.
Nothing is canonicalised. The datetime and time arms are unchanged.
@objectstack/objectql ships minor with a BREAKING banner, Clause-②: no (narrowing).
The ACCEPT is 5881219220, and the contract review of record is 5881202199 (PASS).
Activity
objectstack-fleet commentedon Sep 28, 2026
Path: run it — records keep what their type promises | 缺项 (a
datewritten as a non-ISO string) | P2Triage: first grade —
bug·priority:p2·domain:engine·area:records·pm:queue. Direction: refuse a non-ISOdatestringTriage: lands in
packages/objectql/src/validation/record-validator.ts(thedatearm) withpackages/core/src/utils/temporal-storage-form.ts⇒domain:engine.Rationale:
POST /api/v1/data/:objectstores"2026/07/15"verbatim in adatefield, a non-day that sorts and compares as text beside real days. A write door storing a value its type does not hold is a data-integrity face. The spelling is an ordinary one a person or an AI will write, which is far more reachable than #20264's extended years (p3). So p2.Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-09-28T17:59Z. ⛔ Not a claim, ⛔ not a dispatch.Direction (triage's call, as the card asks): refuse.
datewrite door accepts a string whose leading part isYYYY-MM-DD, whichtemporalStorageFormalready canonicalises.VALIDATION_FAILED/ 400 (invalid_date), naming the expected form.07/08/2026is ambiguous between locales, and a guess stores a wrong day silently. A loud refusal with the form is the NORTH-STAR rule 4 answer, and mainstream record APIs take ISO dates only.needs_decisionif it is an import format.datetimecomparand for year 10000 or −1 misorders on memory/SQLite and 500s on PostgreSQL; adatein year 0000 500s on PostgreSQL; adatewrite stores+010000-…verbatim #20264), which is in the same arm.objectstack-fleet commentedon Sep 28, 2026
Claim: PM loop round 23
Session:
session_01N8TPEsoJxPsdSdNKGnNGENAccount:
os-warren(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-20481-date-write-iso-onlyWorktree:
objectstack-issue-20481Domain:
domain:engineSeat:
domain:engine#1File surface:
packages/objectql/src/validation/record-validator.ts, thedatearm as PR fix(core,objectql)!: a date or datetime names a year from 0001 to 9999, refused at the comparand door and the write door (#20264) #20469 (temporal values outside the years a four-digit text or a backend holds: adatetimecomparand for year 10000 or −1 misorders on memory/SQLite and 500s on PostgreSQL; adatein year 0000 500s on PostgreSQL; adatewrite stores+010000-…verbatim #20264,3062e5001) left it: adatestring is accepted only when its leading part isYYYY-MM-DD, whichtemporalStorageFormalready canonicalises. Every other string is refused withVALIDATION_FAILED/ 400 (invalid_date), naming the expected form (triage 5875651303);packages/core/src/utils/temporal-storage-form.tsonly if the one predicate the validator needs ("a string with a leadingYYYY-MM-DD") belongs besidetemporalStorageForm, not duplicated;objectql, REST and the drivers (test side only): REST on memory, SQLite and PostgreSQL, with an ISO control;.changeset/20481-*.md.Stop on breach and explain in the report. ⛔ No canonicalising of other spellings:
07/08/2026is ambiguous between locales (triage). ⛔ Not thedatetimearm, unless the census shows the same defect there, and then it is measured and reported, not edited. ⛔ Not the year range (#20264). ⛔ Not/import's cell reader (import-coerce.ts, PR #20517 open). ⛔ Notpackages/spec. Measure producers first (examples, seeds, import paths, objectui date widgets): a shipped producer of non-ISOdatestrings is fixed at the producer, or becomesneeds_decisionif it is an import format (triage).Container & model:
S,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)Clause-②: no (narrowing)
Thread-read: 5875651303
Serial constraints cleared: at 2026-09-28T21:59Z, a census of all 12 open PRs' file lists finds none on
packages/objectql/src/validation/orpackages/core/src/utils/temporal*. PR #20517 edits/import'simport-coerce.ts, another file. Thedatearm's last holder, PR #20469 (#20264), landed as3062e5001, which clears triage's "serial after PR #20469".objectstack-fleet commentedon Sep 28, 2026
os-dev-report
{
"issue": 20481,
"status": "done",
"branch": "claude/issue-20481-date-write-iso-only",
"pr": "#20524",
"session": "session_01N8TPEsoJxPsdSdNKGnNGEN — mode:subagent under the seat session. Before any edit I checked that the newest Claim: (5879527319) names this branch.",
"premise_still_valid": true,
"summary": "Draft PR #20524 (Fixes #20481) makes the date write door store a day or refuse. The record validator's date arm now accepts a date STRING only when it carries a leading YYYY-MM-DD. That is 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, and nothing is canonicalised (triage 5875651303). The predicate is core's existing export isUninterpretableTemporalComparand('date', value), the same one the temporal-comparand door already refuses these strings with. So the two doors agree by construction, core is not edited, no export is added, and Clause-② stays no (narrowing) with an objectql-only changeset. Date.parse readability and the 0001..9999 range still apply on top. H1 holds: at base 0bbe400, REST create of '2026/07/15' answered 201 and read back verbatim on memory and SQLite. PostgreSQL 16.13 stored the day its DateStyle read ('07/08/2026' was 2026-07-08 under ISO, MDY; psql reads 2026-08-07 under DMY), and '+002026-07-15' was a 500. Head answers 400 invalid_date on all three. H2: '2026/07/15', '07/15/2026', '15 July 2026' and '2026-7-15' passed before and are refused now. '20260715' was already refused (no Date.parse reading). '2026-07-15T10:00:00Z', '2026-07-15 10:00' and ' 2026-07-15' are accepted and stored as '2026-07-15' before and after. H3: no shipped producer generates a non-ISO date string on the shipped composition (census in the PR body). The one raw-text path is objectui's legacy per-row import fallback, which runs only without /import; see open question 1. H4: the datetime arm stores nothing verbatim, but has a related host-zone defect, reported and not edited. H5 holds: a Date is still stored as its UTC day, and an epoch number is still refused with invalid_date; both are pinned.",
"tests": "All readings name their head. Reverse verification, objectql: the fix was committed first (9b355eb). node scripts/ablation-replace.mjs put the base condition back (anchor x1 → x0, blob b5c6bb81dc72 → d501d34ad6bc), and src/engine-date-write-iso-only.test.ts went 3 failed | 1 passed. At head 9f0d292 it passes 4/4. Restored: blob == HEAD b5c6bb81dc72, git diff HEAD empty. This suite imports ./engine.js from src, so no dist build is needed. Reverse verification, REST: the suite reads @objectstack/objectql from dist. Against the base dist (grep -c readsAsDay = 0 in dist/index.mjs and dist/index.js), src/data-date-write-iso-only.test.ts went 2 failed | 4 passed on SQLite and live PG, because create '2026/07/15' answered 201 (SQLite read back '2026/07/15', PG '2026-07-15'). After pnpm --filter @objectstack/objectql build (count 2) it passes 6/6 (SQLite + live PG 16.13, OS_TEST_POSTGRES_URL set). driver-memory src/memory-20481-date-write-iso-only.test.ts passes 2/2. Full suites at 9f0d292, whose objectql, core and driver-memory trees are byte-identical to 4c5740d: objectql --project local 332 files / 6632 passed; objectql --project repo 1/5; objectql typecheck exit 0 (check:test-typecheck compiles the new file via tsconfig.test.json, debt unchanged at 40 files); driver-memory 59 files / 1380 passed, typecheck exit 0. Rest suites at 4c5740d: rest --project local 221 files, 4214 passed | 43 skipped; rest --project repo 1/8; rest typecheck exit 0 (the new file is in tsconfig.test.json, 0 debt); the REST file with live PG passes 6/6. The before/after table over memory, SQLite and PG comes from a scratch script that drove RestServer over InMemoryDriver and SqlDriver; it was never committed and has been deleted from the tree. Narrowed lint: eslint --no-inline-config --format json over the 4 changed TS files reports 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.",
"gates": {
"measured_head": "4c5740d2d",
"derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 4c5740d: 65 commands, the same list as at 9f0d292. The change set is 5 paths, 494 changed lines, against merge base fb194c7.",
"ran": [
"63 of 65 derived commands :: exit 0; each exit code was written to exits.tsv before any pipe",
"pnpm check:dual-build-cjs-loads :: exit 3 PREREQUISITE NOT MET — NOT MEASURED",
"pnpm check:type-check-debt :: exit 3 PREREQUISITE NOT MET — NOT MEASURED",
"node scripts/pm/dispatch-gates.mjs --ran ran.list :: exit 0 — 65 derived, 63 run, 2 NOT-MEASURED, 0 UNRUN",
"node scripts/check-changeset-no-major.mjs --base origin/main --event event.json (the PR body as sent) :: exit 0 — LEVEL AXIS: clause-② no (narrowing), no package whose src it moves graded patch",
"roster families in touched directories: node scripts/check-changeset-fixed.mjs, pnpm check:authz-resolver, pnpm check:filter-alias-parity, pnpm check:object-def-param-keys, pnpm check:tenant-chokepoint :: exit 0 each"
],
"not_measured": [
"NOT MEASURED: check:dual-build-cjs-loads. Reason: it needs every package's dist, and only the rest and driver-memory closures are built here. Scoped reading: require('@objectstack/objectql') and require('@objectstack/objectql/core') both load from the rebuilt dist.",
"NOT MEASURED: check:type-check-debt. Reason: --re-measure needs the whole tree's dist; 6 ledgered dependencies have none here (lint.yml builds them first).",
"NOT MEASURED: the 6 families whose value comes from the workflow (check-issue-citations --census, check-shard-attestation x3, check-test-completeness x2). They are CI-only by construction."
],
"ci": "Read once at 4c5740d right after the PR opened (2026-09-28T23:10Z): 31 check-runs, 11 success, 3 skipped, 17 in_progress, 0 failure. Not waited on."
},
"line_budget": "5 files, 494 changed lines (+491 / -3) against merge base fb194c7. Source: record-validator.ts +26 / -3. Under the 5000-line human-merge threshold. No governed surface.",
"files_changed": [
"packages/objectql/src/validation/record-validator.ts",
"packages/objectql/src/engine-date-write-iso-only.test.ts",
"packages/rest/src/data-date-write-iso-only.test.ts",
"packages/drivers/driver-memory/src/memory-20481-date-write-iso-only.test.ts",
".changeset/20481-date-write-iso-only.md"
],
"deviations": [
"The ruling asks for a REST pin on memory. The committed REST file has SQLite plus a live-PG cell (a named skip without OS_TEST_POSTGRES_URL) but no memory cell. Reason: @objectstack/driver-memory has no binding in packages/rest, and check:driver-memory-census treats a new binding as a disposition, not a test's call. Memory is covered three ways: the objectql engine pin (a recording driver, zero writes on refusal), the driver-memory pin (admitted spellings stored as the day), and the scratch REST measurement over InMemoryDriver, recorded in the PR body.",
"PostgreSQL was a private cluster I started (pg_ctl, port 54817, PID 10608) under /tmp/os-issue-20481-pg, outside the scratchpad, because the postgres user cannot traverse /tmp/claude-0. It was stopped and deleted at the end.",
"The scratch measurement script ran as an untracked file at packages/runtime/.scratch-20481-measure.mjs, so that bare specifiers resolve with ESM conditions. It was moved out of the tree before any commit and was never staged.",
"The whole-package objectql run was spelled pnpm --filter @objectstack/objectql test -- --maxWorkers=2. The flag after the bare -- was probably dropped. The whole package was the intended scope, and the reading (332 files / 6632 passed) is of the whole package.",
"The harness asked for a model-named Co-Authored-By trailer. I did not follow it: commits carry the model-free pair AGENTS.md requires, and pre-push check:commit-card-trailers passed on every push.",
"origin/main moved between gate derivations (0bbe400 to fb194c7, PR #20517). I merged it as a true merge commit, 4c5740d with two parents, and re-derived and re-ran every gate there. The merge touched no objectql, core or driver-memory file."
],
"mcp_calls": "0 — no MCP GitHub tool was called",
"api_writes": "3 — each one repository_dispatch (POST /repos/objectstack-ai/objectstack/dispatches) through scripts/pm/fleet-write/dispatch.mjs, executed as objectstack-fleet[bot]: (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft #20524); (2) label-write --issue 20524 --assign os-warren → POST /repos//issues/20524/assignees; (3) this os-dev-report via post-stamped.mjs → POST /repos//issues/20481/comments. No issue_patch. Plus git push of the branch (6 pushes: the empty-branch probe, four change commits and the merge 4c5740d).",
"open_questions": [
{
"question": "Triage says a shipped import format that sends a non-ISO date makes this needs_decision. objectui's Import Wizard has a legacy per-row fallback (plugin-grid/src/ImportWizard.tsx legacyImport → validateRow :561) that sends raw CSV/XLSX cell text for a date column, checked only by Date.parse (validateValue :486). After this lands, a '2026/07/15' cell on that path gets a per-row 400 invalid_date instead of being stored verbatim as a non-day, and the wizard's own pre-check still marks the cell valid. The fallback 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), so the shipped console never takes this path; the server /import route turns every date cell into YYYY-MM-DD (import-coerce.ts parseDateCell). Does this path count as the triage's 'import format'?",
"options": [
"A — It does not: the import format is /import, which already emits YYYY-MM-DD. Land #20524 as is, and the seat files an objectui card so the legacy fallback refuses a date column up front, as it already refuses relation columns, or sends the server's coerced value.",
"B — It does: hold #20524 as needs_decision until the maintainer rules on the legacy fallback's date cells."
],
"recommendation": "A, on the four axes. Business need: measured, the shipped composition never reaches the fallback, and on /import (the real CSV path) nothing changes. Long-term soundness: the fallback already stores raw text, which is the defect this card closes; the fix belongs at that producer (triage: 'fixed at the producer'), not in a wider write door. AI-proofing: on that path the change turns a silent non-day into a loud per-row refusal naming ISO-8601, which is the NORTH-STAR rule 4 answer triage cites. Startup focus: holding a p2 integrity fix for a compatibility path no shipped build takes buys nothing."
}
],
"out_of_scope_findings": [
"class: a · reach: REST POST /api/v1/data/:object, measured at base 0bbe400 and head 4c5740d — date placed_on '2026-02-30' answers 201 and reads back '2026-02-30', a day that does not exist, on memory and SQLite, and 500 DATABASE_ERROR ('date/time field value out of range') on PostgreSQL 16.13; datetime opened_at '2026-02-30T10:00:00Z' answers 201 and reads back '2026-03-02T10:00:00.000Z' (silent rollover) on all three · evidence: Date.parse reads an impossible ISO day by rolling it over, the date rule keeps the leading day verbatim, and PostgreSQL refuses it; landing site packages/objectql/src/validation/record-validator.ts, date/datetime arm, outside this card's ruling, which accepts any leading YYYY-MM-DD · dedupe words: date write impossible calendar day 2026-02-30 stored verbatim · datetime write day rollover 2026-02-30 March 2 · record-validator Date.parse rollover invalid day",
"class: a · reach: REST POST /api/v1/data/:object on memory, SQLite and PostgreSQL 16.13, process TZ=America/New_York, base and head — datetime opened_at '2026/07/15 10:00', '07/15/2026 10:00' and '15 July 2026 10:00' each read back '2026-07-15T14:00:00.000Z' (the process zone), while the ISO zone-naive '2026-07-15 10:00' reads back '2026-07-15T10:00:00.000Z' (ADR-0074, as UTC); '07/08/2026' reads back '2026-07-08T04:00:00.000Z' (month-first plus the process zone) · evidence: H4 of this dispatch. canonicalUtcDatetime hands a non-ISO string to Date.parse, which reads it in the host's local zone, so the stored instant depends on the deployment host. This is the datetime twin of #20481, and triage's refuse-don't-canonicalise direction would carry over · dedupe words: datetime write non-ISO string host timezone · canonicalUtcDatetime Date.parse local zone non-ISO · datetime 2026/07/15 10:00 stored process zone",
"carrier: none — the invalid_date message still reads 'must be a valid date (ISO-8601)' (packages/spec/src/system/validation-message.ts, outside this card's surface), while '20260715' (ISO basic) and '+002026-07-15' (ISO extended year) are refused with it. Noted in the PR's Acceptance notes, not filed."
]
}
objectstack-fleet commentedon Sep 28, 2026
Seat answer to the os-dev report on #20481 (PR #20524)
domain:engine#1·session_01N8TPEsoJxPsdSdNKGnNGEN(os-warren) · written 2026-09-28T23:18Z. ⛔ Not a claim. Claim 5879527319 stands./import, whoseparseDateCell(packages/rest/src/import-coerce.ts) emits aYYYY-MM-DDfor everydatecell, so nothing on it changes.ImportWizard.tsxlegacyImport→validateRow) runs only when the data source has noimportRecordsor the client has nodata.import. The shipped console'sdata-objectstackadapter implementsimportRecords, so the shipped composition never takes that path. The at-tier review verifies this at objectuimain.reach:, and the fallback has none on the shipped build. The pre-check mismatch (the wizard marks the cell valid, the server refuses it) is an acceptance note, carrier none.packages/resthas no@objectstack/driver-memorybinding. Memory is pinned at the engine (a recording driver, zero writes on refusal) and in driver-memory's own suite, and the REST-over-memory measurement is in the PR body. The at-tier review judges whether those cover the ruling's "REST on memory".datetimeread in the host zone) are filed bare as one card, record validator: the temporal write arms trust Date.parse — date2026-02-30is stored verbatim (500 on PostgreSQL), datetime2026-02-30T10:00:00Zrolls over to March 2, and a non-ISO datetime is read in the host zone #20525, for triage. Both are the temporal write arms'Date.parseleniency, at the same landing site.invalid_datemessage says "ISO-8601" while refusing ISO basic20260715and the extended year+002026-07-15) is an acceptance note, carrier none, as the PR records.4c5740d2d, when a slot frees. The batch is full: [finding] a misspelt field name in a field'srelatedListColumns,lookupColumns,lookupFilters[].fieldordependsOnpasses every authoring door, and fails only at view or picker time #20432, platform-objects: the ja-JP and es-ES object bundles ship 383 / 392 leaves as the English source string (Delegated Admin in the Invite dialog role list among them), the class #20462 fixed for zh-CN #20493 and the p0 [finding]resolveUserAuthzGrantskeeps EVERY organization-scoped grant when no organization is active, so a member removed from an organization keeps that organization's capabilities (measured:manage_metadatapasses onDELETE /packages/:id) #20515 are running.objectstack-fleet commentedon Sep 29, 2026
ACCEPT — PR #20524 at
4c5740d2d598ce1a6f64b2def69c848a2909e524domain:engine#1·session_01N8TPEsoJxPsdSdNKGnNGEN(os-warren) · written 2026-09-29T00:20Z. Contract review of record: 5881202199 on PR #20524, at-tier, read-only, PASS on this head.Checklist, verified against GitHub rather than the reports:
Form: draft, base
main, first lineFixes #20481, and that is the only closing keyword in the body. Claim 5879527319 names the branchclaude/issue-20481-date-write-iso-onlyand readsClause-②: no (narrowing), which is also the line in the changeset and the PR body.Scope: 5 files, +491 / −3:
record-validator.ts'sdatearm (+26 / −3), which reuses core's existingisUninterpretableTemporalComparand('date', value), so the write door and the comparand door share one reading;OS_TEST_POSTGRES_URL) and a driver-memory pin;Every file is inside claim 5879527319.
packages/coreandpackages/specare untouched, and no export is added.Changeset:
@objectstack/objectqlminor, with a BREAKING banner underClause-②: no (narrowing), and ADR-0087not-required (no-migration-prescription). It gives the before / after a caller sees and the one-line fix (sendYYYY-MM-DDor a JSDate).Check Changesetissuccess.Governed surface: none.
check-governed-merges --pr 20524finds 0 of 5 paths on the register. The PR is 494 changed lines.CI: 34 check-runs on the head: 31
success, 3skipped(path- or opt-in-gated), 0 failures.mergeable_stateisclean.Behaviour: a
datefield's write door stores a day or refuses. A string is admitted only with a leadingYYYY-MM-DDin years 0001..9999.2026/07/15,07/15/2026,15 July 2026and2026-7-15are now refused withVALIDATION_FAILED/ 400invalid_datebefore any driver write, on insert, update, multi-row update and the dry-runvalidate. Before, they were stored verbatim, or read by PostgreSQL's DateStyle.Date, an ISO date-time string and a leading-space ISO day keep their answer.datetimeandtimearms are untouched.Open question 1 (objectui's legacy per-row import fallback): A, as the seat answered (5880487779). The review re-verified both facts at objectui
main: the shipped console reaches/importthroughdata-objectstack→data.import, andparseDateCellemitsYYYY-MM-DD. There is no objectui card. The fallback's pre-check mismatch is an acceptance note.Deviations 1–6: accepted as the review answers them in ③.
packages/resthas no memory binding.origin/maintouched no objectql, core or driver-memory file.Carried out of this card:
2026-02-30is stored verbatim (500 on PostgreSQL), datetime2026-02-30T10:00:00Zrolls over to March 2, and a non-ISO datetime is read in the host zone #20525 (filed from this card's report; triage p1): an impossible calendar day, and a non-ISOdatetimeread in the host zone. Triage names it serial after this PR.datecell year below 1000 unpadded (0500-01-01→500-01-01), so after PR #20524 a valid ISO date cell is refused per row asinvalid_date, and before it a non-day was stored #20534 (filed bare, from the review's escalation):parseDateCellemits a year below 1000 unpadded. After this lands, a valid0500-01-01import cell is refused per row, although the write door takes it directly.YYYY-MM-DD", and the PR body's "always returnsYYYY-MM-DD", hold for years 1000..9999 only. /import: parseDateCell emits adatecell year below 1000 unpadded (0500-01-01→500-01-01), so after PR #20524 a valid ISO date cell is refused per row asinvalid_date, and before it a non-day was stored #20534 carries the rest.invalid_datemessage names ISO-8601 while it also refuses ISO basic20260715and the extended year+002026-07-15.Landing:
ready, then auto-merge through the queue. The merge closes this card (Fixes). The seat then verifies the squash onmainand removespm:dispatchedin the same act.objectstack-fleet commentedon Sep 29, 2026
Landing record: PR #20524 merged. This card is closed
completedby itsFixeslinedomain:engine#1·session_01N8TPEsoJxPsdSdNKGnNGEN(os-warren) · written 2026-09-29T00:42Z.Verified on
main:b2b6a0643325b689c7dfd8f50d7864f59d539695, merged 2026-09-29T00:41Z by the queue, with one parent. It is an ancestor oforigin/main, and theorigin/maintip is the squash itself.4c5740d2d's changes against its merge basefb194c70e(the same md5 over every added and removed line).datearm'sreadsAsDaygate is present at the squash and absent at its parent, and so is the20481-date-write-iso-onlychangeset.Fixes #20481, so no other card was closed.Delivered: a
datefield's write door stores a day or refuses.YYYY-MM-DDin years 0001..9999. It is judged by core's existingisUninterpretableTemporalComparand, so the write and comparand doors share one reading.2026/07/15,07/15/2026,15 July 2026and2026-7-15are refused withVALIDATION_FAILED/ 400invalid_datebefore any driver write. Before, they were stored verbatim, or read by PostgreSQL's DateStyle.datetimeandtimearms are unchanged.@objectstack/objectqlshipsminorwith a BREAKING banner,Clause-②: no (narrowing).Carried out of this card:
2026-02-30is stored verbatim (500 on PostgreSQL), datetime2026-02-30T10:00:00Zrolls over to March 2, and a non-ISO datetime is read in the host zone #20525 (p1): an impossible calendar day, and a non-ISOdatetimeread in the host zone. Its serial constraint on this PR is now cleared, and the seat claims it next.datecell year below 1000 unpadded (0500-01-01→500-01-01), so after PR #20524 a valid ISO date cell is refused per row asinvalid_date, and before it a non-day was stored #20534:/import'sparseDateCellemits a year below 1000 unpadded, so such a cell is now refused per row.invalid_datemessage's "ISO-8601" wording, and the REST PG cell being a named skip in CI.pm:dispatchedis removed in the same act as this record. The domain, area and type labels stay.fix(objectql)!: a date string is written in its YYYY-MM-DD form, or r…
fix(objectql)!: a temporal string is written on a real calendar day, …
fix(core,objectql)!: a temporal comparand is refused exactly when the…