Skip to content

fix(driver-sql): a MySQL date reads back the day it stores, so a year below 100 no longer comes back a century late - #20306

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20280-mysql-date-read-century
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20280-mysql-date-read-century

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #20280 — the date half. The datetime half stays open on the card: it needs a decision about ADR-0053 D-F2 (see "Open: the datetime half" below), so merging this must leave #20280 open.

Clause-②: no

What was wrong

On MySQL, driver-sql took mysql2's JS Date for a DATE column. mysql2 3.23.1 Packet#parseDate rebuilds it as new Date(Date.UTC(y, m - 1, d)) under the driver's timezone: 'Z' pin (new Date(y, m - 1, d) under 'local'), and both constructors read a year from 0 to 99 as 1900 + year. The write was right and the read was wrong: where placed_on $eq '0009-03-04' found the row and presented 1909-03-04.

What changed

  • packages/drivers/driver-sql/src/sql-driver.ts: a new withMysqlCalendarDayAsText, chained in withConnectBound beside withUtcSession and withPostgresCalendarDayAsText, sets dateStrings: ['DATE'] on a MySQL connection (mysql / mysql2, object or URL connection) unless the host set dateStrings itself. A function-valued connection is left alone, as withUtcSession leaves it.
  • The read doors already present a date through toDateOnly, which is @objectstack/core's temporalStorageForm. With the wire text arriving, that rule's string arm hands back the stored YYYY-MM-DD: the same path PostgreSQL's date takes since its calendar-day parser. No presenter changed and no copy of the rule was added.
  • Docblocks of withPostgresCalendarDayAsText and toDateOnly updated: a date now reaches the read doors as text on every dialect, MySQL included.

Measured (live MySQL 8.0.46, server time_zone='+08:00', process TZ=America/New_York, mysql2 3.23.1)

Records written through POST /api/v1/data/:object, read through driver.find / findOne, engine.find / findOne, POST …/query and GET …/:id (all six agree in every cell). Base 89f87f2344, head this branch.

year kind stored (CAST(… AS CHAR)) presented at base presented at head
0009 date 0009-03-04 1909-03-04 0009-03-04
0099 date 0099-03-04 1999-03-04 0099-03-04
0999 date 0999-06-15 0999-06-15 0999-06-15
0000 date 0000-06-15 1900-06-15 0000-06-15
1000 date 1000-01-01 1000-01-01 1000-01-01
2026 date 2026-03-04 2026-03-04 2026-03-04
9999 date 9999-12-31 9999-12-31 9999-12-31
0009 datetime 0009-03-04 10:00:00.000 2004-09-03T10:00:00.000Z unchanged
0099 datetime 0099-03-04 10:00:00.000 1999-03-04T10:00:00.000Z unchanged
0000 datetime 0000-06-15 10:00:00.000 2000-06-15T10:00:00.000Z unchanged
0999 / 1000 / 2026 / 9999 datetime stored as written the stored instant unchanged

A groupBy key on the date field, distinct, and min moved the same way (base 1900-06-15 / 1909-03-04 / 1999-03-04; head 0000-06-15 / 0009-03-04 / 0099-03-04). $eq and $gt found the same rows at base and head on both kinds. Year 0000 is recorded, not decided: it is inside what this head stores and reads, and the range decision on #20264 refuses it once that lands.

