Skip to content

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

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-20264-temporal-year-range
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-20264-temporal-year-range

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #20264

Clause-②: yes (narrowing)

A date or datetime value now names a year from 0001 to 9999, or it is refused: INVALID_FILTER / 400 as a comparand on where, a per-aggregation filter and having, and VALIDATION_FAILED / 400 (invalid_date) as a written value. This is triage's ruling on the card (5858474998): "The supported year range is 0001..9999 for both date and datetime." Year 0000 joins the refused range, and the date arm's padding covers 0001..0999. One range function in @objectstack/core answers both doors. No driver source is edited.

Stop valve (claim 5872518067): it fired. On a local MySQL 8.0.46, a datetime in years 0001..0099 is still stored right and read back a century late through mysql2's instant parser. That cell is returned as needs_decision (see the section below). Everything else lands here.

Patch round 1 (at-tier review 5874841530, amended claim 5874849531) touches .changeset/20264-temporal-year-range.md only; no code or test changed.

  • Clause-② is now yes (narrowing), because @objectstack/core gains the root export isOutsideTemporalYearRange. The levels, the BREAKING banner, the ADR-0087 marker and the FROM → TO line are unchanged.
  • The note now says a datetime where bound in year 10000 answered 7/0/0 for $gt / $lt / $eq. The right answer is 0/7/0.
  • The note's Unchanged clause now makes one exception to "refused in its existing words". A date-column string whose instant names a year outside 0001..9999 (+010000-01-01T00:00:00.000Z, -000001-…, an out-of-range epoch-millisecond string) is refused with the same code and status on where, the per-aggregation filter and having, but now in the year-class words.
  • The new head b559a5d0e is that commit (f16ff84ac) plus a merge of origin/main b810ddb6f. Every other file of this PR is blob-identical to 311ce0640.

What changed

  • packages/core/src/utils/temporal-storage-form.ts
    • New export isOutsideTemporalYearRange(value, kind), the one range. The year is the one the kind's rule reads:
      • datetime: the UTC year of the instant canonicalUtcDatetime reads. That function now shares one private instantMs reader with the range, so the two cannot drift.
      • date: a string's leading YYYY-MM-DD year. Otherwise, the UTC year of the instant the value names.
      • time: never judged.
    • The date arm pads years 0001..0999 only. Year 0 keeps its unpadded spelling (0-06-15), like every other year outside the range. The rule stays total, and the datetime spelling of any instant is unchanged.
  • packages/core/src/utils/temporal-comparand.ts: isUninterpretableTemporalComparand asks the range for a date or datetime number, Date or readable string. #20240's private isOutsideCalendarDayYears (0..9999, date only) is removed. time is untouched.
  • packages/objectql/src/temporal-comparand-door.ts: the year-class refusal now covers both kinds, in words that name 0001 to 9999. where, the per-aggregation filter and having inherit the range through the one predicate. The door asks core's isOutsideTemporalYearRange which class a hit is, and never re-derives the range.
  • packages/objectql/src/validation/record-validator.ts, the date / datetime arm only: a readable value outside the range fails invalid_date, with the same code, constraint and message key as any other invalid date. This covers insert, update, a multi-row update and engine.validate. The number arm (PR feat(objectql,spec)!: enforce a field's declared precision (total digits) at the write seam — max_precision (#19992) #20423) is not touched.

Measured: base b28550818 vs head f2d96c96c

Scratch harness, not committed. Drivers: InMemoryDriver, and SqlDriver on SQLite, on a local PostgreSQL 16.13 (server Asia/Shanghai) and on a local MySQL 8.0.46 (+08:00), with TZ=America/New_York. Doors: the engine and REST (POST /data/:object/query, POST /data/:object). Data: seven 2026 rows. The harness compares 236 cells: 160 identical, 76 moved. Every moved cell went from a misorder, a 500 or a stored non-day to a 400. No in-range cell and no control moved.

position value base: memory · SQLite · PG · MySQL head, all four
where datetime, $gt/$lt/$eq year 10000 or −1: number, Date, ISO 7/0/0 · 7/0/0 · 500 · 500 400 INVALID_FILTER
per-agg filter $gt / having $gt on min(datetime) the same 7 / 4 groups on all four 400 INVALID_FILTER
where datetime year 0 7/0/0 · 7/0/0 · 500 · 7/0/0 400 INVALID_FILTER
where date year 0: number, Date, ISO, bare 0000-06-15 7/0/0 · 7/0/0 · 500 · 7/0/0 400 INVALID_FILTER
REST create date +010000-01-01T00:00:00.000Z / -000001-… 201 verbatim · 201 verbatim · 500 · 500 400 VALIDATION_FAILED
REST create date / datetime year 0 201 · 201 · 500 · 201 400 VALIDATION_FAILED
edges 0001-01-01, 9999-12-31T23:59:59.999Z, 2026 control every spelling, every position read identical

H1 held: the card's table reproduces on origin/main in every cell. PR #20261 (#20240) and #20263 had already moved only the date 10000 / −1 cells, and those are unchanged.

The PM's hypotheses

  • H2. The one place is core's isOutsideTemporalYearRange. It is called by the predicate (and through it by the three comparand positions, judgeFilter and service-analytics' decline) and by the record validator. Each caller of the storage rule:

    • The engine's write coercion (resolveNowDefault / normalizeExpressionDefault) runs before validateRecord on insert, so a defaulted year outside the range is refused.
    • SqlDriver.formatInput and memory-temporal.ts read temporalStorageForm and still see an out-of-range year, but only on a direct driver call that bypasses the engine. Both doors sit in front of them. Their source is not edited.
    • mongodb-temporal.ts keeps its own copy (storageDatetimeValue / storageDateValue). This card needs no edit there, because both doors are engine-level. The copy's drift is pre-existing (no four-digit padding for a Date year 1..999, no number arm on date), and is noted below, not changed.
  • H3. The write door is validateRecord's date / datetime arm, reached from the engine and REST create, PATCH and the multi-row update. It is refused there through the same range.

  • H4. These pins asserted year 0000 as accepted. Each is flipped as the ruling says:

    • core temporal-comparand.test.ts IN_RANGE the first millisecond of year 0;
    • core temporal-storage-form.test.ts padding cases 0000-06-15 and 0000-01-01, now 0-06-15 and 0-01-01;
    • objectql engine-date-year-range-door.test.ts IN_RANGE 0000-01-01.

    These pins asserted a datetime number, Date or extended-year string as read, and are flipped too:

    • core leaves the datetime and time rules alone;
    • objectql leaves the datetime and time fields alone;
    • objectql having UNCHANGED an extended-year ISO on min(datetime);
    • REST data-query-date-year-range.test.ts's datetime control. It now reads a 2026 instant.

