Repository navigation
Commit b2b6a06
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](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 31d281d commit b2b6a06
5 files changed
Lines changed: 491 additions & 3 deletions
File tree
- .changeset
- packages
- drivers/driver-memory/src
- objectql/src
- validation
- rest/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
Lines changed: 62 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
Lines changed: 172 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
54 | 54 | | |
55 | 55 | | |
56 | 56 | | |
57 | | - | |
| 57 | + | |
| 58 | + | |
58 | 59 | | |
59 | 60 | | |
60 | 61 | | |
| |||
85 | 86 | | |
86 | 87 | | |
87 | 88 | | |
88 | | - | |
| 89 | + | |
89 | 90 | | |
90 | 91 | | |
91 | 92 | | |
| |||
1220 | 1221 | | |
1221 | 1222 | | |
1222 | 1223 | | |
1223 | | - | |
| 1224 | + | |
| 1225 | + | |
| 1226 | + | |
| 1227 | + | |
| 1228 | + | |
| 1229 | + | |
| 1230 | + | |
| 1231 | + | |
| 1232 | + | |
| 1233 | + | |
| 1234 | + | |
| 1235 | + | |
| 1236 | + | |
| 1237 | + | |
| 1238 | + | |
| 1239 | + | |
| 1240 | + | |
| 1241 | + | |
| 1242 | + | |
| 1243 | + | |
| 1244 | + | |
| 1245 | + | |
| 1246 | + | |
1224 | 1247 | | |
1225 | 1248 | | |
1226 | 1249 | | |
| |||
0 commit comments