Skip to content

fix(core,objectql)!: a datetime names a year from 1000 to 9999 at both engine doors; a date keeps 0001..9999 (#20280) - #20843

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-20280-datetime-year-floor
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-20280-datetime-year-floor

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #20280
Clause-②: no (narrowing)

What this changes

The datetime half of the card, by triage's answer A (comment 5905050200): a datetime value names a year from 1000 to 9999, and one before year 1000 is refused at both engine doors. A date keeps 0001..9999, and a time column's range does not move.

  • One per-kind bound, in core's one range function. packages/core/src/utils/temporal-storage-form.ts: FIRST_SUPPORTED_YEAR is now { date: 1, datetime: 1000 }, and isOutsideTemporalYearRange asks FIRST_SUPPORTED_YEAR[kind]. No dialect switch: the range is the contract on every backend. The write door (the record validator) and the comparand door both reach it through isUninterpretableTemporalComparand, so neither door needed a second edit to the range. The date rule's four-digit padding reads the date bound (FIRST_SUPPORTED_YEAR.date), so it still pads 0001..0999.
  • The refusal words. packages/objectql/src/temporal-comparand-door.ts: the datetime year class names 1000 to 9999. A datetime in 0001..0999 gets its own consequence sentence, because it sorts and compares as the instant it names on every backend. The misorder sentence ("does not sort as an instant") would be false for it. The existing wire codes are unchanged: INVALID_FILTER / 400 for a comparand, and VALIDATION_FAILED with the field's invalid_date code for a written value.
  • The time class keeps its meaning. A time refusal's class ("an instant whose UTC year falls outside the years 0001 to 9999, so no time of day is read from it") used to ask the datetime range. It now asks the four-digit years explicitly, through core's date range over the instant the datetime rule reads. The corpus below shows every time answer byte-identical.
  • Comments that stated 0001..9999 for a datetime are corrected in temporal-storage-form.ts, temporal-comparand.ts, datetime.ts, record-validator.ts, and one comment-only line in packages/spec/src/data/calendar-day.ts (the spec lane's file; declared here and in the report).

The published accept sets that narrow (per door, per spelling)

Each one refuses a datetime whose UTC year falls in 0001..0999, and accepted it at the base:

  • Write door (VALIDATION_FAILED / invalid_date). Covers engine.insert, engine.update (one row and many) and the dry-run engine.validate, so also POST /api/v1/data/:object, PATCH /api/v1/data/:object/:id and each row of POST /api/v1/data/:object/import. Spellings: an ISO instant string, a bare YYYY-MM-DD (midnight UTC), a zone-naive YYYY-MM-DD HH:MM, and a Date. A number was already refused there, because a datetime is stored as text, so no number spelling narrows at this door.
  • Comparand door (INVALID_FILTER / 400). Covers where, a per-aggregation filter and having, on the engine and on POST /api/v1/data/:object/query. Spellings: a string, a Date and a number (epoch milliseconds). The analytics native-SQL strategy asks the same predicate (service-analytics comparand-shape.ts), so it declines such a comparand and the query reaches this door. That is read from source; its suite is green.
  • The year is the UTC year: 1000-01-01T00:00:00+08:00 (year 999 in UTC) is refused, and 0999-12-31T23:00:00-02:00 (year 1000 in UTC) is read.

Nothing widens: across the corpus below, no answer moved from refused to accepted.

Before and after, measured (scratch harness, not committed)

Every door answer went through the public engine API on a recording driver: find (where), aggregate (a per-aggregation filter, and having on min), validate, insert and update. The fields were a date, a datetime and a time. There were 69 values: 12 years, from -1 and 0 up to 9999 and 10000, each as a string, a number and a Date, plus zone-crossing offsets, impossible days, non-ISO spellings, bare days, zone-naive stamps, bare-integer strings, placeholders and junk. That is 1242 answers. Base 4b45afaed5 against head 65728398b2 (the source commit; later commits are tests and the changeset):

  • date field: 0 of 414 answers moved. time field: 0 of 414 moved.
  • datetime field: 189 moved. 126 are narrowings over 24 values in 0001..0999, in every spelling (108 accepted-to-refused, plus 18 validate verdicts from valid to invalid). 63 are refusals whose words moved: the range now reads 1000 to 9999. Six of those, in 0001..0999, were already refused for their spelling or day (0500-02-30T10:00:00Z, 0500/07/15 10:00, a bare-integer string) and are now named by their year class first, as a year past 9999 already was.
  • 1000 and 9999, in every spelling, answer as before (accepted).

Stored rows before the floor (ADR-0087: refused with guidance, never shifted)

Nothing rewrites a stored datetime below 1000. It reads back as before; on MySQL a year in 0001..0099 still presents a century late (ADR-0053 D-F2; ruling 5859414357, C: no mysql2 parser change). An operator finds such rows with $lt on 1000-01-01T00:00:00.000Z, a comparand both doors admit. A PATCH that leaves the field out is accepted. A write that carries a year below 1000 is refused, so the field is written again with an instant from year 1000 on, or null. packages/rest/src/data-date-read-year-below-100.test.ts pins exactly this at the REST door: the census, the refused comparand below the floor, a PATCH of another field on such a row, a refused PATCH that keeps the year, and an accepted PATCH onto 1000. The changeset carries one ADR-0087 marker, not-required (no-migration-prescription): the change refuses a value, and no key, export or stored metadata shape moves. Its body has no FROM/TO table and no arrow.

Tests

All at head a3afb404b6 (after merging origin/main b28054654d), process TZ=America/New_York:

  • @objectstack/core 60 files, 1729 passed · @objectstack/objectql 344 files, 6773 passed · @objectstack/driver-memory 65 files, 1470 passed · @objectstack/service-analytics 141 files, 3267 passed.
  • @objectstack/driver-sql 209 passed, 3 skipped files (4047 passed, 95 skipped), on SQLite and on a private PostgreSQL 16.13 (server zone Asia/Shanghai). @objectstack/rest 233 files, 4571 passed, 33 skipped, on SQLite and the same PostgreSQL.
  • NOT MEASURED: live MySQL. No MySQL server is available in this container, so every MySQL cell ran as a named skip. Temporal Conformance (live PG + MySQL) runs driver-sql's year-range file there, including its new year-1000 row.
  • Typecheck, each exit 0: core, objectql, driver-memory, driver-sql, rest and spec, each with its check:test-typecheck test layer held.
  • Ablation of the bound (run at d767c1ca81; the merge did not touch the file, blob 17abe8b044 both sides):
    • node scripts/ablation-replace.mjs set { date: 1, datetime: 1000 } back to { date: 1, datetime: 1 } (anchor 1 to 0, blob 17abe8b044 to 7a7adacdb9). Core was rebuilt, and ablation-dist-preflight found the marker in dist/index.js and dist/index.cjs (exit 0, with --source-marker).
    • Result: core 3 failed of 109, objectql 7 failed of 69. Every red is a floor pin: the datetime 0001..0999 refusals on both doors, the having twin, and the year-0050 write control.
    • Restore under trap: blob equals HEAD, git diff HEAD empty, core rebuilt, preflight --absent exit 0 with a clean tree. Green again: 109 of 109 and 69 of 69.
  • Lint: eslint --no-inline-config --format json over the 19 changed TS files reported 19 files, 0 errors, 0 warnings. Population: eslint.config.mjs line 971, files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'], covers all 19. Invariance: the config enables no type-aware linting (its lines 327-328), so this diff moves no untouched file's verdict.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands at a3afb404b6 derived 89. All were run with recorded exit codes. --ran reports "89 derived, 87 run, 2 NOT-MEASURED, 0 UNRUN". The two NOT MEASURED are check:dual-build-cjs-loads and check:type-check-debt, both exit 3 (PREREQUISITE NOT MET: they read every package's dist and this worktree built only the touched closures).

The pins, and each one's fate

  • Flipped to refused, with the 1000 edge added as the accepted control:
    • core temporal-storage-form.test.ts and temporal-comparand.test.ts;
    • objectql engine-temporal-year-range.test.ts (both doors, every position), engine-aggregate-having-temporal-door.test.ts (the year-1 control became a refusal row; year 1000 is read), engine-temporal-write-real-day-iso.test.ts (the 0050-01-01T10:00:00Z write);
    • rest data-temporal-year-range.test.ts, import-date-cell-iso-real-day.test.ts (the dt cell 0001-01-01 is now refused per row by the write door behind the reader), import-datetime-year-below-100.test.ts (the reader layer is kept; the import door refuses each row below 1000 and stores 1000 and 2026; the round trip runs at 1000 and 2026), and data-query-calendar-day-year-below-100.test.ts (the whole-day bound is measured at 1000-01-01; the card's 0050-01-01 bounds and row are pinned as refusals).
  • Moved to date as the control: the year-4 leap day in core, and date 0001..0999 in every file above (still accepted).
  • Kept because they never pass a door: driver-memory memory-20264-temporal-year-range.test.ts and driver-sql sql-driver-20264-temporal-year-range.test.ts. They write straight through the driver, and their datetime rows below 1000 are what a row stored before the floor holds. Headers are corrected, and a year-1000 edge row is added. Also kept: the [finding] outside calendar-day.ts, a year from 0001 to 0099 is still read as 1900..1999: Date.UTC's two-digit-year remap in core's datetime and bucket helpers, filter-tokens and the REST import's datetime cell #20599 / spec nextUtcCalendarDay answers null for every day in years 0001..0099 (Date.UTC reads them as 1900..1999), so a datetime $lte / $between max on such a day skips whole-day widening and misses that day's rows #20550 unit pins on parts, week keys and calendar-day helpers (core datetime-year-below-100.test.ts, service-analytics week-key-year-below-100.test.ts, spec calendar-day.test.ts), since no door is in their path.
  • data-date-read-year-below-100.test.ts (rest) now writes its datetime values below 1000 through the driver, as stored-before-floor rows, and pins the operator path above.

Acceptance notes

  • File surface. Five REST test files went red and are rewritten above, plus one comment line in a sixth (count corrected by the seat after review 5910551203). REST's source is untouched. The dispatch's surface named pins in core, objectql, driver-memory and driver-sql; these are the same pins one door further out.
  • The write door's words name the kind, not the range. The ruling asks for a refusal "naming the kind and the range". The comparand door names both. The write door answers the existing invalid_date sentence ("must be a valid datetime (ISO-8601)"), as it did for the 0001..9999 range: naming the range there needs a new validation-message.ts key in packages/spec, outside this card's surface. Raised in the report.
  • Placeholders are not judged by the door, by design. A relative-date placeholder is stepped around ahead of its resolution, so {1977_years_ago} on a datetime still reaches the driver. It compares as the instant it names (year 49; measured, correct rows). The same path lets placeholders that land past 9999 or before year 1 through, with wrong answers. That class is older than this change; the measured case is in the report.
  • Levels. minor for @objectstack/core and @objectstack/objectql, whose source moves. REST and the analytics service narrow through them without a source change of their own, and the changeset config's one version group moves them in lockstep.

Generated by Claude Code

…; time keeps its class (#20280)

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…d the date years stay read (#20280)

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…oth doors; 1000 read; date and time unmoved (#20280)

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…its year-1000 edge row (#20280)

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…used; the floor and the date years stay read (#20280)

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

16 anchor(s) derived from 3 changed package(s); no hand-written page names any of them. ⚠️ 3 changed file(s) yielded no anchor (packages/core/src/utils/datetime.ts, packages/core/src/utils/temporal-comparand.ts, packages/spec/src/data/calendar-day.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/core/src/utils/datetime.ts, packages/core/src/utils/temporal-comparand.ts, packages/spec/src/data/calendar-day.ts) — pages documenting those are invisible to this run
  • 7 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 — 142 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 1741c5dcb62d11bc9b59702dd67080ef6a7ac9e8 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 1741c5dcb62d11bc9b59702dd67080ef6a7ac9e8

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

PR #20843 on card #20280 (the datetime half; PR #20306 landed the date half). The head was a3afb404b6418eaa640f223e06d0ca5ed71286e0 at both reads, draft, open. Inputs: the card's body and all eleven comments, #20264, the PR body, its 20-file list, the net diff against main at the merge base b28054654d (20 files, +560/−158), and the head's check-runs. Read-only: git reads of the fetched branch and REST reads; nothing built, run or re-run.

Check-runs on this head, read once just before this record was written: 34 runs, 26 success, 3 skipped, 5 in_progress, 0 failure. success: Temporal Conformance (live PG + MySQL), Build Core, TypeScript Type Check with Type Check · workspace, · source gates, · consumer gates and · debt ledger, Dogfood Regression Gate (three shards and the rollup), Dogfood Verify CLI, Governed Surface Queue Guard, Check Changeset, Spec property liveness, Test Core (2/6), Test Core (4/6), and the PR-shape checks (the card claims this branch; no other open PR claims the issue or the single-writer path; Part-of parity; PR size; documentation links; docs-affected flag). Still in_progress: Lint & Repo Gates, Test Core (1/6), (3/6), (5/6), (6/6). Not waited on.

① Derived judgments

Accept sets, door by door and spelling by spelling; each judged against the diff.

  1. The write door (record-validator.ts, VALIDATION_FAILED with the field code invalid_date) narrows for a datetime field: a value whose UTC year falls in 0001..0999 was accepted at the base and is refused at the head, in every spelling the door read: an ISO instant string (with Z, an offset, or none), a bare YYYY-MM-DD (midnight UTC), a zone-naive YYYY-MM-DD HH:MM[:SS[.f]] or T form, and a Date. A number was already refused (the arm's readable predicate admits only a Date or a Date.parse-readable string), so no number spelling moves. Reached on engine.insert, engine.update (one row or many) and engine.validate. Right: the validator's source diff is comment-only; it asks isUninterpretableTemporalComparand, which asks isOutsideTemporalYearRange, which now reads FIRST_SUPPORTED_YEAR[kind]: one per-kind bound, no dialect switch, no second edit to the range.
  2. The comparand door (temporal-comparand-door.ts, INVALID_FILTER / 400) narrows for a datetime field at where, at a per-aggregation filter and at having (list-operator members included): a string the datetime rule reads, a Date and a number (epoch milliseconds) whose UTC year falls in 0001..0999 are refused before any driver read. Right: the door's diff moves words and classes only; the verdict stays core's predicate. Pinned at every position (engine-temporal-year-range.test.ts, and the having twin).
  3. REST narrows through those two doors with no packages/rest/src change: POST /api/v1/data/:object, PATCH …/:id, each row of POST …/import (the reader layer is unchanged; the write door behind it refuses the row as invalid_date), and POST …/query. Right; pinned in the six REST test files.
  4. service-analytics' native-SQL decline: comparand-shape.ts asks the same core predicate of every temporal literal, so NativeSQLStrategy.canHandle now declines a datetime comparand in 0001..0999 and the query is served on the engine path, where the door refuses it. Right; no source change in that package; read from source, as the PR says.
  5. The UTC year is the year judged: the first instant of year 1000 at an offset of plus eight hours (year 999 in UTC) is refused; the last hour of year 999 at an offset of minus two hours (year 1000 in UTC) is read. Right; pinned on both doors.
  6. date is unchanged on both doors: 0001..9999, padded to four digits from FIRST_SUPPORTED_YEAR.date. Right by construction (the date entry stays 1 and the padding reads that entry) and by the date controls added in every pin file. The date year-class sentences are byte-identical: the misorders() helper composes exactly the base template ("It would reach the driver in a form that does not sort as a day and answer the wrong rows, or a database error." and "Compared with each group, it does not sort as a day and would keep the wrong groups.").
  7. time is unchanged: the H2 fork. misreadClassOf asked isOutsideTemporalYearRange(value, 'datetime') for a time hit and now asks isInstantOutsideFourDigitYears, which reads a string into the instant the datetime rule reads (temporalStorageForm) and asks core's date range of that instant; a number or a Date goes straight through. At the base the datetime range was 0001..9999; at the head the date range is 0001..9999, and both read a number, a Date or a string's instant by its UTC year, so every time answer is unchanged by reading. A time refusal's verdict never consulted the floor (the time predicate reads keepsTimeOfDay, which the floor does not touch), and its misread class keeps the four-digit years. The helper asks core's function and does no year arithmetic of its own; it borrows the date kind's bound as "the four-digit years", which its docblock says out loud. Consistent with the dev's 0 of 414 moved. Right.
  8. Words only, not accept sets: YEAR_CLASS names 1000 to 9999 for a datetime; the new DATETIME_BEFORE_YEAR_1000 class gives 0001..0999 its own consequence sentence (such a value sorts as the instant it names on every backend, so the base's "does not sort as an instant" would be false for it); year 0 and a year past 9999 keep the misorder words. A comparand in 0001..0999 already refused for its spelling or its day is now named by its year class first (the six the PR body counts). No tracker number in the runtime strings.
  9. Public surface: nothing exported moves. isOutsideTemporalYearRange keeps its signature; FIRST_SUPPORTED_YEAR (now a per-kind record) and isInstantOutsideFourDigitYears are module-private; temporalStorageForm was already exported from core. The packages/spec change is one docblock sentence in calendar-day.ts. Right.
  10. Widening: none. The only bound that moved went from 1 to 1000; nothing refused at the base is accepted at the head. The dev's corpus reads 0 refused-to-accepted, consistent with the source.
  11. Seat ruling 5859414357 C holds: no mysql2 parser change. driver-sql's source is untouched; its one changed file is the year-range test.

Triage's answer A (5905050200), item by item: a datetime below year 1000 refused at both doors: met. date keeps 0001..9999: met. One per-kind bound inside isOutsideTemporalYearRange, no dialect switch, no second edit: met. The existing refusal codes: met (INVALID_FILTER; VALIDATION_FAILED / invalid_date; no new code). "Naming the kind and the range": met at the comparand door; the write door names the kind and keeps its existing sentence (③ item 7). Pins: the datetime 0001..0999 controls flip to refused on both doors, 1000 and 9999 are accepted, date 0001..0999 stay accepted: met, in core, objectql, driver-memory, driver-sql and rest. A BREAKING minor with an ADR-0087 disposition: met (②).

② Semver level

  • .changeset/20280-datetime-year-floor.md: @objectstack/core minor, @objectstack/objectql minor, a **BREAKING** banner, Clause-②: no (narrowing). Right: the launch-window convention ships an accept-set narrowing as minor with the banner as the carrier, and the two packages whose published source moves are the two graded. rest and service-analytics narrow through a dependency with no src/** change of their own and are named in the prose; under this repo's rule (WHICH LEVEL: the level follows the act on a package's own published surface, and the level axis judges only a declared clause ②) they need no frontmatter entry, the fixed group moves them in lockstep, and Check Changeset is success on this head. Right.
  • Clause-②: no (narrowing) is the line: the diff widens no surface and narrows two doors, which is the no (narrowing) arm of clause2-line.mjs; triage's yes (narrowing) spelling would have read as "widens one surface and narrows another", which this diff does not do. The PR body carries the same line.
  • ADR-0087: exactly one marker, not-required (no-migration-prescription). The body carries no FROM/TO table, no arrow and no migration heading (the only arrow-shaped bytes in the file are the marker's own closing delimiter). The category is right: the change refuses a value; no authorable key, spelling, export or stored metadata shape moves, so nothing exists for objectstack migrate meta to rewrite; a datetime stored below 1000 is record data, and the body says it is never shifted. The operator path in the body (a $lt census on the floor's first instant, then a write into the range or null) is guidance about data, not a rewrite of a consumer's code or metadata; the prescription refusal is about consumer rewrites, and the disposition triage asked for ("refused with guidance, never silently shifted") is exactly that paragraph.
  • Review faces, sentence by sentence:

③ Boundary flags

  1. Five REST test files changed: answered, right. REST source is untouched; the files pinned the two doors one layer out, at the public door, and had to move with them; the dispatch's pin surface named the inner packages and these are the same pins. The PR body's count is wrong by one (② above); the report's is right.
  2. YEAR_CLASS changed shape; DATETIME_BEFORE_YEAR_1000 added; date refusals byte-identical: answered, right, and confirmed by composing the templates (① item 6). The old single misorder template was false for a datetime in 0001..0999.
  3. The driver pins keep rows below 1000: answered, right. Neither driver is a door (both file headers say the doors sit in the engine); the rows are the stored-before-floor class the disposition describes, written straight through the driver; headers corrected and a year-1000 edge row added in each; the MySQL cell still asserts the stored text for 0001..0099.
  4. One merge of main: answered. The merge commit brings b28054654d in. main had moved one file of this diff since the dev's base 4b45afaed5, import-date-cell-iso-real-day.test.ts, by /import: a time cell with milliseconds (10:00:00.250), exactly as /export writes it, is refused per row as invalid_date, so the export does not re-import #20722's codeOf change, and the merge combined both edits without conflict; the merged table is coherent (the new dt row below 1000 is refused as invalid_date, the code codeOf gives a dt cell). The net diff of the other 19 files has the same patch-id as the dev's pre-merge branch, byte for byte. The report's "touched no file of this diff" is inexact for that one file; the PR body's narrower claim (the merge left temporal-storage-form.ts at the same blob) is true. The dev's suites and gates ran at the merged head.
  5. check:dual-build-cjs-loads and check:type-check-debt NOT MEASURED locally: answered from CI. Build Core hosts the first (its "Every published require entry point actually loads" step) and Type Check · debt ledger runs the second; both success on this head.
  6. Live MySQL NOT MEASURED locally: answered from CI. Temporal Conformance (live PG + MySQL) is success on this head. It runs pnpm --filter @objectstack/driver-sql test against live PostgreSQL 16 and MySQL 8.0 with the matrix expectation set, so sql-driver-20264-temporal-year-range.test.ts, its year-1000 edge row and its MySQL stored-text cell ran there.
  7. open_questions[0]: answered by the seat (5910336270): A in this PR, B filed as [finding] the write door refuses a readable ISO datetime outside its years with "must be a valid datetime (ISO-8601)", a false sentence for that value; the comparand door names the range, the write door does not #20846. Judged only for the faces: A leaves no face sentence false. The changeset says the write refusal is the existing invalid_date sentence for a datetime field, and the PR body says the write door names the kind, not the range, and why. Triage's "naming the kind and the range" is met at the comparand door; the write door's range words are [finding] the write door refuses a readable ISO datetime outside its years with "must be a valid datetime (ISO-8601)", a false sentence for that value; the comparand door names the range, the write door does not #20846's.
  8. The out-of-scope finding (a relative-date placeholder resolving outside the four-digit years steps around the door): this diff does not touch that path. The placeholder step-around in isUninterpretableTemporalComparand (the classifyFilterToken exemption) and the door's position before token resolution are unchanged. Filed by the seat as [finding] a relative-date filter token that resolves outside years 0001..9999 ({8000_years_from_now}, {2027_years_ago}) reaches the driver as extended-year text and matches every row: the comparand door runs before token resolution #20844.
  9. Governed surfaces: none in the file list (.changeset and packages/** only); Governed Surface Queue Guard is success.
  10. Before landing: correct the PR body's REST file count (five rewritten and a comment-only sixth), a body edit that does not move this head; and read Lint & Repo Gates and the four remaining Test Core shards green, as the queue rule asks.

Implemented-by: claude/issue-20280-datetime-year-floor
Reviewed-by: session_01DEvba2nBuD4tWzfq8r8NFY

VERDICT: PASS


Generated by Claude Code

huangyiirene pushed a commit that referenced this pull request Sep 30, 2026
…floor

After merging main, the create door refuses a datetime before year 1000
(the floor landed with PR #20843), so the pin's route rows for 0500 and 0999
could no longer be created and the whole route layer went red. The route rows
before 1000 now carry a date only, 0099 joins the years, and a boundary row
pins the one datetime cell the export's padding still reaches at the routes:
1000-01-01T02:00:00.000Z, which America/New_York reads on 0999-12-31.

Two comments and the changeset no longer say the import would take a padded
datetime before year 1000: it refuses one now, as the write doors do.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…mit that decided it (objectstack-ai#20850)

Part of objectstack-ai#20596
Clause-②: no

## What changed

This is the sixteenth stage of the `domain:services` lane of the
dead-citation sweep. It covers `packages/plugins/plugin-webhooks/src/**`
and nothing else. By the seat's claim (`5909908590`), it is the next of
the three single-site packages left in the lane, taken one package per
stage. Later stages cover the other packages, so this PR says `Part of`
and the card stays open.

Every comment or docblock site in scope that cited a tracker number
answering 404 has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123), by the method of stages 1 to 15 (the latest is PR
objectstack-ai#20836, landed as `bbe03f406`). That is **1 site on 1 line in 1 file,
covering 1 number**:

- `src/translations/index.ts:26`, the census's one site in this package
at the base. No test comment, test string, source string or
gate-invisible spelling in this package carries a dead number (see the
supplementary reading below), so the census site is the whole
population.

The rewritten line now cites the commit in this repository that decided
what it describes: **1 commit sha**. No ADR or ruling record records
that decision (see the per-number table), so ruling C's commit rung
applies. No number was dropped.

Only a comment changed. The file keeps its line count (1 line out, 1
in), so no line citation into it moves. No code token moves (see the
guard below). **No citation number is added**: the only tracker number
on the added line is the live `objectstack-ai#12069`, which already stood on it.

The rewritten line is byte-identical to the line the six sibling
translation sets already carry (`plugin-approvals:26`,
`plugin-audit:28`, `plugin-security:26`, `plugin-sharing:26`,
`service-messaging:29`, `service-storage:29`), landed by stages 1, 2, 4,
6, 7 and 9.

**No changeset.** The rewritten docblock does not reach `dist`, and
`dist` is byte-identical with and without the rewrite (see Changeset
below), so this PR takes `skip-changeset`.

## Census: `plugin-webhooks`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged. The count below is its `allocated-but-absent` findings under
`packages/plugins/plugin-webhooks/`. Each run counts as a reading only
because its board frontier equals the newest issue or pull-request
number, read by a separate request just before and just after the run.

| reading | tree | board | whole-repo `allocated-but-absent` |
plugin-webhooks sites | lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `bbe03f406`, run 2026-09-30T11:09:05Z to 11:12:38Z |
enumerated, 187 pages, frontier objectstack-ai#20841 (newest objectstack-ai#20841 before and after)
| 748 | **1** | 1 | 1 | 1 |
| after | head `01312906e`, run 11:26:38Z to 11:30:05Z | enumerated, 187
pages, frontier objectstack-ai#20843 (newest objectstack-ai#20842 before; objectstack-ai#20843 after, a pull
request opened 11:29:02Z inside the run, so the frontier equals the
newest at the run's end) | 747 | **0** | 0 | 0 | 0 |

The whole-repo drop is 1, exactly this diff's census site. The
`resolves` tally is 33,194 in both runs, and `resolves-as-pull-request`
(1,985) and `cross-repo-unjudged` (1,018) did not move either. Neither
run was truncated or discarded: both enumerations read 187 pages at the
newest frontier. The seat's census counted 1 here at `6bff748b`, and the
base agrees: `6bff748b` is an ancestor of the base, and the one commit
between them that touches this package's `src` (`7184436a4`, a
re-translation of generated object leaves) does not touch
`translations/index.ts:26`.

**Supplementary instrument, the whole scope.** The census does not read
test files or strings. So a second reading runs the gate's own exported
`extractCitations` (whole-file and comment-prose projections) and
`namesThisRepository` over every `.ts` file under `plugin-webhooks/src`
(34 files). It takes its verdicts from the before census's own board
reading rather than from a second enumeration: a number is dead when
that census reported it `allocated-but-absent`, and alive when the
gate's own census-scope extraction judged it and the census did not
report it. 4 numbers are covered by neither, because they stand only in
test files. Each was read on its own through the read-only tools:
`objectstack-ai#10619` answers as an issue; `objectstack-ai#8556`, `objectstack-ai#13565` and `objectstack-ai#15687` answer as
pull requests. The probe's controls: `objectstack-ai#11671` and the known issue
`objectstack-ai#10619` answer 404 on the pull-request endpoint.

| reading | citations | dead | src comment | test comment | src string |
test string |
|---|---|---|---|---|---|---|
| before, `bbe03f406` | 369 | **1** | 1 | 0 | 0 | 0 |
| after, `01312906e` | 368 | **0** | 0 | 0 | 0 | 0 |

Its src-comment column equals the census's 1, which is the control on
the second instrument. The 368 live citations are the same in both
readings (363 by the census's judgement, 5 by the single reads), none is
cross-repo, none is unjudged, and the drop of 1 citation is exactly the
rewritten site. A third, raw reading (every `#` followed by 2 to 6
digits, whatever surrounds it) finds 378 occurrences before and 377
after, the same drop of 1. The 9 tokens beyond the gate's grammar are
the same before and after, and none is dead (see Acceptance notes).

## Per-number table

Sites and files count every dead occurrence in scope at the base
(comments and strings, tests included).

| number | sites / files | rewritten / left | anchor: what it decided |
|---|---|---|---|
| `objectstack-ai#11671` | 1/1 | 1/0 | `09b4f4e4e` (PR objectstack-ai#12557): the extract command's
`--source-hashes` option writes a per-locale provenance companion,
`LOCALE.source-hashes.generated.ts`, beside the generated translation
bundles, recording for each leaf the source revision it is still a byte
copy of, per maintainer ruling objectstack-ai#12069 Option A. That is exactly what
`:25-26` say. Its message names `objectstack-ai#12069` but not `objectstack-ai#11671`; its own diff
names `objectstack-ai#11671` on 16 added lines, the first of them its changeset's
headline. `git blame` puts `:26` in `30928a615` (PR objectstack-ai#12724, which wires
the serving-time read of that companion into all nine bundle sets), a
descendant of the anchor. Reused: stages 1, 2, 4, 6, 7 and 9 gave the
identical line in six sibling packages this anchor |

The sha matches exactly one commit (`git rev-parse --disambiguate`,
count 1) and is an ancestor of the base (`merge-base --is-ancestor`,
exit 0; reverse leg, base against the anchor, exit 1; control legs exit
0: stage 1's landing `422db788a`, and the repository's root commit,
15,200 commits deep against the anchor's 3,802; the history is complete,
`--is-shallow-repository` false, 15,201 commits). `objectstack-ai#11671` answers 404
on the issues endpoint and on the pull-request endpoint, read one by
one.

No ADR, `scripts/adr-anchors/` file or ruling record names `objectstack-ai#11671`,
`objectstack-ai#12069` or the source-hash companion (`git grep` over `docs/adr` and
`scripts/adr-anchors`: one unrelated `source-hash cache` hit in
ADR-0080), so ruling C's first rung is empty.

## Wordings to check

- **Tag swap in place.** 「(maintainer ruling objectstack-ai#12069 Option A, objectstack-ai#11671)」
became 「(maintainer ruling objectstack-ai#12069 Option A, commit 09b4f4e)」. It is
the form, and the byte-identical line, the six sibling translation sets
carry.

## Sites left

- **In `src`: none.** No test title, assertion message, operator log
string, runtime refusal, quoted maintainer ruling or generated file in
this package carries a dead number.
- **Outside `src`, listed and left, not edited in this stage:**
- `scripts/i18n-extract.config.ts:17` names the dead `objectstack-ai#11671` (beside
the live `objectstack-ai#12069`); the anchor for whoever next edits it is `09b4f4e4e`.
The file is outside `files[]` and outside the census surface.
- `tsconfig.scripts.json:1` names the dead `objectstack-ai#11351` (404 on the issue
and pull-request endpoints).
- `tsconfig.test.json:3` and `:56` name the dead `objectstack-ai#13176` (404 on both
endpoints; stage 4 measured it dead).
- The other numbers in those files and in `vitest.config.ts` are live:
`objectstack-ai#12069`, `objectstack-ai#12559`, `objectstack-ai#10868`, `objectstack-ai#10756`, `objectstack-ai#5475`, `objectstack-ai#14062`, `objectstack-ai#5286`,
`objectstack-ai#5449`, `objectstack-ai#12542`, `objectstack-ai#10772`, `objectstack-ai#10374`, `objectstack-ai#12642`.
  - The release-owned `CHANGELOG.md` names `objectstack-ai#11671` on 2 lines.
- `package.json`, `tsconfig.json` and `tsup.config.ts` name none.
`files[]` lists `README.md`, but the package has none.

## Mechanical guard: no code token moves

The guard compares, base `bbe03f406` against head, over the one touched
`.ts` file:

- **Reading 1**, the TypeScript parser's leaf nodes (a `forEachChild`
walk, so comments are trivia and JSDoc nodes are never visited). String
and template literals are therefore read in full.
- **Reading 2**, the full token stream in parser context (a
`getChildren` walk, so punctuation and keywords are included; JSDoc
nodes skipped).

Results:

- Real run at the head `01312906e`: 50 base leaf tokens, **0 files with
a token change** on either reading (exit 0).
- Comment control (「Recording alone changes nothing a user sees」 to 「...
alters ...」): 0 files changed, as expected (exit 0).
- Positive control, a code token renamed (`undefined,
zhCNGeneratedSourceHashes)` to `...SourceHashesX)`): DIFFER on the
identifier in both readings (exit 1).
- Positive control, a string-literal key changed (`'es-ES'` to
`'es-EZ'`): DIFFER on the string literal in both readings (exit 1).

Every mutation went through `scripts/ablation-replace.mjs` (wrap mode)
under a shell trap that restores by absolute path, and each landed
(anchor 1 to 0, blob changed). Each restore was proven byte-identical to
the HEAD blob (`929fa19989e2`), with `git diff HEAD` empty and a clean
tree afterwards.

## Changeset: none (`skip-changeset`)

`files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is
not private. Measured on the built package (A3), after `pnpm --filter
'@objectstack/plugin-webhooks...' build` at `01312906e`, where the
package's own `tsup` ran and cleaned its output folder:

- The rewritten line, its whole docblock (`:22-55`, which sits on the
unexported `const enSource`) and the old number appear in no `dist` file
(0 in all 20, source maps included). Nor do its neighbours `:23`, `:25`,
`:27` and `:30`, or a never-written negative phrase.
- Positive controls, text that does ship: `WebhooksTranslations` from
this same file is in `index.js`, `index.cjs` and both translations
chunks (8), and `zhCNGeneratedSourceHashes` is in both translations
chunks (4). A docblock line of this package, `auto-enqueuer.ts:33`
(「Enqueue callback into the shared `service-messaging` HTTP outbox
(ADR-0018 M3).」), is in `index.d.ts` and `index.d.cts`.
- **Byte identity.** The package was rebuilt with the base line put back
(through `scripts/ablation-replace.mjs`, with the file proven equal to
the base blob `686646fac51d` during the leg), and again after the proven
restore. The sha256 of all 20 `dist` files is identical in all three
builds: JS entries, declaration files, chunks and source maps.

So nothing this PR rewrites is published, and a changeset would announce
a change no consumer can observe. No `.changeset/*` file is added or
touched.

## Gates (head `01312906e`)

- **Citation judging, as CI runs it:** `node
scripts/check-issue-citations.mjs` exits 0 (「every citation this change
adds resolves」). `pnpm check:issue-citations` exits 0 (self-test, 114
cases, 8 batteries).
- **Doc authoring:** `pnpm check:doc-authoring` exits 0 (the
sibling-package prose-id baseline holds, no growth).
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at `01312906e` (change set derived
from git: 1 path against the merge base `bbe03f406`) derived 50
commands, all among the dispatch list's 65.
- All 65 of the dispatch list ran, each with its exit code captured
before any pipe, and all 65 exit 0; none exited 3.
- `--ran`, fed each command with its exit code, reports 50 derived, 50
run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0.
- A full `turbo run build` over `./packages/*` and `./packages/*/*` ran
first under the shared verify lock (71 of 71 tasks), so no gate hit an
unbuilt workspace.
- **Roster families the derivation lists outside its commands** (their
rosters sit in a directory this diff touches): `pnpm
check:authz-resolver`, `pnpm check:error-code-casing` and `pnpm
check:filter-alias-parity`, plus `node
scripts/check-changeset-fixed.mjs`, which the dispatch named. Each exits
0.
- **Tests and typecheck, under the verify lock, at `01312906e`:**
- `pnpm --filter @objectstack/plugin-webhooks test`: 13 files pass and
160 tests pass, which is every tracked test file under `src/`.
- `pnpm --filter @objectstack/plugin-webhooks typecheck` exits 0, and
`tsc --noEmit --listFiles` puts the touched file in the program (the
config includes `src` and excludes no test file).
- `pnpm --filter @objectstack/plugin-webhooks check:test-typecheck`
exits 0 (0 files held in the package's debt ledger).
- **Lint, as a proven narrowing:** eslint with inline config disabled,
over the touched `.ts` file, gives 1 file, 0 errors and 0 warnings (its
`--format json` output). The file is in eslint's own population (not
reported ignored; `dist/index.js`, the control, reads ignored).
`eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`, as its own lines 327-328 state), so a comment
edit here cannot move the verdict on any untouched file. The repo-wide
`pnpm lint` is CI's run.
- **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of
the changed file for control bytes finds none.

## Acceptance notes

- **The gate-invisible spellings, grepped as the claim asked** (objectstack-ai#20636,
including the `clause #N` position). At the base and at the head,
`#N-word` is on 0 lines. `#A/#B` is on 8 lines (8 second numbers), and
every second number is live: `objectstack-ai#8022` (3 lines), `objectstack-ai#7986` (2) and `objectstack-ai#8043`
(2) by the census's own judgement, and `objectstack-ai#8565` read on its own as a pull
request. `option #N`, `clause #N` and URL-spelled links are on 0 lines.
So the claim's 0 / 8 / 0 / 0 / 0 hold, and nothing dead hides behind
them. The one other raw token beyond the grammar is `PD objectstack-ai#12` at
`auto-enqueuer.ts:919`, a Prime Directive ordinal the extractor excuses
on purpose.
- **Outside-`src` residue.** Three dead numbers in files outside `src`
and outside `files[]` (`objectstack-ai#11671`, `objectstack-ai#11351`, `objectstack-ai#13176`, listed above) are
left, as stages 13 to 15 left theirs. They ship nothing and move no
gate.
- **The census instrument did not truncate in this stage.** Both
enumerations read 187 pages at the newest frontier.
- **Anchor for the next stages:** `objectstack-ai#11671` → `09b4f4e4e`.
`service-realtime/src/translations/index.ts:27` still carries the dead
number in the identical sentence.
- **Base.** The branch is on `main` at `bbe03f406`. `main` has since
moved five commits (to `688ddef3c`). None touches
`packages/plugins/plugin-webhooks`, `scripts/check-issue-citations.mjs`,
`scripts/pm/dispatch-gates.mjs` or `.changeset/config.json`. No merge
was taken; the merge queue rebuilds on the merged generation.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…our digits, and the reader reads the week key the writer writes (objectstack-ai#20760) (objectstack-ai#20865)

Fixes objectstack-ai#20760
Clause-②: no

## What changes

Triage direction 5903751640, as ruled: one writer, one reader, no local
padding. Nothing in any driver's SQL moves.

- **One writer: `@objectstack/core` `bucketDateKey` and its ISO week
label.** A private `bucketKeyYear(year)` spells the year of a bucket key
with four digits, and every arm goes through it: `year`, `quarter`,
`month`, `day` (through a private `bucketDayKey`) and the week label
(`isoWeekLabelFromCalendarDay`). A year from 1000 to 9999 is spelled as
before. No export is added or removed.
- **One reader: `bucketKeyToCalendarRange`.** Its patterns already
required a four-digit year. The defect was the week arm: it checks a key
against the label the writer gives the reconstructed Monday, and that
label was unpadded, so `0050-W01` answered `null`. Its bounds are now
spelled by the writer's own day key (`fmt` is `bucketDayKey`), so the
two share one spelling. It does not accept the unpadded spelling
(`50-06`, `49-W52`): nothing writes it after this change.
- **`service-analytics` `bucketKeyAtOrdinal` (the declared cross-lane
line).** It spelled every granularity itself and carried a private copy
of the ISO week rule (`isoWeekKeyOfUtcMs`, deleted). It now computes
only the UTC instant the ordinal's bucket starts at, and core's
`bucketDateKey` spells the key (a local `bucketKeyAt`, which
`calendarDayAt` now delegates to). No second padding.

## Before and after (function level)

Core at base `c90f9fb6e2`, read from a temporary copy of the base
`datetime.ts`, against this branch:

| instant | base | this branch |
|:--|:--|:--|
| `0050-06-15T10:00Z` | `50` · `50-Q2` · `50-06` · `50-06-15` · `50-W24`
| `0050` · `0050-Q2` · `0050-06` · `0050-06-15` · `0050-W24` |
| `0050-01-01T10:00Z` | `50` · `50-Q1` · `50-01` · `50-01-01` · `49-W52`
| `0050` · `0050-Q1` · `0050-01` · `0050-01-01` · `0049-W52` |
| `0999-06-15T10:00Z` | `999` · `999-Q2` · `999-06` · `999-06-15` ·
`999-W24` | `0999` · `0999-Q2` · `0999-06` · `0999-06-15` · `0999-W24` |
| `2026-06-15T10:00Z` (control) | `2026` · `2026-Q2` · `2026-06` ·
`2026-06-15` · `2026-W25` | identical |

`bucketKeyToCalendarRange(key, 'week')`: `0050-W01`, `0049-W52` and
`0999-W24` answered `null` at base. They now answer
`0050-01-03`..`0050-01-10`, `0049-12-27`..`0050-01-03` and
`0999-06-10`..`0999-06-17`. `2026-W01` answers
`2025-12-29`..`2026-01-05` on both.

## The zone-2 hypotheses

- **H1, the callers: held, and the census found two more writers.** Each
named caller follows the helper with no edit:
- `objectql` `in-memory-aggregation.ts` `bucketDateValue` is a thin
delegate (pinned);
- `objectql` `having-filter.ts` names the helper in a comment only; its
`day` bucket's `date` class now holds a real `YYYY-MM-DD`;
- `driver-memory` `memory-analytics.ts` `aggregateWithTimeBuckets`
delegates (pinned);
- `service-analytics` `analytics-service.ts`, the drill ranges, calls
`bucketKeyToCalendarRange` (pinned through `queryDataset`);
- `service-analytics` `dataset-executor.ts`: `calendarDayAt` delegates,
`alignedCompareBucketKey` reads through the reader (pinned), and
`bucketKeyAtOrdinal` is H2;
  - `core` `compensated-sum.ts` names the helper in a comment only.

Two `service-analytics` writers call no helper and spell the year
unpadded: `preview-evaluator.ts` `bucketDate` and `dimension-labels.ts`
`formatDateBucket`. Both are outside the declared surface (Acceptance
notes).
- **H2: held.** `bucketKeyAtOrdinal` built its own keys at every
granularity. It now calls the helper (above).
- **H3: SQLite and PostgreSQL pad, measured; MySQL is NOT MEASURED.**
- SQLite 3.53.4: pinned below. The keys equal `bucketDateKey` at `year`,
`quarter`, `month` and `day` through a `datetime` and a `date` column.
SQLite buckets `week` in memory, not in SQL.
- PostgreSQL 16.13: measured live on a throwaway local server. This
branch's `SqlDriver` ran `initObjects`, `create` and `aggregate` over a
`datetime` and a `date` column holding 0050-06-15, 0050-01-01,
0999-06-15 and 2026-06-15. The keys equal `bucketDateKey` at all five
granularities, 0 mismatches in 10 cells, `0049-W52` from `IYYY"-W"IW`
included. This was a scratch harness, not committed (Acceptance notes).
- MySQL: NOT MEASURED. This container has no MySQL server (no binary, no
Docker daemon). The reference manual gives `%Y` and `%x` as four digits.
- No dialect was found whose bucket SQL does not pad, and no driver
changes.
- **H4: read at function level, not reached, and no refusal added.**
  - Year 0 keys `0000`, as SQLite's `strftime('%Y')` does.
- Year −1 keys `-1`, never the padded fragment `00-1`. SQLite answers
`-001`.
  - Year 10000 keys `10000`. SQLite answers NULL.
  - The reader answers `null` for both the −1 and the 10000 key.
- Both engine doors refuse these years for a `date` and a `datetime`
value. The shape is pinned in the core file below.
- **H5: held.** Early January 0050 keys `0049-W52`, and `0049-W52` spans
`0049-12-27`..`0050-01-03`. This is pinned in core, objectql,
service-analytics and the PostgreSQL reading.

## Pins

One new file beside each face. Every expected key is spelled literally,
and each file covers 0050, 0999 and the 2026 control:

- core `datetime-bucket-key-four-digit-year.test.ts`:
- the writer at every granularity, for 0001, 0050, 0999 and 2026, as a
`Date` and as epoch ms too;
  - the Asia/Shanghai week-year;
  - the reader round-tripping every written key;
  - the week ranges above (`0050-W01` included);
  - the unpadded spelling answering `null`;
  - H4.
- objectql `in-memory-aggregation-four-digit-year.test.ts`: the engine's
in-memory `groupBy` at every granularity.
- driver-memory `memory-analytics-four-digit-year.test.ts`: the memory
cube face at every granularity.
- driver-sql `sql-driver-bucket-key-four-digit-year.test.ts`:
`SqlDriver` on SQLite against core's `bucketDateKey`, for 0050-06-15.
- service-analytics `bucket-key-four-digit-year.test.ts`:
  - `bucketKeyAtOrdinal` against the grouped key at every granularity;
- `alignedCompareBucketKey` restating `0049-06` / `0049-W24` as
`0050-06` / `0050-W24`;
- a `queryDataset` drill-down from `0050-W01` finding
`0050-01-03`..`0050-01-10`.

Two existing files had comments stating the unpadded spelling as current
(`datetime-year-below-100.test.ts` in core,
`week-key-year-below-100.test.ts` in service-analytics). Their comments
are corrected, and their lenient readers now require the four-digit
year.

## Ablation: red/green on the padding

At `3716880ded`. The padding line is byte-identical at the PR head.

- **Mutation.** `scripts/ablation-replace.mjs` turned `return year >= 0
? String(year).padStart(4, '0') : String(year);` into `return
String(year);` (anchor 1 → 0, blob `fe67aac16ed5` → `5a14574b2faf`).
- **It reached `dist/`.** Core was rebuilt, and `ablation-dist-preflight
--absent` passed: the marker was absent from all 14 built files.
objectql and service-analytics read core from `dist/`.
- **Red:**
  - core `datetime*`: 101 failed / 151 passed of 252;
  - objectql: 6 of 6 failed;
  - driver-memory: 5 of 5 failed;
- driver-sql: 4 failed / 8 passed. The 8 are the SQL cells, green
because SQLite pads on its own; only the in-memory cells go red;
  - service-analytics: 30 failed / 102 passed of 132.
- **Restore.** The blob equals HEAD's `fe67aac16ed5` and `git diff HEAD`
is empty. Core was rebuilt, and preflight in default mode found the
marker in 2 built files with the tree clean.
- **Green:** 252 / 6 / 5 / 12 / 132.

## Verification (head `c03977051a`)

`main` at `05a7547c9f` (objectstack-ai#20843) was merged in. It edits one comment in
`datetime.ts`, and this branch's two range sentences now state its
`datetime` floor.

- **Suites** (`vitest run`, at `076ba0c599`, after the merge and a
rebuild of the touched closure):
  - core: `local` 61 files / 1793 passed, `repo` 3 / 48;
  - objectql: `local` 345 / 6779;
  - driver-memory: 66 / 1475;
- driver-sql: 202 passed, 11 skipped (the live cells, which have no URL
here) / 3266 tests;
  - service-analytics: 142 / 3282.
- **At `c03977051a`,** which changes only the driver-sql pin: every
face's pins plus driver-sql's `date-bucket` and `date-bucket-storage`
suites pass, 36 / 252 / 6 / 5 / 57.
- **Typecheck:** `typecheck` exits 0 for all five packages. `tsc
--listFiles` shows each new test file in a compiled program.
- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` derived 67
families at `c03977051a`, and all 67 ran there. 67 of 67 exited 0.
`--ran` reconciles: 67 derived, 67 run, 0 NOT MEASURED, 0 unrun.
- `check:dual-build-cjs-loads` and `check:type-check-debt` first exited
3 (PREREQUISITE NOT MET: only the touched closure was built). Both
exited 0 after `pnpm exec turbo run build --filter='./packages/*'
--filter='./packages/*/*'` (71/71 tasks). The seat corrected this bullet
from `os-dev-report` 5912276902; the head is unchanged.
- The first run caught one `as any` on the driver-sql pin's aggregate
options (`check:query-options-erasure`, test surface 236 → 237). It was
typed in `c03977051a`, and the ratchet holds at 236.
- **Lint (narrowed, proven).** `eslint --no-inline-config --format json`
ran over the 9 changed `.ts` files: 9 files, 0 errors, 0 warnings, none
reported as ignored. The config covers all 9. It enables no type-aware
linting (no `parserOptions.project` anywhere, stated in
`eslint.config.mjs` itself), so this diff cannot move a verdict on an
untouched file. The full `pnpm lint` is CI's.

## Clause-② measured

Six published outputs change spelling for a year below 1000:
- `bucketDateKey`;
- the in-memory `groupBy` keys;
- the memory cube labels;
- the `compareTo` merge keys;
- `bucketKeyToCalendarRange`, which now answers for a padded week key;
- the display label `formatDateBucket` gives an in-memory month or day
key: it was `1950-06`, a wrong century, and is now `50-06`, what it
gives the SQL key.

Each is now what the SQL path answered already: SQLite pinned,
PostgreSQL measured. The reader drops no key it read before, because the
unpadded spelling never matched its four-digit patterns. So `Clause-②:
no` stands.

## Acceptance notes

- **`dimension-labels.ts` `formatDateBucket` (service-analytics).** It
relabels a date dimension's rows when labels resolve. For a padded key
below 1000 it answers `year` `0050` → `1970`, reading a pure-digit key
as epoch seconds because its year check admits only 1000..9999. It
answers `month` `0050-06` → `50-06` and `day` `0050-06-15` → `50-06-15`.
Function level. It is the same unpadded-year family as objectstack-ai#20602, it sits
outside the declared surface, and it is reported for the family card.
- **`preview-evaluator.ts` `bucketDate` (service-analytics, the draft
preview).** It spells the year unpadded at `year` / `quarter` / `month`
/ `day`. Its `week` key is the Monday's `YYYY-MM-DD` (pinned so), not
the ISO label. Outside the surface.
- **`driver-mongodb` `mongodb-aggregation.ts`.** A header comment says a
year before 1000 "labels '0999' here and '999' in memory". This PR makes
it false. The file is outside the declared surface, so it is left for
its next editor.
- **`@objectstack/verify` `checkDateBucketParity`.** It probes 2024 and
2025 instants only, so the parity device cannot see this family on any
driver. No gate change here.
- **The live dialect matrix (driver-sql
`live-dialect-matrix.testkit.ts`).** It is not extended to this pin. A
MySQL `datetime` below 1000 is objectstack-ai#20280's ground, and its reading is not
measured here.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ear, so an export of a year below 1000 re-imports (objectstack-ai#20602) (objectstack-ai#20688)

Fixes objectstack-ai#20602
Clause-②: no

## What changes

`GET /api/v1/data/:object/export` wrote a `date` or `datetime` cell's
year unpadded, so a day in the years 0001 to 0999 left the export short
(`500-01-01`, `999-12-31 21:03:58`) and `POST
/api/v1/data/:object/import`, which reads a four-digit year only,
refused the platform's own file.

One source file changes, `packages/rest/src/export-format.ts`, on the
three paths triage named:

- `formatDate`'s `date` branch, `utcWallClock` and `zonedWallClock` now
take the day from one private helper, `calendarDay`.
- **The pad rule: core's `temporalStorageForm`, `date` rule, imported
from `@objectstack/core`** (not mirrored). It pads 0001..0999 and leaves
a year outside 0001..9999 unpadded. `@objectstack/rest` already depends
on `@objectstack/core`; no `packages/core/**` edit.
- `zonedWallClock` no longer reads the year from `Intl`'s `year` part,
which is an ERA year (year 0, 1 BC, reads `1`): padding it would spell
`0001-01-01T03:00:00Z` in New York as `0001-12-31`, a day a year later
than the instant's. The zone's year is the instant's UTC year, plus one
when the zone has reached January while UTC is in December, minus one
the other way round. The day is built with `setUTCFullYear`, never
`Date.UTC`.
- `packages/rest/src/rest-server.ts` is untouched; the pins drive the
real routes.

## This round: merge of `main`, and what it changed for this PR

The seat held this PR behind objectstack-ai#20599 (answer `5895522655`, option B).
objectstack-ai#20599 has landed (PR objectstack-ai#20746, `a6866da0c`), and this round merged
`origin/main` `9509ea106a` into the branch as `c0c254921a` (a merge
commit; no rebase, no force-push; `git merge-tree` was clean).

`main` also carries PR objectstack-ai#20843 (`05a7547c9f`, objectstack-ai#20280): **a `datetime`
names a year from 1000 to 9999 at both engine doors**, and a `date`
keeps 0001..9999. That moved this PR's own pin: at `c0c254921a` the
route layer of `export-date-year-pad.test.ts` went red (`80 passed | 63
skipped`, every route `beforeAll` failed), because the create door now
refuses the pin's `datetime` rows for 0500 and 0999 (`400
VALIDATION_FAILED`, field `dt`, code `invalid_date`). So the dispatched
step, "drop the `dt: undefined` exclusion for 0001 and 0050", cannot be
taken as written: those rows cannot be created at all. What `81b61a6b18`
does instead:

- **The pin** (`packages/rest/src/export-date-year-pad.test.ts`): the
route rows before year 1000 carry a `date` only; 0099 joins the years
(formatter census and routes); a boundary row pins the one `datetime`
cell the export's padding still reaches at the routes, the instant
`1000-01-01T02:00:00.000Z`, which America/New_York reads on `0999-12-31`
(exported `0999-12-31 21:03:58`, re-imported as the same instant). The
module note says why.
- **Two comments** (one in `export-format.ts`, one in the pin) no longer
say the import "would take" the era-year spelling: after objectstack-ai#20280 the
import refuses it, as the write doors do. Comment-only; no behaviour in
`export-format.ts` moved (its blob went `8904e5c4b1b4` to `d318ab58eb98`
on that one comment).
- **The changeset** no longer says a `datetime` `0500-01-01 10:00:00`
re-imports: it names the `date` cell and the zone-boundary `datetime`
cell that do, and says a `datetime` stored before year 1000 exports
padded and is refused by the import, as by the write doors.

## Measured, on the merged tree

Harness: the real `POST /api/v1/data/:object`, `GET
/api/v1/data/:object/export` and `POST /api/v1/data/:object/import`
handlers of a `RestServer` over `ObjectQL` plus `SqlDriver`
(better-sqlite3 `:memory:`) and the real metadata protocol, driven
in-process, business timezone from the resolved `ExecutionContext`, into
a fresh stack for the import. A throwaway probe (deleted, not in the
diff), at `c0c254921a`, CSV, xlsx and JSON, business timezone none /
Asia/Shanghai / America/New_York, host `TZ` unset (UTC) and
`TZ=America/New_York`.

| row | create door | exported cell (none / Shanghai / New York) |
`/import` | stored back |
|:--|:--|:--|:--|:--|
| `date` 0001, 0050, 0099, 0500 (`-01-01`) | 201 | `0001-01-01` etc.,
padded, all zones | ok | identical, 72 of 72 legs |
| `date` 2026 | 201 | `2026-01-01` | ok | identical |
| `datetime` 0001, 0050, 0099, 0500 at 10:00Z | **400**
`VALIDATION_FAILED`, `dt` `invalid_date` (72 of 72) | no row | none |
none |
| `datetime` 1000 at 10:00Z | 201 | `1000-01-01 10:00:00` / `18:05:43` /
`05:03:58` | ok | identical |
| `datetime` 2026 at 10:00Z | 201 | `2026-01-01 10:00:00` / `18:00:00` /
`05:00:00` | ok | identical |
| `datetime` `1000-01-01T02:00:00.000Z` | 201 | `1000-01-01 02:00:00` /
`1000-01-01 10:05:43` / **`0999-12-31 21:03:58`** | ok | identical |
| `datetime` 0050, 0500 at 10:00Z written through the driver (a row
stored before the floor) | not the door | `0050-01-01 10:00:00`,
`0500-01-01 10:00:00` (and zone clocks), padded | row refused, `dt`
`invalid_date` (36 of 36) | nothing stored |

Every row the create door takes round-trips exactly: 144 of 144 legs.
The seat's concern for this landing order, a padded `datetime`
0001..0099 stored 1900 years late with no error, has no path left: the
create door refuses such a `datetime`, and a row stored before the floor
exports padded and is refused loudly by the import, never stored.

**Control, the same probe with `export-format.ts` at `9509ea106a`** (the
merge's `main` parent; blob `5791dbaeb33b` proven on disk, restored to
HEAD `8904e5c4b1b4` with `git diff HEAD` empty): the `date` cells export
`1-01-01`, `50-01-01`, `99-01-01`, `500-01-01`, the New York boundary
cell `999-12-31 21:03:58`, the pre-floor rows `50-01-01 10:00:00`; the
import refuses every one as `invalid_date`. Per format: `ok 4, errors 6`
with no zone and in Asia/Shanghai, `ok 3, errors 7` in America/New_York;
at the head `ok 8, errors 2` everywhere (the two pre-floor rows).

**Ablations at `81b61a6b18`**, each through `node
scripts/ablation-replace.mjs` (anchor hit once, blob moved, restore
proven: blob `d318ab58eb98` equals HEAD and `git diff HEAD` empty); the
subject resolves through `src/` by relative import, so no `dist` leg:

- `calendarDay` returns the unpadded spelling: `111 failed | 61 passed
(172)`; no failing test names 1000, 2026 or 9999; the failures are the
below-1000 formatter cells and every route row whose `date` is before
1000, the boundary row included.
- Only the zone path unpadded (`zonedWallClock` spells the day's
`getUTCFullYear()` unpadded, the era-year correction kept): `14 failed |
158 passed (172)`: the ten zoned formatter cells before 1000
(Asia/Shanghai and America/New_York), the year-boundary pin, and exactly
the boundary route row in America/New_York in CSV, xlsx and JSON. That
is the new route row's `datetime` half biting on its own.

Earlier readings by the predecessor round (head `e739a50fa0`, base
`6981abfd26`), still describing `export-format.ts` as it is: H0 (the
base exported `500-01-01` and `500-01-01 10:00:00` and the import
refused the row) and the one-shot H1 census of 1050 `formatCellValue`
cells (years 1000, 2026, 9999 and +010000 byte-identical except 5 cells
whose zone day is `0999-12-31`, now padded; 0001..0999 padded;
out-of-range zoned cells now spell the rule's year instead of the era
year).

## Tests

`packages/rest/src/export-date-year-pad.test.ts`, 172 tests: the
formatter census (years 0001, 0050, 0099, 0500, 0999, 1000, 2026, 9999;
`date` and `datetime`; zones none / UTC / Asia/Shanghai /
America/New_York / unknown), the 2026 control's exact cells, `Date` and
epoch-ms inputs, the year-boundary pin, and the route round trip per
row, format and business timezone (the xlsx leg also asserts text
cells).

All at `81b61a6b18`, under `scripts/pm/os-verify-lock.sh`:

- `pnpm --filter @objectstack/rest exec vitest run --project local
--maxWorkers=2 src/export-date-year-pad.test.ts
src/import-datetime-year-below-100.test.ts`: `Tests 218 passed (218)`,
host `TZ` unset and again under `TZ=America/New_York`.
- `pnpm --filter @objectstack/rest run test`: `Test Files 239 passed
(239)`, `Tests 4824 passed | 106 skipped (4930)`.
- `pnpm --filter @objectstack/rest run test:repo`: `Tests 8 passed (8)`.
- `pnpm --filter @objectstack/rest run typecheck`: exit 0,
`check:test-typecheck: OK`; `tsc -p tsconfig.test.json --listFilesOnly`
lists the pin file.
- The public surface of `@objectstack/rest` is byte-unchanged
(`calendarDay` is private), so no downstream consumer owes a test.

## Gates

At `81b61a6b18` (merge base `9509ea106a`):

- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`: 60 commands, all exit 0. `check:dual-build-cjs-loads` and
`check:type-check-debt` first exited 3 (`PREREQUISITE NOT MET`, no
`dist`) and are green after `pnpm exec turbo run build
--filter='./packages/*' --filter='./packages/*/*' --concurrency=2`.
`--ran`: `60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN`.
- NOT MEASURED locally, by the tool's own account: the six families
whose argv takes a value from the workflow, and the eleven whole-root
families; CI runs them.
- `pnpm lint` (the whole tree, not narrowed): exit 0, 229 s.

## Acceptance notes

1. **The landing order the seat set is met, and the round trip it asked
about is not a case any more.** objectstack-ai#20599 has landed, and objectstack-ai#20280's floor
means a `datetime` in 0001..0999 is refused at the create door, so no
such row exports. Every row the doors take round-trips exactly (table
above).
2. **`packages/rest/src/import-datetime-year-below-100.test.ts`** (PR
objectstack-ai#20746, read-only here): its `padExportYear` step is now a byte-for-byte
no-op on every cell this export writes for a day in 0001..9999, so on
every cell its own round trip exports. Its round trip runs at 1000 and
2026 only (the floor), so it never carried a year below 1000 through the
export; this PR's boundary row is the end-to-end `datetime` check below
1000 in a zone. Not edited.
3. **Out-of-range years in a business timezone.** A `datetime` whose
zone day falls in year 0 or before spells the rule's year (`0-12-31`)
instead of the era year (`1-12-31`). The import refuses both, and the
write doors refuse such years.
4. **Observed, not filed:** the create door refuses a well-formed ISO
`datetime` before year 1000 with the sentence "At must be a valid
datetime (ISO-8601)", which names the spelling rather than the
1000..9999 range; the record validator chose one sentence per kind on
purpose. No carrier.
5. **Not changed, dormant:** `packages/rest/src/import-prepare.ts`
`xlsxDateToNaiveCell` spells an xlsx date cell's year unpadded. An xlsx
date cell is an Excel serial (from 1900, or 1904), and the export writes
text cells, so no workbook reaches it with a year below 1000.

Taken over in this round by session `session_01VvcEokUG1tvVxkceYfR5XB`
(claim `5916503658`); the branch's first two commits are the predecessor
seat's (session `session_local_1d2a197c-c20e-4e90-9be8-413d4d432289`).

---------

Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
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 protocol:data size/l tests tooling

Projects

None yet

2 participants