Stop valve: MySQL datetime in 0001..0099 (needs_decision)

The cell was measured live on MySQL 8.0.46, through REST create then query, at base and at head (identical):

  • 0001-01-01T00:00Z reads back as 2001-01-01T00:00Z, 0001-03-04T10:00Z as 2004-01-03, 0050-… as 1950-…, 0069-… as 1969-…, 0070-… as 1970-…, and 0099-… as 1999-….
  • 0100, 0101, 0500, 0999 and 1000 read back as written.
  • The stored text (CAST(… AS CHAR)) is right in every case.

The mysql2 read parser is not touched here (ADR-0053 D-F2). The two options are in the os-dev-report on #20264. #20280 remains open for its datetime half, per ruling 5859414357.

DELIBERATE CORRECTION: three pending release notes

Check Changeset will be red on these three names by design. Each file gets one clause, correcting a sentence this change makes false in the same release. Do NOT restore them from base.

The claim's file surface names .changeset/20264-*.md only. These three are an in-place addition, declared here and in the report.

Tests and gates, measured at 311ce0640

311ce0640 is the merge of origin/main dc0ab6a2e into this branch, and it carries PR #20423's record-validator number arm. These readings are its own.

  • Full suites:
    • core: 56 files / 1490 passed, plus test:repo 3 / 48.
    • objectql: 328 files / 6077 passed, plus test:repo 1 / 5.
    • rest: 217 files / 3916 passed / 34 skipped, plus test:repo 1 / 8.
    • driver-memory: 58 files / 1378 passed.
    • service-analytics: 132 files / 3093 passed.
    • driver-sql, the whole suite: 207 files / 4714 passed / 1 skipped, with "all 3 dialects were exercised". It ran with TZ=America/New_York, OS_EXPECT_LIVE_DIALECT_MATRIX=1, live PostgreSQL 16.13 (Asia/Shanghai) and live MySQL 8.0.46 (+08:00).
    • The new REST file with its live cells: 12 passed on SQLite, PG and MySQL.
  • Typecheck: core, objectql, rest, driver-memory and driver-sql exit 0. Test-layer debt is unchanged (core 4 / 4, objectql 40 files / 234 errors, rest 0). Every new or edited test file is in its package's tsc program, counted with --listFiles.
  • Ablation A: the range reverted to the base semantics (date only, 0..9999). The mutation went in through scripts/ablation-replace.mjs: anchor 1 to 0, blob 7801894e to 8a7dc4b4. Core was rebuilt, and the dist preflight found the marker present in 2 built files.
  • Ablation B: the write door's range call removed from record-validator.ts. Anchor 1 to 0, blob a7fd6b04 to cfbeeebc. objectql was rebuilt, and the preflight found the marker present in 4 files.
    • Mutated: objectql 2 failed / 118 passed (exactly the write-door cells); rest 1 failed / 3 passed (the write cell).
    • Restored: the tree is clean, objectql got a full rebuild with DTS, the marker is absent from 14 files, and 120 and 4 passed.
  • dispatch-gates --commands at 311ce0640 derived 67 commands. All 67 ran, each exit code captured before any pipe. --ran reconciles them: 67 derived, 67 run, 0 NOT-MEASURED, with a derived zero.
    • 66 exit 0.
    • check-empty-changeset.mjs --base origin/main exits 1, on exactly the three DELIBERATE CORRECTION names above.
    • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3). After a full turbo run build, it exits 0.
  • ESLint, narrowed: 13 changed .ts files, 0 errors and 0 warnings, counted from --format json. The population is eslint.config.mjs's **/*.{ts,…} block (line 971). The config enables no type-aware linting (its own note, lines 327-328), so no untouched file's verdict can move.
  • check:driver-conformance: base b28550818 reads 50 covered / 0 DEBT / 0 exempt, and 311ce0640 reads 50 / 0 / 0.

Acceptance notes


Generated by Claude Code

…9, at the comparand door and the write door (#20264)

WIP: the rule and its two doors; pins follow.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
… engine doors, flipping the year-0 pins (#20264)

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…public door and each dialect's edges (#20264)

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
… out-of-range number is refused on datetime too (#20264)

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…lause corrections to the 20203, 20240 and 20263 notes it falsifies (#20264)

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

15 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 33 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b810ddb6f1635fdf58a901aa082f5de015bb80c8 → packageMentionDocs.

Which tree this was computed on

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

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

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 311ce0640415cff614cc4e10faf53f056014bda7
Local-runs: none

Inputs: card #20264 (body and all 5 comments), PR #20469 (body, 17-file list, net diff against the merge base dc0ab6a), the check-runs on the head, and the rulings the card cites on #20280 (5859414357) and #20240 (5857781537, 5858389239). Read-only git and REST GETs only; nothing built, run or re-run.

① Derived judgments

Source moves in four files (core temporal-storage-form.ts, temporal-comparand.ts; objectql temporal-comparand-door.ts, validation/record-validator.ts); the other 13 are tests and changesets. No driver source is edited.

  1. The one range, RIGHT. isOutsideTemporalYearRange(value, kind) in core: time never; a finite number the Date type cannot hold is outside; a date string is judged by its leading YYYY-MM-DD year, everything else by the UTC year of the instant instantMs reads; bounds 1 and 9999. No in-range value is refused: 0001-01-01 (day and instant), 9999-12-31T23:59:59.999Z (number, Date, string), 0099, and the 2026 controls pass both doors, pinned at core, objectql, driver-memory, driver-sql on three dialects and REST.
  2. One reader, RIGHT. canonicalUtcDatetime and the range both call the private instantMs; the datetime spelling of any instant is unchanged (+010000-…, -000001-…, 0000-06-15T00:00:00.000Z pinned in the totality test), so the two cannot drift.
  3. Every door inherits it, traced at head, RIGHT:
  4. Accept-set narrowings, each as ruling 5858474998 says, RIGHT:
    • comparand on datetime: a number, Date, ISO, extended-ISO, bare day, zone-naive or epoch-ms-string spelling whose UTC year is outside 0001..9999 answers INVALID_FILTER / 400 (before: 7/0/0 misorder on memory and SQLite, 500 on PostgreSQL and MySQL).
    • comparand on date: year 0 in every spelling is refused (before: 7/0/0 on memory, SQLite, MySQL; 500 on PostgreSQL); the 10000 and −1 number and Date cells were already refused by 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 and do not move.
    • zone edges: 9999-12-31T23:59:59-01:00 (UTC year 10000) and 0001-01-01T00:00:00+08:00 (UTC year 0) are refused on datetime and read on date by their leading day — each is what that kind's rule reads.
    • written value: a date or datetime string or Date naming a year outside the range is VALIDATION_FAILED / invalid_date on insert, update, the multi-row update and validate (before: 201 with +010000-… stored verbatim as a non-day on memory and SQLite, year 0 stored; 500 on PostgreSQL).
    • storage rule, date arm: year 0 is no longer padded (0-06-15); only a direct driver write reaches that arm with it.
    • engine write coercion: applyFieldDefaults (12024, 12029, re-default 12385) runs before validateRecord (12608), so a defaulted year outside the range is refused, not stored.
  5. Words. The year-class refusal now names 0001..9999 on both kinds (was 0000..9999, date only), as declared. ALSO, undeclared: a date-column STRING the date rule does not read but whose instant names a year outside the range (+010000-01-01T00:00:00.000Z, -000001-…, an out-of-range epoch-millisecond string) was refused at the base in the generic words ("compare false for EVERY row"; on having "keep no group or every group") and is refused at head in the year-class words, because yearClassOf(hit) replaced the base's typeof hit.value !== 'string' test. Same code, same status; the words moved on where, the per-aggregation filter and having. As behaviour RIGHT (the year class is the true reason); as declared, the 20264 note says the opposite — ②. No test pins the old words (the having row asserts not a date value, which both messages carry).
  6. Public surface. @objectstack/core gains one named export, isOutsideTemporalYearRange, reachable through exports["."] → dist/index (export * from './utils/temporal-storage-form.js'). RIGHT as a design: objectql's validator must import the range from core, and routing it through isUninterpretableTemporalComparand would newly refuse readable-but-unspellable written strings, a second narrowing outside the ruling. The declaration is judged in ②.
  7. Drivers, RIGHT. driver-sql (toDateOnly / formatInput → temporalStorageForm) and driver-memory (memory-temporal.ts) read core's rule; the file list carries no driver source. driver-mongodb keeps its own copy (mongodb-temporal.ts: storageDateValue pads no year and has no number arm; storageDatetimeValue), pre-existing and named unchanged by the 20203 and 20240 notes. Both refusals sit at engine doors in front of every driver, so no door this PR guards is bypassed on MongoDB; the copy's in-range drift (an unpadded Date year 1..999 through the engine) is the pre-existing note, not this card's. The seat's reading (5874562152 item 3) is confirmed from the code.
  8. time judges no year, RIGHT.
  9. The flipped pins, each as the ruling says, RIGHT. Year 0000: core temporal-comparand.test.ts IN_RANGE "first millisecond of year 0" → OUT_OF_RANGE, year 1 in its place; core temporal-storage-form.test.ts padding cases 0000-06-15 / 0000-01-01 → 0-06-15 / 0-01-01 in the outside block; objectql engine-date-year-range-door.test.ts IN_RANGE 0000-01-01 → 0001-01-01, OUT_OF_RANGE gains 0000-01-01. Datetime-as-read: core "leaves the datetime and time rules alone" → datetime judged, time alone; objectql "leaves the datetime and time fields alone" → datetime refused with code AND status, driver reads halved; objectql having UNCHANGED "extended-year ISO on min(datetime)" → the REFUSED table, replaced by the first instant of year 1; REST data-query-date-year-range.test.ts datetime control → refused at engine and REST, a 2026 instant read.
  10. Stop-valve cell, CONFIRMED pre-existing and unchanged: a MySQL DATETIME in 0001..0099 is read a century late through mysql2's parser; the diff touches no mysql2 option or parser; the dev measured it identical at base and head; the driver-sql matrix pin asserts that cell's STORED text and read-back from 0100 up (green under Temporal Conformance). The PR body states it, returns it as needs_decision and carries it to 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 under ruling 5859414357 (option C); the seat's answer carries it further (pm:retriage after landing). Nothing on this PR decides it.
  11. The dev's two class-a findings, confirmed from the code at head, both pre-existing and outside this PR:
    • time comparand +010000-01-01T10:00:00Z: the predicate's time arm is !(readsAsWallClock || readsAsInstant) and Date.parse reads an extended year, so it passes the door; canonicalTimeOfDay hands a non-wall-clock to canonicalUtcDatetime, whose +010000-01-01T10:00:00.000Z fails the four-leading-digit check and comes back unchanged, so it is compared as text against HH:MM:SS (+ sorts below every digit: $gt matches all, $lt none). Neither line moved in this diff.
    • date written as 2026/07/15: the validator's readable is Date.parse-readable and the year is inside, so it is accepted; canonicalCalendarDay keeps a string with no leading YYYY-MM-DD unchanged, so it is stored verbatim. The base arm accepted it identically. The comparand door already refuses it (readsAsCalendarDay); the write door does not.
  12. The three DELIBERATE CORRECTIONS, each note named, confirmed as written and not to be restored from base. Check Changeset is red by design under 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: no PR may edit a changeset that exists on the merge base); the new 20264 note has a non-empty frontmatter, so the empty-frontmatter rule is clean.

② Semver level

  • Changeset .changeset/20264-temporal-year-range.md: @objectstack/core minor, @objectstack/objectql minor. Source moves only in those two packages; driver-memory, driver-sql and rest are test-only, so no bump. No skip-changeset. RIGHT.
  • Clause-②: no (narrowing), present in the PR body and the changeset, copied from claim 5872518067 and triage 5858474998 item 3. The accept set narrows on both doors; (narrowing) is BREAKING (AGENTS.md Post-Task §3), the **BREAKING** banner is present and the level ships minor under the launch-window convention (check-changeset-no-major). Consistent. On the one new export (①.6): it is on none of the five review faces, core keeps no api-surface listing (the widening tell T3 reads packages/spec/api-surface/*.json only), the family precedent 20176-* shipped temporalStorageForm as a new core export at minor, and the declaration's two consequences — at least minor, and this at-tier review — both already hold. Judged: the line stands, with the export declared in the note's prose ("@objectstack/core exports isOutsideTemporalYearRange") where a consumer reads it. If the seat reads 扩大公开面 as any new named export of a published package, the honest spelling is yes (narrowing); the level and the tier move either way by nothing.
  • BREAKING banner: present, names what narrows and each old answer. RIGHT.
  • ADR-0087 marker: exactly one, not-required (no-migration-prescription), a category the gate lists, with its why. The FROM → TO line is a behaviour statement (a value → its refusal) plus the one-line fix, not a consumer-code rewrite; the prescription detector does not read it as one (dev: check-adr-0087-registration exit 0 among the 66 green; CI Lint & Repo Gates success). RIGHT.
  • FROM → TO with the one-line fix ("write a year from 0001 to 9999"): present. RIGHT.
  • Sentence audit of the 20264 changeset — every sentence TRUE except two, each a must-change:
    1. FALSE: "A datetime in year 10000 spells +010000-…, which sorts below every four-digit year as text (its where answer was 0/7/0)". Sorting below every year makes $gt / $lt / $eq answer 7/0/0, which is what the note's own table, the card's table and the PR body record; 0/7/0 is the RIGHT answer, not the observed one. Must-change: state 7/0/0 as the answer it gave and 0/7/0 as the right one.
    2. FALSE: Unchanged, "every string the rules could not read before, refused in its existing words". A date-column string the date rule does not read whose instant names a year outside 0001..9999 (+010000-01-01T00:00:00.000Z, -000001-…, an out-of-range epoch-millisecond string) was refused at base in the generic words and is refused at head in the year-class words, on where, the per-aggregation filter and having (①.5). Must-change: except that class in the clause (same code and status; the words now name the year class).
      Every other sentence TRUE: the title; the ruling; the four "What changes" bullets; the six table rows; the MySQL sentence; PostgreSQL has no year 0; year 0 answered right on memory and SQLite; each refused query or write reaches no driver; Who is affected; the edges, controls, time, NaN class and datetime spelling unchanged; the MySQL 0001..0099 sentence; the driver-mongodb sentence.
  • PR-body sentence audit: every checkable sentence TRUE — the Fixes and Clause-② lines; the summary; the stop-valve paragraph; the four "What changed" bullets; H1 to H4; the flipped-pin list (①.9); the three DELIBERATE CORRECTION descriptions, each matching its hunk; "the claim's file surface names .changeset/20264-*.md only" (5872518067); the head is the merge of origin/main dc0ab6a (the merge base) and carries PR feat(objectql,spec)!: enforce a field's declared precision (total digits) at the write seam — max_precision (#19992) #20423's number arm (b98fbc2 is in the base and the [finding] currencyConfig.precision is declared and validated against ISO 4217, but no renderer or runtime reads it — an ADR-0049 enforce-or-remove case, filed on ruling 乙 on #19910 #19992 code is at base); 13 changed .ts files; the three acceptance notes (①.7, ①.11). The measured readings — 236 / 160 / 76 cells, the full suites, typecheck and debt, ablations A and B, 67 gate commands with 66 green, driver-conformance 50 / 0 / 0 — are the dev's, not re-run here; the check-runs in ③ are the verdicts for what CI derives.

③ Boundary flags

  • open_questions[0], the stop-valve cell (needs_decision): not decided on this PR, which ships 0001..9999 as ruled; the seat carries it to 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 (pm:retriage after landing, options A and B with the dev's four-axis analysis). Confirmed pre-existing and unchanged (①.10). Added here: the driver-sql matrix pin encodes the misread as a named exemption (readsBackAsWritten), which flips when 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's datetime half lands under either option.
  • Deviation 1 (three notes outside the claimed file surface): answered — ①.12; Check Changeset red by design; no skip-changeset.
  • Deviation 2 (driver-mongodb keeps a copy; the stop line read as "a fix that NEEDS a driver copy"): answered — the seat's reading (5874562152 item 3) is confirmed from the code; both refusals are engine-level, in front of every driver; the copy needs no edit for this card.
  • Deviation 3 (PR body corrected once): the body matches the diff.
  • Deviation 4 (the REST file's PostgreSQL and MySQL cells are named skips without OS_TEST_*_URL; the CI live pin is driver-sql's matrix): answered — consistent with ruling 5859414357 option B; Temporal Conformance is success on this head.
  • Deviation 5 (local servers stopped and removed): outside the diff.
  • Deviation 6 (main advanced three commits on other files after the final gate run): the PR reads mergeable; the queue rebuilds onto current main.
  • out_of_scope_findings[0] (time comparand with an extended-ISO year compared as text): confirmed (①.11), pre-existing, outside the ruling's date / datetime scope — for the seat to file.
  • out_of_scope_findings[1] (a date written as 2026/07/15 stored verbatim): confirmed (①.11), pre-existing, outside the ruling's range scope — for the seat to file.
  • out_of_scope_findings[2] (driver-mongodb copy): acceptance note, carrier none; confirmed pre-existing.
  • Check-runs on the head, final read (last step): 41 runs, all completed, none in progress — 34 success, 5 skipped (Auto Label and Check PR Size on the edited run, Build Docs, Console Pin Gate, Packed-tarball smoke), 2 failure: Check Changeset twice (the push run at 15:38Z and the edited run at 16:44Z), the expected red on the three DELIBERATE CORRECTION names under the foreign-changeset rule; its job log is not reachable from this seat, and the conclusion is the verdict. Success: Build Core; Lint & Repo Gates; Test Core (1/6 to 6/6 and the rollup); Type Check source gates, consumer gates, workspace, debt ledger, and TypeScript Type Check; Temporal Conformance (live PG + MySQL); Dogfood Regression Gate (1/3 to 3/3 and the rollup); Dogfood Verify CLI; Governed Surface Queue Guard; Check Documentation Links; Flag docs affected by code changes; filter; and the four PR-automation guards on both runs.

Must-changes (both one-clause edits to .changeset/20264-temporal-year-range.md, ②.1 and ②.2), then a delta record on the new head over blob-identical sources. Every code judgment passes.

Implemented-by: claude/issue-20264-temporal-year-range
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

VERDICT: FAIL

…oot export; state the 7/0/0 answer it gave, and except the date-string class whose refusal words moved (#20264)

Patch round 1 of the at-tier review on the PR: two false sentences and the Clause-② reading. No code or test change.

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

Copy link
Copy Markdown
Contributor Author

Contract review

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

Delta record over the FAIL 5874841530 at 311ce06. Inputs added: the amended claim 5874849531 and the patch-round-1 os-dev-report 5874921899 on #20264, the edited PR body, the diff 311ce06..b559a5d, and the check-runs on the new head (read last). Read-only git and REST GETs only.

① Derived judgments

  1. The PR's own delta, CONFIRMED. The head is two commits past 311ce06: the round's commit f16ff84 and the merge b559a5d (parents f16ff84 and origin/main b810ddb; merge base of the two parents dc0ab6a, the same base as before). git diff 311ce0640 f16ff84ac is .changeset/20264-temporal-year-range.md alone, +3 / −3, on three lines: the Clause-② line, the year-10000 sentence and the Unchanged clause. No code, no test and no other changeset moved. Blob identity, file by file: all 17 PR files at b559a5d equal their blob at f16ff84, and 16 of them equal their blob at 311ce06 (only the 20264 note differs). Every code judgment of the FAIL record (①.1 to ①.11 there) carries over unchanged, the three DELIBERATE CORRECTIONS included (the 20203, 20240 and 20263 notes are blob-identical to the confirmed head).
  2. Sentence (a), now TRUE. "A datetime in year 10000 spells +010000-…, which sorts below every four-digit year as text (its where answer was 7/0/0 for $gt / $lt / $eq, where the right answer is 0/7/0)". 7/0/0 is what the note's table, the card's table and the PR body record for the bound; 0/7/0 is the right answer for a bound above every row. Exact.
  3. Sentence (b), now TRUE, and the exception is exact. "every string the rules could not read before, refused in its existing words, except a date-column string whose instant names a year outside 0001..9999 (+010000-01-01T00:00:00.000Z, -000001-…, an out-of-range epoch-millisecond string), refused with the same code and status on where, the per-aggregation filter and having but now in the year-class words". Against the code at head: a date-column string hit reaches yearClassOf, which asks isOutsideTemporalYearRange(s, 'date'); with no leading YYYY-MM-DD that reads instantMs(s) and answers true exactly when the string names an instant whose UTC year is outside 0001..9999 — then the year-class words; a string instantMs cannot read, or one naming an in-range instant (an in-range epoch-ms string, 2026/07/15), keeps the generic words. The set the exception carves is that set and no other: not wider (a date string WITH a leading day whose year is outside, 0000-06-15, was read before, so it is not in "could not read before" and is declared as a new refusal elsewhere; a datetime string the rule could not read keeps its words, since instantMs reads none of them); not narrower (the three spellings named are the class's instances, and the phrase "whose instant names a year outside" covers a bare extended day if Date.parse reads it). The three positions are right: the base's having door made the same string / non-string split (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 is in the base), so its words moved there too. Same code and status — INVALID_FILTER / 400 before and after. Exact.
  4. Clause-②: yes (narrowing), RIGHT, in the changeset (line 8) and the PR body (line 3), matching the amended claim 5874849531. The ground is the seat's own ruling on RLS enforcement: the write check (packages/formula matches-filter) admits a cross-class field-to-field comparison that driver-sql's read refuses — one classification, one answer per policy (the engine half of #20347) #20355 (claim 5868635246, read: a published package's root entry gaining exports grows the public surface, and the narrowing arm stays when the write check refuses what it admitted) — the same shape here: @objectstack/core's root entry gains isOutsideTemporalYearRange (①.6 of the FAIL record), and both doors narrow. The grammar holds (yes plus one arm; (narrowing) is BREAKING).
  5. Levels, banner, marker, FROM → TO, unchanged and consistent: the diff touches none of them. @objectstack/core minor, @objectstack/objectql minor — yes requires at least minor for every package whose source moves, which holds; the **BREAKING** banner and the launch-window minor stand; the ADR-0087 marker is still the one not-required (no-migration-prescription) with its why; the FROM → TO line and the one-line fix are byte-identical; the "What changes" bullet naming the export is unchanged. The dev's three changeset gates at b559a5d (adr-0087 exit 0; changeset-no-major with the patched event exit 0, reading yes (narrowing) and no patch on a moved package; check-empty-changeset exit 1 on exactly the three names) are the dev's readings; CI's are below.
  6. The merge, CONFIRMED a true merge with no hand edit. git diff f16ff84ac b559a5d0e is byte-identical (index lines aside) to git diff dc0ab6a2e b810ddb6f (main's six commits, 66 files, +4596 / −234), and git diff b810ddb6f b559a5d0e is byte-identical to git diff dc0ab6a2e f16ff84ac (the PR's 17 files); the two file sets are disjoint (intersection empty). No file of this PR changed in the merge.
  7. The six commits main brought, none interacting with the temporal doors on the combined tree:
  8. PR-body patch-round-1 section and its Clause-② line, every sentence TRUE: "touches .changeset/20264-temporal-year-range.md only; no code or test changed" (①.1); the Clause-② sentence with its ground and "the levels, the BREAKING banner, the ADR-0087 marker and the FROM → TO line are unchanged" (①.4, ①.5); the 7/0/0 and 0/7/0 sentence (①.2); the exception sentence (①.3); "the new head b559a5d is that commit (f16ff84) plus a merge of origin/main b810ddb. Every other file of this PR is blob-identical to 311ce06" (①.1, ①.6). The body's top Clause-②: yes (narrowing) matches the changeset and the amended claim. The rest of the body is byte-for-byte the confirmed text, still labelled "measured at 311ce06", which stays true of the code it describes.

② Semver level

  • @objectstack/core minor, @objectstack/objectql minor; test-only packages unbumped; no skip-changeset. RIGHT, unchanged.
  • Clause-②: yes (narrowing) — the line now names both directions the diff carries: the accept set narrows on both doors (BREAKING, banner present, minor under the launch window), and the public surface grows by the one core root export. Consistent with the levels (at least minor on every moved package), the ADR-0087 marker, the FROM → TO and the one-line fix, none of which moved. RIGHT.
  • Sentence audit of the 20264 changeset at this head: the two FALSE sentences of the FAIL record are corrected exactly (①.2, ①.3); every other sentence is byte-identical to the confirmed text and stays TRUE. No sentence of the note is FALSE.
  • PR body: TRUE throughout (①.8, and the FAIL record's audit for the unchanged remainder).

③ Boundary flags

  • The amended claim 5874849531 supersedes 5872518067's file surface and Clause-② line: it admits the three DELIBERATE CORRECTIONS (confirmed in the FAIL record ①.12; blobs unchanged since) and reads yes (narrowing) for the root export. Both answered above.
  • open_questions: none in the round-1 report; the stop-valve cell stays carried to 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 exactly as the FAIL record ③ recorded (pre-existing, unchanged, undecided here; no driver source in the diff then or now).
  • Deviation 1 (the merge of six main commits, none on a PR file): answered — ①.6, ①.7.
  • Deviation 2 (worktree recreated at 311ce06 before the edit): outside the diff; the blob identity in ①.1 is what matters and holds.
  • out_of_scope_findings: none new; the two class-a findings and the driver-mongodb note stand as the FAIL record left them (for the seat to file / carrier none).
  • Check-runs on the head, last read (the final step of this record, with 15 runs still running at that moment; an in-progress run is recorded as in progress, never as passed): 38 runs — 16 success, 5 skipped, 2 failure, 15 in progress.
    • failure: Check Changeset twice (the push run 17:10Z and the edited run 17:12Z) — the expected red on the three DELIBERATE CORRECTION names under the foreign-changeset rule; those three notes are blob-identical to the head this red was already confirmed on, and the only other changeset in the diff, the 20264 note, has a non-empty frontmatter; the dev's local check-empty-changeset at this head names exactly 20203, 20240, 20263. The job log is not reachable from this seat; the conclusion is the verdict. Nothing else red is this PR's.
    • success: Type Check source gates; Dogfood Verify CLI; Governed Surface Queue Guard; Check Documentation Links; Flag docs affected by code changes; Check PR Size; Auto Label; filter; and the four PR-automation guards on both runs (the card claims this branch, no other open PR claims the issue or the single-writer path, Part-of parity).
    • skipped: Auto Label and Check PR Size on the edited run, Build Docs, Console Pin Gate, Packed-tarball smoke.
    • in progress at the last read, not judged here: Build Core; Lint & Repo Gates; Test Core (1/6 to 6/6); Type Check consumer gates, debt ledger and workspace; Temporal Conformance (live PG + MySQL); Dogfood Regression Gate (1/3 to 3/3). Each of these was success on 311ce06, whose code and tests this head carries blob-for-blob, and main's six commits landed through the queue; the seat's enqueue check reads their conclusions when they land, and this record does not count them as passed.

Implemented-by: claude/issue-20264-temporal-year-range
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

VERDICT: PASS

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/xl tests tooling

Projects

None yet

2 participants