Remedy choice (the two measured on PR #20261's review)

Measured on a mysql2 connection per remedy, same rows:

remedy DATE 0009 / 0099 / 0000 DATETIME 0009 / 0099 / 0000 zero DATE / DATETIME
timezone: 'Z' (base) 1909 / 1999 / 1900 as a Date 2004-09-03 / 1999 / 2000 as a Date 1899-11-30 / Invalid Date
timezone: '+00:00' right, as a Date still folded Invalid Date / Invalid Date
dateStrings: ['DATE', 'DATETIME'] right, as text right, as text text / text
  • dateStrings for DATE is taken. It is the narrower change: it touches only DATE columns and no write, and it is the shape PostgreSQL already reads a day in. '+00:00' would also move the zone every bound Date and every DATETIME is rendered in, and fixes nothing more.
  • dateStrings for DATETIME is NOT taken, because ADR-0053 D-F2 (accepted; anchored in scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver.ts.json) says the mysql2 parser keeps materialising an instant as a Date, folded to text only at the driver's read doors. Text at the client parser for an instant is the ADR's "B1-full", an option not taken, and three landed pins encode it (sql-driver-13973-canonical-iso-read-door.test.ts §C, sql-driver-13567-audit-stamp-materialisation.test.ts §B3, sql-driver-14078-invalid-date-materialisation.test.ts §B2). Reversing that is an ADR decision, not a changeset.

Open: the datetime half

A MySQL DATETIME in years 0001..0099 still reads a century late. No read-door repair exists (the fold is not invertible: 2004-09-03 may be a real stored day), so any fix is at the client parser. The options and their measured costs are in the report on the card; the tests here pin the current datetime reading as OBSERVED so a decision that moves it has to move them.

Collateral (MySQL only)

  • Every read of a year from 1000 to 9999, on date, datetime, time, a TIMESTAMP column and null, is byte-identical base to head on find, findOne, count, aggregate (min, max, groupBy), distinct and a write-then-read (265 cells compared; the 25 that moved are exactly the year 0 / 9 / 99 date cells above, their groupBy / distinct / min keys, the raw execute() DATE, and the new connection option).
  • A raw execute() read, and a DATE column read under a field not declared date, now receive YYYY-MM-DD text where they received a Date (measured: Date(2026-03-04T00:00:00.000Z) became "2026-03-04"). A DATETIME there is still a Date. PostgreSQL has answered a date this way since its calendar-day parser.
  • A zero day (0000-00-00, only storable with NO_ZERO_DATE off) presents as that text, where mysql2 invented 1899-11-30. The zero DATETIME pin of The shared canonical-ISO normaliser turns an Invalid Date from a driver into a 500, where String() served text #14078 is untouched (still an Invalid Date).
  • SQLite and PostgreSQL: withMysqlCalendarDayAsText returns their config object unchanged (pinned), and the whole driver-sql suite is green on both.

Tests

  • New packages/drivers/driver-sql/src/sql-driver-20280-mysql-date-read.test.ts: the connection option (URL and object, host-set dateStrings kept, other dialects untouched, DATETIME / TIMESTAMP not in the list), then per dialect cell of the live matrix: stored text by raw cast, find / findOne, groupBy / distinct / min / max, $eq / $gt, the raw wire (date text everywhere, datetime a Date on live cells), a create / update / read round trip of years 42 and 1, and the datetime reading as observed.
  • New packages/rest/src/data-date-read-year-below-100.test.ts: the same read through engine.find / findOne / aggregate / count and POST /api/v1/data/:object, POST …/query, GET …/:id, on a SQLite cell (every runner) and a live MySQL cell (OS_TEST_MYSQL_URL; a named skip otherwise, a failure under OS_EXPECT_LIVE_DIALECT_MATRIX=1). No CI job runs packages/rest against MySQL today, so that cell runs only where a server is provisioned; the CI-run pin of the same read is the driver-sql file under Temporal Conformance (live PG + MySQL).
  • Corrected pins that described the old read: sql-driver-connect-bound.test.ts (the exact MySQL URL config gains dateStrings), sql-driver-11389-date-tz-skew.test.ts (two comments, plus the option asserted), sql-driver-20240-date-year-spelling.test.ts (header no longer says MySQL reads a year below 100 a century late; the write-path cell now also reads the year-9 row back through the driver).

Runs (live MySQL 8.0.46 on a private server at +08:00, live PostgreSQL 16.13 at Asia/Shanghai, TZ=America/New_York, OS_EXPECT_LIVE_DIALECT_MATRIX=1):

  • whole @objectstack/driver-sql suite at e9d1a26529: 203 files, 4669 passed, 1 skipped, "all 3 dialects were exercised"; the one test file changed after it (sql-driver-20280-mysql-date-read.test.ts, query options typed) re-run at 93af6580d2: 25 passed on 3 dialects.
  • REST query files (data-query-date-year-range, data-query-epoch-ms-date-comparand, the new file) at 93af6580d2: 3 files, 36 passed, SQLite and live MySQL cells.
  • pnpm --filter @objectstack/driver-sql typecheck exit 0 (the new test is in the tsc program, --listFiles count 1); pnpm --filter @objectstack/rest typecheck exit 0 (test layer: 0 files / 0 errors held).
  • eslint --no-inline-config on the 6 changed TypeScript files: 6 files, 0 errors, 0 warnings. That narrowing is complete: eslint.config.mjs lints **/*.{ts,…} and enables no type-aware rule, so this diff cannot move a verdict on an untouched file.

Ablation (committed first; restore proved by blob equal to HEAD and a clean git status --porcelain): removing the withMysqlCalendarDayAsText call from withConnectBound

  • driver-sql, 4 files on 3 dialects: 9 failed / 107 passed. Every red is a MySQL cell or a config pin; every SQLite and PostgreSQL cell stayed green.
  • REST, with driver-sql's dist/ rebuilt around the mutation (ablation-dist-preflight proved the call absent from dist/ in the mutate leg, present again after the restore rebuild): 4 failed / 6 passed, the four being every date cell of the live MySQL cell.

Changesets

Acceptance notes

  • check:dual-build-cjs-loads printed PREREQUISITE NOT MET (it reads every package's dist/; this worktree built only the driver-sql and REST closures): NOT MEASURED here, CI measures it.

Generated by Claude Code

…100 presents the day it stores

mysql2 rebuilt a DATE with Date.UTC(y, m-1, d), which reads a year 0..99
as 1900 + year: a stored 0009-03-04 was presented as 1909-03-04. The
connection now asks mysql2 for the DATE text (dateStrings: ['DATE']),
presented through toDateOnly / temporalStorageForm as PostgreSQL's day
already is. DATETIME keeps the client parser's Date (ADR-0053 D-F2).

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…rect the pins that described the old fold

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
… live MySQL cells

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…20240 note it makes false

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 4 documentable anchor(s).

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

  • content/docs/data-modeling/drivers.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/permissions/tenant-audit-census.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/types.mdx (via SqlDriver (symbol, a top-level class))

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

  • content/docs/releases/v17/17-0.mdx (via SqlDriver (symbol, a top-level class))

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

What this run could not see
  • 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 — 11 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 a78f731add67eab50b3e969e8ad46e44d195ab12 → packageMentionDocs.

Which tree this was computed on

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

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a78f731add67eab50b3e969e8ad46e44d195ab12 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 93af6580d2df186e1f3dab007f14a86c9e2d4b83
Local-runs: probe — live MySQL 8.0 cells for years 0/9/99, which no check-run on this head reports per year. The seat's brief did not follow the read-only template and also directed closure builds, a suite run and an ablation; that is a seat slip, recorded on seat post #6367

Scope: PR #20306 (card #20280, Part of, the date half), 8 files, +579/−14 (git diff --stat e6b7d8c861 93af6580d2: 2 changesets, sql-driver.ts, 4 driver-sql tests, 1 REST test), against merge base e6b7d8c861 = merge-base(head, origin/main). The branch's merge commit e9d1a26529 brought exactly the 5 main commits 89f87f2344..e6b7d8c861 (e6b7d8c861, ae8e3ca016, 5f9d7d7864, 3f86dc52f2, 47849e7209; 37 files) — intersection with the 8 PR files EMPTY. Measured in fresh detached worktrees <scratchpad>/pr-20306/rev/{head,base}, closures turbo run build --filter=@objectstack/rest... --filter=@objectstack/driver-sql... --force (25/25 tasks each). Databases: private MySQL 8.0.46 on 127.0.0.1:33306, --default-time-zone='+08:00' (CI's setting), default sql_mode (STRICT_TRANS_TABLES,NO_ZERO_DATE,…), mysql2 3.23.1; the seat's PostgreSQL 16.13 cluster through my own role/db rev20306r2 (server Asia/Shanghai, DateStyle ISO, MDY); process TZ=America/New_York. Harness (reviewer-written, scratch): imports each tree's BUILT dist through packages/rest's resolution; doors driver.find/findOne/count/aggregate/distinct/execute, engine.find/findOne/count/aggregate/insert, REST POST /api/v1/data/:object, POST …/query, GET …/:id on a real RestServer; rows in years 0, 9, 99, 999, 1000, 2026, 9999 and a null row on date + datetime + time fields plus an undeclared DATE and a TIMESTAMP column; 459 cells per tree, 407 identical, 52 moved, every one classified below. Current origin/main at review time de091b50e6.

① Derived judgments

  • The rows — CORRECT, every door agrees. Stored (CAST(… AS CHAR)) identical base → head for every row (0000-06-15, 0009-03-04, 0099-03-04, 0999-06-15, 1000-01-01, 2026-03-04, 9999-12-31; datetimes 0009-03-04 10:00:00.000 etc.). date presented at head = stored day on all six doors (driver.find/findOne, engine.find/findOne, …/query, GET …/:id): y0 0000-06-15 (base 1900-06-15), y9 0009-03-04 (base 1909-03-04), y99 0099-03-04 (base 1999-03-04), y999/1000/2026/9999 unchanged. datetime byte-identical base → head at every door: y0 2000-06-15T10:00:00.000Z, y9 2004-09-03T10:00:00.000Z, y99 1999-03-04T10:00:00.000Z (still folded, per ruling C), y999 0999-06-15T10:00:00.000Z, 1000/2026/9999 as stored. $eq/$gt on both kinds, 7 years × 3 doors (84 cells): identical base → head and correct ($eq finds its row; $gt orders); placed_on $eq '1909-03-04' finds nothing at head (engine.count 0). date groupBy key, distinct, min: base 1900-06-15/1909-03-04/1999-03-04 → head 0000-06-15/0009-03-04/0099-03-04 on driver, engine and REST; max 9999-12-31 both. datetime/time groupBy/distinct/min/max identical (min opened_at 2000-06-15T10:00:00.000Z on both trees — the year-0 fold). Bar met: every date cell presents its stored day; every datetime cell byte-identical.
  • ADR-0053 D-F2 — fits; the MySQL counterpart of the pg date parser. D-F2: "The pg and mysql2 type parsers are not touched. A Date stays the client-level materialisation — a raw knex read of the same row still hands one back, and a host's own pg clients keep the stock behaviour — and the driver canonicalises in formatOutput / presentReadValue. This is the narrow form of B1: the date parser installed by withPostgresCalendarDayAsText stays the one place a clock is chosen, the instant types keep their stock parser, and nothing outside the driver's own read doors moves." D-F1 scopes the addendum: it "governs … ONLY the two instant classes named here" and lists Field.date among the classes it does NOT rule. dateStrings: ['DATE'] is a mysql2 connection option on the driver's own connections (not a process-wide parser mutation), selects the wire text for the DATE column type alone, and chooses no clock at all; measured at head the instant types keep their stock parser — raw execute() still hands a Date for DATETIME(3) (Date(2004-09-03T10:00:00.000Z) for y9) and for TIMESTAMP (Date(2026-03-04T10:00:00.000Z)), the zero DATETIME still Date(Invalid) (D-F3). The sql-driver-13973 §C raw-read pin checks INSTANT_COLUMNS only and is green. So it touches none of what D-F2 forbids and is the exempted calendar-day parser's twin. D-B1: the MySQL date read now presents the date canon (Phase 1 YYYY-MM-DD, which D-B1 says the instant canon "matches") through temporalStorageForm: toDateOnly is return temporalStorageForm(value, 'date'), called by formatOutput's date arm and presentReadValue('date'); the wire text enters the string arm and leaves as its first 10 chars. The diff adds no presenter and no MySQL arm — only the chained call, the private static and a private constant.
  • No collateral — CONFIRMED. Years 1000..9999 on date, datetime, time, the TIMESTAMP column and null: byte-identical base → head on find, findOne, count (8 = 8), aggregate (min/max/groupBy), distinct and the write-then-read (rt1 2026-12-31 / 2026-12-31T23:59:59.999Z / 23:59:59.999; engine.insert 1000-01-01), on driver, engine and REST. The 52 moved cells: 18 = year 0/9/99 date at the 6 doors; 7 = date groupBy/distinct/min-max keys (driver 3, engine 2, REST 2); 12 = the undeclared DATE column (d_raw) at the 6 doors for y9 and y2026 (Date → text); 7 = raw execute() DATE (declared y0/y9/y99/y2026/y9999 and d_raw y9/y2026, Date → text); 2 = the year-42/year-1 round trip (1942-01-31/1901-12-31 → 0042-01-31/0001-12-31); 3 = the zero DATE; 3 = the connection option. No unexplained movement. Raw door: base Date(2026-03-04T00:00:00.000Z) → head "2026-03-04"; PostgreSQL's raw read of a DATE is text at base AND head ("0009-03-04", "2026-03-04"; select date '0009-03-04' → "0009-03-04", timestamptz → Date). Disclosed in the changeset ("A raw execute() read, and a DATE column read under a field that is not declared date, now receive the YYYY-MM-DD text … PostgreSQL already answers a date column this way") and the PR body. Not a D-F2 conflict: D-F2's raw-read sentence is about the instant columns (§C's INSTANT_COLUMNS), and PG's DATE has been text at the raw door since 17.3.0 (c05b40b: "on PostgreSQL a raw read … now yields a string for a date column where it previously yielded a Date"), before the D-F addendum was written over it — covered by that precedent. Zero DATE (NO_ZERO_DATE lifted at GLOBAL, restored after; session mode measured): stored 0000-00-00; base find/distinct 1899-11-30, raw Date(1899-11-30T00:00:00.000Z); head 0000-00-00 text at all three; zero DATETIME Date(Invalid) at both trees (The shared canonical-ISO normaliser turns an Invalid Date from a driver into a 500, where String() served text #14078 pin untouched). Acceptable and disclosed: a stored zero day is no longer invented as a real day. Host dateStrings true / false / ['DATE','DATETIME'] kept verbatim; a function-valued connection is the same function object (===). SQLite/PostgreSQL: pg connection.dateStrings undefined; every PG cell identical base → head. Whole @objectstack/driver-sql suite at head, OS_EXPECT_LIVE_DIALECT_MATRIX=1, 3 dialects: 203 files, 4651 passed / 1 failed / 18 skipped, "all 3 dialects were exercised"; the 3 red files are vitest timeouts (two 10 s hooks in sql-driver-unresolvable-where-column-refusal and sql-driver-upsert-conflict-target-dialects, live-postgres cells; one 5 s test in sql-driver-sys-setting-organization-unique) on a 4-core box at load ≈54 while both closure builds and the REST run were live; 17 of the 18 skips are that first file's tests downstream of its timed-out beforeAll, the 18th is schema-drift.base-type-mismatch's 1 skipped (the dev's "1 skipped"). Re-run alone: 3 files / 148 tests passed. None of the three is in the PR's file set. New REST file at head with OS_TEST_MYSQL_URL: 1 file, 10 passed (SQLite + live MySQL cells).
  • Pins discriminate — REPRODUCED EXACTLY. Committed head; mutation SqlDriver.withMysqlCalendarDayAsText(SqlDriver.withUtcSession(bounded)) → SqlDriver.withUtcSession(bounded), anchor 1 → 0, blob a6acc3cd35 → 02daf085b0 (the dev's mutated blob). 4 files on 3 dialects: 9 failed / 107 passed, all 3 dialects exercised. Reds: 11389 "leaves the other dialects alone" (config pin, :294), 20240 live mysql "the write path stores the four-digit year" (:153), 20280 config ×2 (URL+object dateStrings; "only DATE"), 20280 live mysql ×4 (find/findOne; groupBy/distinct/min/max; raw wire; round trip), connect-bound mysql2 URL exact config (:71). Every red a live-MySQL cell or a config pin; zero SQLite/PostgreSQL reds. Restore git restore --source=HEAD --staged --worktree: blob a6acc3cd35 == HEAD, git status --porcelain empty, git diff HEAD empty; restored run 4 files / 116 passed. (Control before mutating: 116 passed.)
  • Gates (head tree, current PR body as --event): check-changeset-no-major --base e6b7d8c861 --event event.json exit 0: "✓ This diff introduces no major bump. ✓ LEVEL AXIS: this PR declares clause-② no, so no package here is declared to have grown a published surface. · declaration line: Clause-②: no · direction arm: none declared". check-adr-0087-registration --base e6b7d8c861 exit 0: "✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (2 non-breaking changeset(s) seen)." check-partof-closing-keyword with PR_BODY/PR_NUMBER=20306 exit 0: "✓ check:partof-closing-keyword: PR fix(driver-sql): a MySQL date reads back the day it stores, so a year below 100 no longer comes back a century late #20306 carries no Part-of/closing-keyword contradiction and no closing keyword bound to a card its own sentence says it is not closing."
  • Prose, sentence by sentence — no FALSE sentence. 20280-* changeset: mechanism (new Date(Date.UTC(y, m - 1, d)) under timezone: 'Z', read from mysql2 3.23.1 Packet#parseDate), the 5-row table (all cells measured), "read doors present that text through temporalStorageForm", "PostgreSQL has read a day as text the same way since its calendar-day parser", the "Unchanged" list, "SQLite and PostgreSQL reads do not move", the three "Also moved" bullets, "Not changed: a datetime field … 2004-09-03T10:00:00.000Z" — all TRUE. 20240-* DELIBERATE CORRECTION, confirmed as written: git diff --numstat 1/1, only line 38 of 38 differs; old "a stored year below 100 still reads back a century late (0009-03-04 as 1909-03-04, mysql2's Date.UTC reading of a DATE), which this change does not touch." → new "a stored year below 100 read back a century late (…), which this change does not touch and driver-sql on MySQL reads a year 0..99 back a century late — REST create stores placed_on: "0009-03-04" correctly, and …/query returns "1909-03-04"; a datetime 0009-03-04T10:00Z returns 2004-09-03T10:00Z #20280, in the same release, corrects by reading a MySQL DATE as its text." — TRUE at head (base 1909-03-04, head 0009-03-04; both notes pending in .changeset/ at head; 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's rule untouched — stored cells identical); every other line byte-identical. No other pending changeset (828 at head; every MySQL / read-path / Date.UTC / zone sentence grepped: 17469, 17973, 19844, 19868, 20203, 20240 line 32) reads FALSE. Test headers (20280 driver + REST, edited 11389, 20240): the tables, "parseDateTime hands the wire text to V8's non-ISO Date parser", "no CI job runs this package against a MySQL server" (ci.yml sets OS_TEST_MYSQL_URL only for driver-sql and the metadata-protocol live step), "under its default 'local' zone mysql2 materialises a DATE at local midnight" — TRUE. The three edited existing pins: connect-bound toEqual gains dateStrings: ['DATE'] (measured connection.keys = connectTimeout,dateStrings,timezone,uri); 11389 adds the dateStrings assertion beside the unchanged afterCreate/timezone ones; 20240 adds findOne(w1) → '0009-03-04' on every dialect — all TRUE at head and all red under the ablation. PR body: the mechanism, the Measured table (13 rows), the remedy table (re-measured directly on mysql2: Z → 1909/1999/1900 + 2004-09-03/1999/2000 + zero 1899-11-30/Invalid; +00:00 → right Dates, DATETIME still folded, zero Invalid/Invalid; dateStrings [DATE,DATETIME] → text/text), the collateral bullets, the tests and ablation counts — TRUE; the dev's "265 cells / 25 moved" is its own harness's count (mine 459 / 52), the classes match exactly. "Open: the datetime half" — TRUE (fold not invertible; measured folded at head).
  • Scope and merge. packages/core, packages/spec, packages/objectql: untouched (0 paths). Non-MySQL paths: withMysqlCalendarDayAsText returns the same config object for a non-MySQL client (pinned; pg dateStrings undefined); the withPostgresCalendarDayAsText / toDateOnly edits are docblock-only. git merge-tree --write-tree --name-only in a driver-free bare probe against origin/main de091b50e6: tree 25f0d24997, no conflicted path, exit 0.
  • CI at head (34 check runs, all completed; read last): 30 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 1 failure. Required contexts all success: Lint & Repo Gates 108696783472, TypeScript Type Check 108699397479, Test Core 108700149511, Dogfood Regression Gate 108698128669, Build Core 108696834120, Temporal Conformance (live PG + MySQL) 108696834194 success, Governed Surface Queue Guard 108696782913. The failure is Check Changeset 108696783049, by design on exactly one name: "✓ No empty-frontmatter changeset introduced by this diff (2 declaring changeset(s) added). This PR changes a changeset it did not add: .changeset/20240-date-year-four-digits.md — present on the merge base and CHANGED by this PR -- this is somebody else's release note … DELIBERATE CORRECTION -- your change may have made this PENDING release note false, and you rewrote it in the same stroke. Remedy: do NOT restore it -- say so on the PR and get it confirmed". No other failure.

② Semver level

  • Clause-②: no is correct: no packages/spec path, no new export (withMysqlCalendarDayAsText and MYSQL_TEXT_TEMPORAL_TYPES are private static), no authorable key, no schema; the level-axis gate accepts it with the current body.
  • The raw-door type change (Date → string for a DATE on MySQL via execute() / an undeclared DATE column) widens or narrows no PUBLISHED contract: IDataDriver.execute() is typed Promise<unknown> (packages/spec/src/contracts/data-driver.ts:108); every declared-door type is unchanged (a date was already string, only the day moves). It is a runtime-shape movement of the class the repo shipped as minor twice (17.3.0 c05b40b for the PG date parser, 17.4.0 45cfa1b for D-F1). The changeset declares @objectstack/driver-sql patch. Judged acceptable: a bug fix at the declared doors, the raw movement disclosed in the note in the 17.3.0 entry's own words, and the lockstep fixed group already ships this release at minor through pending 20240-* (core/objectql minor), so the grade moves no version. Recorded as a flag, not a must-change; the seat may prefer minor by precedent.
  • ADR-0087: no declared-breaking changeset; gate exit 0. The 20240-* one-clause correction changes no level.

③ Boundary flags

Implemented-by: claude/issue-20280-mysql-date-read-century
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

…sql-date-read-century

# Conflicts:
#	.changeset/20240-date-year-four-digits.md
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c35bfbf2308f9bd89e774de9db321fdf72436ddf
Local-runs: none

Scope: PR #20306 (card #20280, Part of, the date half) at c35bfbf230, a DELTA on the record of record 5860517947 (PASS at 93af6580d2). Inputs: card #20280 body and its 4 comments (triage 5858482980, claim 5858607214, os-dev-report 5859394812, seat ruling 5859414357); PR #20306 body, file list and both comments (docs-drift 5859370725, record 5860517947); the head's 34 check-runs, read last at 23:23Z when every one had completed; git reads only (fetch, show, diff, diff-tree, merge-tree, rev-parse) in the session clone. Nothing checked out, built, run or re-run; the record template was rendered from origin/main's scripts/pm/record-recognisers.mjs --template.

The delta, verified from git. c35bfbf230 is one merge commit, parents 93af6580d2 (the PASSed head) and a78f731add (= origin/main at review time, main after #20307 / #20263). git merge-tree --write-tree 93af6580d2 a78f731add → tree 3b650c4ca5, exactly one conflict, .changeset/20240-date-year-four-digits.md (stages 7ec207b6 base / e243f7a1 PR / 7dc31be7 main). git diff-tree 3b650c4ca5 355d7da95a (the head tree) differs in .changeset alone, so the merge did nothing beyond resolving that conflict; the 236 paths of 93af..head are exactly the 236 paths of merge-base(e6b7d8c861)..a78f731add (set difference empty both ways). Net diff against main: 8 files, +579/−14, the same 8 as at 93af. Blob identity 93af → head: 20280-mysql-date-read-text.md c86edc07, sql-driver.ts a6acc3cd, sql-driver-20280-mysql-date-read.test.ts cb5b439a, sql-driver-11389-date-tz-skew.test.ts baa5e158, sql-driver-20240-date-year-spelling.test.ts e9154f6e, sql-driver-connect-bound.test.ts 56c47251, packages/rest/src/data-date-read-year-below-100.test.ts 859f739a — 7 of 8 IDENTICAL; the 8th, 20240-date-year-four-digits.md, e243f7a1 → 3847eb46. git diff origin/main c35bfbf230 -- .changeset/20240-date-year-four-digits.md: numstat 1/1, line 38 of 38 alone, no conflict marker, 38 lines at main, 93af and head alike. The resolved line carries BOTH clauses: main's (#20307) "having reaches the same door in the same release (#20263), so a number or Date outside 0..9999 is refused there too." and this PR's "a stored year below 100 read back a century late (0009-03-04 as 1909-03-04, mysql2's Date.UTC reading of a DATE), which this change does not touch and #20280, in the same release, corrects by reading a MySQL DATE as its text." The 93af → head diff of the file is only the having sentence swap; nothing else on the line moved.

① Derived judgments

  • Code record carries forward — CORRECT. The seven code, test and changeset blobs are identical to the PASSed head, so record 5860517947's ① stands for them; re-read at head rather than trusted: withConnectBound first moves a URL connection into { uri, connectTimeout } for mysql / mysql2 (DIALECT_CONNECT_TIMEOUT is spelled from MYSQL_EMIT_CLIENTS), then chains withPostgresCalendarDayAsText(withMysqlCalendarDayAsText(withUtcSession(bounded))); withMysqlCalendarDayAsText gates on MYSQL_EMIT_CLIENTS.has(clientSpelling(cfg)) (mysql, mysql2), returns the SAME config object for every other client, leaves a non-object (function-valued) connection and a host-set dateStrings (!== undefined) alone, else sets dateStrings: ['DATE'] (MYSQL_TEXT_TEMPORAL_TYPES, private static readonly). The date read arms are unchanged: presentReadValue('date') and formatOutput's dateFields loop call toDateOnly, which is temporalStorageForm(value, 'date') from @objectstack/core, whose string arm hands back the wire YYYY-MM-DD. Accept-set at the declared doors: unchanged (a date field was already a string; only the day moves, from the invented 19xx to the stored one). Public surface: none added (private static member and constant; IDataDriver untouched). Runtime-shape movement on MySQL only, disclosed in the note: raw execute() and an undeclared DATE column receive text where they received a Date; a zero DATE presents 0000-00-00 where mysql2 invented 1899-11-30; DATETIME / TIMESTAMP keep the client parser's Date (ADR-0053 D-F2, ruling C). SQLite and PostgreSQL untouched by construction and pinned ("touches no other dialect").
  • The resolved sentence, read literally — TRUE, clause by clause. (i) "a stored year below 100 read back a century late (0009-03-04 as 1909-03-04, mysql2's Date.UTC reading of a DATE)" — the card's and the PR's measured table; past tense correct at head. (ii) "which this change does not touch" — 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's change (the temporalStorageForm padding) does not touch the MySQL read, and this PR touches no packages/core path. (iii) "driver-sql on MySQL reads a year 0..99 back a century late — REST create stores placed_on: "0009-03-04" correctly, and …/query returns "1909-03-04"; a datetime 0009-03-04T10:00Z returns 2004-09-03T10:00Z #20280, in the same release, corrects by reading a MySQL DATE as its text" — the correction IS dateStrings: ['DATE'], the wire text, shipped by this PR under card driver-sql on MySQL reads a year 0..99 back a century late — REST create stores placed_on: "0009-03-04" correctly, and …/query returns "1909-03-04"; a datetime 0009-03-04T10:00Z returns 2004-09-03T10:00Z #20280 (Part of, the date half, the only half this sentence names); "same release": .changeset/20240-date-year-four-digits.md (@objectstack/driver-sql patch; core / objectql minor; driver-memory patch) and .changeset/20280-mysql-date-read-text.md (@objectstack/driver-sql patch) are both pending at head and both in the fixed lockstep group of .changeset/config.json, so they version together. (iv) "having reaches the same door in the same release (objectql having: a comparand on an aggregated date column never meets the temporal-comparand door — over REST having { last_placed: { $lt: "not-a-date" } } on max(placed_on) keeps every group (200) while its where twin answers 400 #20263), so a number or Date outside 0..9999 is refused there too" — .changeset/20263-having-temporal-comparand-door.md (@objectstack/objectql minor) is pending on main at a78f731add; its body says having runs "the same walk and the same predicate, isUninterpretableTemporalComparand in @objectstack/core, that the door runs on where", and packages/objectql/src/engine-aggregate-having-temporal-door.test.ts on main pins the number and the Date for 10000-01-01 on max(placed_on) refused with "outside the years 0000 to 9999". Same door, same pending release. No FALSE sentence on the line.
  • The merge is a working merge — answered by the head's CI, not assumed. main's 236 incoming paths include packages/rest/src/rest-server.ts, packages/rest/src/index.ts and packages/objectql/src/engine.ts, having-filter.ts, temporal-comparand-door.ts — the same packages the new REST pin drives (RestServer, engine.aggregate) — so AGENTS.md §10's full re-check applies, and on this head it is the check-runs: Build Core success; TypeScript Type Check success (all four Type Check · jobs); Test Core success (6 of 6 shards — where the SQLite cell of data-date-read-year-below-100.test.ts and the driver-sql config pins run); Temporal Conformance (live PG + MySQL) success (the live MySQL cells of sql-driver-20280-mysql-date-read.test.ts and of the edited 20240, 11389 and connect-bound pins). No packages/core, packages/drivers or lockfile path came in with main.
  • CI at head — 34 check-runs, all completed: 30 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 1 failure. The seven required contexts are all success: Lint & Repo Gates 108727994447, TypeScript Type Check 108730290161, Test Core 108730653511, Dogfood Regression Gate 108729707796, Build Core 108728045909, Temporal Conformance (live PG + MySQL) 108728045849, Governed Surface Queue Guard 108727994312. Also green: Part-of PR must not also close its card, The card this PR closes must claim this branch, both same-issue / single-writer guards. The failure is Check Changeset 108727994470, whose annotation names exactly one path, .changeset/20240-date-year-four-digits.md, in the gate's DELIBERATE CORRECTION arm ("do NOT restore it -- say so on the PR and get it confirmed"). A by-design red, and the three SKILL.md conditions hold: the gate's source (scripts/check-empty-changeset.mjs, "The foreign changeset rule (finding: random changeset filenames collide silently across parallel agents — a round overwrote a sibling PR's minor changeset and every gate stayed green #17712)", ruling D) states the arm; .github/workflows/pr-automation.yml runs on pull_request only, never merge_group; the PR body names the gate and the reason (its "Changesets" section). Per landing-operations.md, the same-head at-tier PASS record is the confirmation — given in ③.

② Semver level

  • .changeset/20280-mysql-date-read-text.md (blob-identical to the PASSed head): @objectstack/driver-sql patch, Clause-②: no; the PR body and the claim comment both carry Clause-②: no. Correct: a bug fix in a released package takes patch, never none and never skip-changeset; no packages/spec path, no new export, no authorable key, no schema — nothing widened, nothing narrowed at a declared door.
  • The one-clause correction of 20240-date-year-four-digits.md changes no level: its frontmatter and every other line are byte-identical to main.
  • Carried forward, still not verdict-bearing: the raw execute() Date → string movement on MySQL is the class the repo shipped as minor twice (17.3.0 PG date parser, 17.4.0 D-F1); patch is accepted because IDataDriver.execute() is typed Promise<unknown>, the movement is disclosed in the note, and the lockstep group already ships this release at minor through pending 20240-* and 20263-*, so the grade moves no version.
  • ADR-0087: this PR adds no declared-breaking changeset; Lint & Repo Gates, which carries check:adr-0087-registration and check-changeset-no-major, is success on this head.

③ Boundary flags

Implemented-by: claude/issue-20280-mysql-date-read-century
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 23:27
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit d3958ba Sep 27, 2026
35 of 36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20280-mysql-date-read-century branch September 27, 2026 23:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants