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

veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…write seam (objectstack-ai#20386) (objectstack-ai#20482)

Fixes objectstack-ai#20386

Clause-②: no (narrowing)

A `progress` field's declared `min` / `max` are now **enforced at the
objectql write seam**, per triage `5865053231` (ENFORCE, no decision
card). A `progress` write outside a declared bound is refused with `400
VALIDATION_FAILED` and the `number` field's own field codes, `max_value`
/ `min_value`. `scale` and `precision` stay unread on `progress`.
Measured head: **`af9e5101a`**.

## What changes

- **`packages/objectql/src/validation/record-validator.ts`, the number
arm.** The `if (t === 'progress') return null;` early return moves from
above the `min` / `max` checks to directly below them, and above `scale`
/ `precision`. The objectstack-ai#20308 docblock that deferred this now says why the
bounds bind and why the return stays above `scale` / `precision`: each
of those keys' own `.describe()` names a type set `progress` is not in.
- **The file header's `min` / `max` line** now lists `progress`. It
named the five types that were enforced, so leaving it would have made
it false. This is one line outside "the number arm and the objectstack-ai#20308
docblock" (declared below as a deviation). It is line 31, far from the
date line PR objectstack-ai#20469 edits (line 55 on `main`).
- **Tests.** `record-validator.blank-typed-value.test.ts` pinned the old
boundary (`progress` `max: 100` accepting `150.5`). It now pins what
stays true: `summary` reads no bound or `scale`, and `progress` reads no
`scale`. One test name in `record-validator.precision.test.ts` said
`progress`'s "bounds the numeric branch never reads", and it is
reworded. Its assertion is unchanged.
- **`.changeset/20386-progress-min-max-enforced.md`**:
`@objectstack/objectql` `minor`, **BREAKING** banner, the `Clause-②`
line, a before → after line and the ADR-0087 disposition (below).

## Measured premises (dispatch zone 2)

- **H1: `progress` bounds are skipped on `origin/main`. Held.**
Reproduced on `dc0ab6a2e` through the real `RestServer` `POST
/api/v1/data/:object` handler, over a real `ObjectQL` engine on the
SQLite `SqlDriver` and on the memory driver. This was a scratch harness,
not committed, because `check:driver-memory-census` refuses a new
driver-memory consumer.

| field | value | SQLite before | memory before | both after
(`5f5bc7580`) |
  |:--|:--|:--|:--|:--|
| `progress`, `max: 100` | `150` | 201, stored `150` (real) | 201,
stored `150` | 400 `VALIDATION_FAILED` / `max_value` `{ max: 100 }`, no
row |
| `progress`, `min: 0` | `-5` | 201, stored `-5` (real) | 201, stored
`-5` | 400 `VALIDATION_FAILED` / `min_value` `{ min: 0 }`, no row |
| `number`, `max: 100` (control) | `150` | 400 / `max_value`, no row |
the same | unchanged |
| `number`, `min: 0` (control) | `-5` | 400 / `min_value`, no row | the
same | unchanged |
| `progress` in bounds / on each bound | `50`, `0`, `100` | 201 | 201 |
201, stored unchanged |

- **H2: `scale` / `precision` after the move. Held, with a measured
boundary.** With the return placed below the bounds, `scale` and
`precision` are still not enforced on `progress`: `33.5` under `scale:
0` gets 201, and `99.5` under `precision: 2` gets 201, on SQLite and
memory. Deleting the return outright would start both. Measured by
ablation (below): four pins turn red with `max_scale` / `max_precision`.
So the placement is load-bearing, and it is pinned from both sides.
- **H3: producers. Zero writes outside a declared bound.**
- objectui `SliderField`, the `progress` editor
(`FieldEditWidget.tsx:87` `progress: SliderField`), is byte-identical at
the pinned `.objectui-sha` `dd3f7e1be3` and at objectui HEAD `b8e0941`.
It passes `min = field.min ?? 0` and `max = field.max ?? 100` to
`@radix-ui/react-slider` (`^1.4.7`). That component's `updateValues`
does `clamp(snapToStep, [min, max])` before every `onValueChange` (read
in the 1.4.7 tarball). So it cannot emit a value outside a declared
bound.
- Example apps, on `dc0ab6a2e`: 2 `progress` fields declare bounds,
`showcase_task.progress` and the field zoo's `f_progress`, both `min: 0,
max: 100`. Their writers are 12 seed rows, the `showcase_mark_done`
action (`progress: 100`) and the dogfood field-zoo matrix (`60`). All of
them are in bounds.

## Pins

-
`packages/objectql/src/validation/record-validator.progress-bounds.test.ts`
(new, 14 tests):
- the triage pins (`150` gets `max_value` `{ max: 100 }`, `-5` gets
`min_value` `{ min: 0 }`, and in-bounds values plus both inclusive
bounds are accepted);
  - envelope equality with the `number` refusal;
- one bound declared alone, and no invented 0..100 bound when none is
declared;
- update mode, string-carried values, and an omitted field that is never
re-read;
- ⛔ `scale` / `precision` unread on `progress`, while the same
declaration on `slider` refuses;
- every engine write door through a stub driver: insert of one row and
of an array, `insertMany` partial success, update by id and by
predicate, the `validate` dry run, and a control showing that in-bounds
values arrive as the same number.
- `packages/rest/src/rest-data-progress-bounds.test.ts` (new, 6 tests),
on the real `RestServer` routes over SQLite, reading the physical column
past every read coercion:
- POST, batch create, PATCH, batch update and updateMany refuse `150` /
`-5` with the `number` field's envelope and write or change nothing;
- controls: in-bounds values and both bounds are stored unchanged, and
`33.5` writes under `scale: 0, precision: 2`.

## Verification

Heavy runs went through `scripts/pm/os-verify-lock.sh`. All readings are
at `af9e5101a` unless stated otherwise.

- **Build.** `pnpm turbo run build --filter='@objectstack/rest...'
--concurrency=2`: 25/25, VERDICT command-exit 0. It was re-run after
each merge of `main` (last at `5c4148234`). The objectql source has not
changed since.
- **objectql.**
- Validator and door suites (`progress-bounds`, `blank-typed-value`,
`precision`, `number-value`, `record-validator`,
`engine-number-value-door`, `engine-blank-typed-value-door`): 7 files,
333/333.
- Full `--project local`: 328 files, 6081/6081 (at `6a029a923`, before
the second merge, which brought only spec and driver-sql commits).
- `typecheck` (tsc, scripts, and `check:test-typecheck`, whose
`tsconfig.test.json` includes `src/**/*`): exit 0.
- **rest.** `rest-data-progress-bounds`, `rest-data-number-value`,
`rest-data-blank-typed-value` and `import-integration`: 4 files,
100/100. `typecheck` including `check:test-typecheck`: exit 0.
- **Reverse verification.**
- **REST pin, with a build.** Against the `dist/` built from base
`dc0ab6a2e`, the REST pin read **5 failed / 1 passed**. Each failure was
`expected 201 to be 400` or a batch row reporting success. The one green
is the in-bounds control. The `dist/index.js` number arm was read
directly before and after: the return sat above the bounds, then below
`max`. After `pnpm --filter @objectstack/objectql build` at `5f5bc7580`
the pin reads 6/6.
- **Ablation 1, fix committed first (`3b7b55406`).**
`scripts/ablation-replace.mjs` re-planted `if (t === 'progress') return
null;` above the bounds: anchor 1 → 0, blob `9ede5b5b` → `d396c53a`.
Result: the new objectql file reads **10 failed / 4 passed**. The 4
greens are the controls: in-bounds values, `scale` unread, `precision`
unread, and the engine in-bounds control. Restored: blob `9ede5b5b`
equals HEAD, and `git diff HEAD` is empty. The subject resolves through
a relative import to `src/`, so no rebuild was involved. Direction: red.
- **Ablation 2, the H2 reading.** The same tool deleted the return: blob
`9ede5b5b` → `d683b83e`. **4 failed**: the two `progress-bounds` pins
for `scale` / `precision`, the `blank-typed-value` `scale` pin, and the
`precision` test that excludes `progress`. Restored the same way.
Direction: red.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `af9e5101a` derived 64 commands. **62
exit 0**. 2 are **NOT MEASURED** (exit 3, PREREQUISITE NOT MET):
`check:dual-build-cjs-loads` and `check:type-check-debt` both need a
whole-repo build. `--ran` reconciliation: 64 derived, 62 run, 2
NOT-MEASURED (derived from exit 3), 0 UNRUN, exit 0.
- First pass at `5c4148234`: `check:error-code-casing` exit 1 on four
bare `{ code: 'max_value' }` style assertions. They are now
field-addressed (`af9e5101a`), and the gate reads 0.
- **Lint, a declared narrowing.** `eslint --no-inline-config --format
json` on the 5 changed `.ts` files: 5 files, 0 errors, 0 warnings.
- Population: `--print-config` applies the config's rules to each file
(6 rules on the validator, 5 on the REST test).
- Invariance: `eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`), so this diff cannot move any untouched file's
verdict.
  - The repo-wide `pnpm lint` is declared to CI.
- **Not run locally, declared to CI:** the rest of the rest suite,
runtime and dogfood, and the 6 path-matched families that take a value
from the workflow.

## ADR-0087 disposition: which precedent, and why

`not-required (no-migration-prescription)`, following **PR objectstack-ai#20423**, not
objectstack-ai#7501:
- objectstack-ai#20423 is the closer precedent: the same arm, the same kind of change
(a declared numeric bound starting to bind at the write seam, a
narrowing of the write accept set with no authored key moving), and a
gate-era marker.
- objectstack-ai#7501's changeset (`number-scale-enforced-by-rejection.md`,
`951476719`) declared no BREAKING banner, so
`check:adr-0087-registration` never asked it for a marker. It carries
none, and there is nothing to copy.

One conflict with the dispatch order's wording is worth stating. The
seat's dispatch order asks for "a FROM → TO line" (claim 5873443045
itself names only `.changeset/20386-*.md`; seat edit after review
5875022310). Measured with the gate's own exported
`findMigrationPrescription`: a line opening with the literal `FROM → TO`
label is read as a **migration prescription** (branch `from-to-label`),
and that refuses `no-migration-prescription`. The only category left
would then be `registered`, which would need a new ledger entry in
`packages/spec`. That is out of scope for this card and wrong on the
facts, since nothing authorable moves. So the changeset carries the
mapping as **"What a caller sees, before → after"** (`201`, stored as
sent → `400 VALIDATION_FAILED` + `max_value` / `min_value`, nothing
stored), plus the one-line fix. The detector reads that as no
prescription, and `check:adr-0087-registration` passes.

## Acceptance notes

- **`scale` / `precision` declared on a `progress` field parse, and
nothing reads them at the write seam.**
- Their `.describe()` texts name the types they bind on, and
`precision`'s says "Not read on any other field type".
- The metadata designer offers neither on `progress`:
`ObjectFieldInspector` `isNumeric` covers only `number`, `currency` and
`percent`.
  - No example declares them. Noted, not filed.
- **objectui `SliderField`'s undeclared-bound defaults** (`min ?? 0`,
`max ?? 100`) are narrower than the server, which enforces nothing
undeclared.
- There is one degenerate shape, not measured in a browser: a `progress`
field declaring `min` above 100 and no `max`. The slider then clamps
into `[min, 100]`, so it would emit `100`, which is now refused.
  - No field declares that shape. Noted, not filed. Holder: none.
- **`docs/qa/platform-checklist/areas/records-forms.json`**, item
`records-forms.field-type-constraints`: its steps do not reach
`progress` bounds (triage said so). This belongs to the next
checklist-author sweep. Holder: none.
- **PR objectstack-ai#20469** (objectstack-ai#20264) edits the header's date line and the date /
datetime arm of the same file. The hunks are far apart and there is no
textual overlap. The later lander merges `main`.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
… field at the engine's filter door, and narrow a numeric one (objectstack-ai#20351) (objectstack-ai#20501)

Fixes objectstack-ai#20351
Clause-②: no (narrowing)

## What this adds

Lane (2) of the two-lane route objectstack-ai#20336 took on objectstack-ai#15661's precedent: the
engine door that consults the contract PR objectstack-ai#20414 published in
`@objectstack/spec/data` (`filter-number-comparand-declared-type.ts`).
The contract half is untouched; `packages/spec` is not in this diff.

- **The door**,
`packages/objectql/src/number-comparand-declared-type-door.ts`, beside
the text-operator and temporal doors. For each comparand at a judged
position on a declared numeric field it asks
`numberComparandDoorVerdict` and routes the answer:
- `door-refusal`: throws `INVALID_FILTER` / 400 (the existing
`invalidFilterError` envelope) in the contract's words,
`numberComparandRefusalMessage`, before any driver is resolved;
- `narrows`: rewrites the numeric string to its number, copy-on-write
(the caller's filter is never edited, and a filter with nothing to
narrow comes back by reference);
  - `passes` / `deferred`: leaves it alone.
  
The door reads no string itself. The grammar, the judged types
(`NUMERIC_VALUE_TYPES` by identity), the judged operators
(`NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS` /
`NUMBER_COMPARAND_DOOR_LIST_OPERATORS`) and the words are all the
spec's.
- **Its calls in `engine.ts`, at the collection point only**, fifth
after the temporal door in the same order everywhere:
- `lowerWhereFilterArray`, object form (before
`normalizeFilterComparandTypes`) and array form (on the lowered
condition). So `find` / `findOne` / `count` / `aggregate` / `update` /
`delete` and the judge-only `judgeFilter` (`judgeWhereAdmission` calls
the same function) all inherit it;
- each per-aggregation `filter`, rooted at `aggregations[i].filter`,
against the object's declared fields;
- `having`, after the temporal `having` door, over the columns
`aggregatedRowColumnClasses` classes `numeric` (`count` / `sum` / `avg`,
and a groupBy or `min` / `max` of a numeric field).
  
The `judgeWhereAdmission` docblock's pipeline list names the new door
(comment only).
- **A changeset**, `.changeset/20351-number-comparand-door.md`:
`@objectstack/objectql` `minor`, BREAKING, `Clause-②: no (narrowing)`, a
FROM → TO line, and the ADR-0087 disposition `not-required
(no-migration-prescription)` in the form PR objectstack-ai#20469 and PR objectstack-ai#20370 used.
`@objectstack/objectql`'s root exports are unchanged: the door module is
not re-exported from `index.ts` or `core.ts`, like its two siblings.

## What it does to the card's three answers

Measured through `engine.find` / `engine.aggregate` and `POST
/api/v1/data/:object/query`, three rows (5, 12, 30), on InMemoryDriver,
SqlDriver on SQLite and SqlDriver on a local PostgreSQL 16.13 server:

| position | comparand on a `number` field | base `3062e5001`: memory ·
SQLite · PostgreSQL | this branch, all three |
|:--|:--|:--|:--|
| `where` | `$gt` / `$eq` / implicit / a `$in` member `"abc"` | 200 no
rows · 200 no rows · 500 `DATABASE_ERROR` | 400 `INVALID_FILTER` |
| `where` | `$ne "abc"` | every row · every row · 500 | 400 |
| `where` | `$eq ""` | no rows · no rows · 500 | 400 |
| `where`, REST | `$gt "{current_user_id}"` (resolved to the user's id)
| no rows · no rows · 500 | 400 |
| per-aggregation `filter` | `$gt "abc"` / `$ne "abc"` | count 0 / count
3, on all three | 400 |
| `having` on `sum(amount)` | `$gt "abc"` / `$ne "abc"` | no group /
every group, on all three | 400 |
| `where` | `$gt "12"` / `$eq "12"` | **no rows** · 1 row · 1 row | 1
row on all three |
| all three positions | `$gt 10` (the numeric control) | 2 rows / count
2 / both groups | the same |

The last-but-one row is the narrowing's point: InMemoryDriver compared
`"12"` as a string and matched nothing.

## Premise check, and the order's hypotheses

- **H1 holds, reproduced at `3062e5001`** (the table above). SqlDriver's
server-side log line on PostgreSQL reads `(22P02) … invalid input syntax
for type numeric: "abc"`.
- **H2: the collection point is where the order says**, and the new door
sits after the temporal door at each call. `judgeFilter` passes through
it: `judgeWhereAdmission` calls `lowerWhereFilterArray` (pinned:
`judgeFilter` answers `INVALID_FILTER` / 400 for `"abc"` and `{ ok: true
}` for `"12"`). **RLS / sharing / tenant predicates do NOT pass through
it at runtime.** The middleware chain composes them onto the AST after
this seam, and `plugin-security`'s `judgeCompiledComparands` runs only
the two field-agnostic faces (`rls-compiler.ts`, the `[objectstack-ai#20212]` block).
A policy predicate reaches this door at authoring instead:
`validateRlsPredicateEnforceability` asks the engine's `judgeFilter`
when the host hands the rule a judge.
- **H3 holds.** The verdict is `numberComparandDoorVerdict` over
`NUMBER_COMPARAND_DOOR_JUDGED_TYPES` with the scalar and list operators,
and the words are `numberComparandRefusalMessage`. A numeric string is
**narrowed** to its number (the verdict's `narrows`, as the contract
review's judgment 7 asks). The pins assert the rewritten filter the
driver receives, not only the 400s.
- **H4: MySQL is NOT MEASURED.** No MySQL server is available in this
container. The REST suite carries a MySQL cell, a named skip without
`OS_TEST_MYSQL_URL`.
- **H5: neither consults the same verdict everywhere.**
- `service-analytics`: the ObjectQL strategy sends the caller's `where`
into `engine.aggregate` and asks `judgeFilter` about the read scope
(`assertReadScopeAdmittedByEngine`), so both inherit the door. The
**NativeSQL strategy's decline** (`NativeSQLStrategy.canHandle`)
declines a cross-field reference and an uninterpretable temporal
comparand, but does not consult the number verdict. So a raw-SQL
deployment compiles `amount > 'abc'` itself (read at source, not
measured).
- **The metadata save door:** RLS `using` is judged through
`judgeFilter`, as above. No lint rule reads `numberComparandDoorVerdict`
(`git grep` over `packages/lint/src` finds zero hits), so a stored view
or report filter comparing a number field with a non-numeric string
saves clean and is refused at query time.
  
  Both are reported as findings below and are not edited here.

## The staged `$empty` row: pinned at the door alone

`NUMBER_COMPARAND_DOOR_CASES` carries PR objectstack-ai#20442's `unjudged` `$empty`
row. The engine suite partitions it out of the end-to-end drive and pins
it at the door alone: `findNonNumericComparand` answers `null`, and
`narrowNumberComparands` returns the same reference. A partition guard
asserts the table is split exactly. So the row can neither turn this
suite red for a reason that is not the door's, nor vanish unnoticed.

The contract's `formula` rows are partitioned the same way the text
door's suite does it: they are pinned in the direction they answer
(`INVALID_FIELD` / 400 from the objectstack-ai#8296 materializable door, one door
earlier). The door's own walk is pinned to judge `f_formula_number` by
its `returnType`.

## Tests (at `09da7a4cc`, the merged head, unless noted)

- **New:
`packages/objectql/src/engine-number-comparand-declared-type-door.test.ts`,
29 tests.** It drives the contract's case table through a real
`ObjectQL` and a recording driver, per the contract header:
- of the table's 137 cases, 51 refusals (the 52nd is the
`f_formula_number` row), asserting `code` + `status` + `httpStatus`,
every `mustMention` substring, and no driver read. All 8 refusal forms
and every judged position are covered, both ways;
- 23 `narrows` cases, asserting the driver receives `c.expectedFilter()`
and the caller's filter is untouched;
  - 57 `passes` cases, reaching the driver unchanged;
  - the formula (5) and `$empty` (1) partitions above.
  
  Beside the table:
  - every verb (read and write, no read and no write on refusal);
  - `FilterArray` sugar, both refused and narrowed;
  - `$and` / `$or` / `$not`;
  - a placeholder refused unresolved;
  - `judgeFilter`;
- the per-aggregation `filter`, refused at its path, with numeric
strings counting what their numbers count;
- `having` on `count` / `sum` / a numeric `min`, refused, narrowed, and
a placeholder on `count`;
- the four `findData` doors (`where` object, `$filter`, filter AST,
implicit query parameter), both ways;
- the registry-less, unknown-key, by-reference and
unrecognised-combinator guards.
- **New: `packages/rest/src/data-number-comparand-door.test.ts`.** It
runs `POST /api/v1/data/:object/query` and `engine.find` /
`engine.aggregate` over SqlDriver, with a cell per dialect:
  - `where`: 9 refused spellings;
  - the per-aggregation `filter`;
- `having` on `sum` and `max(currency)`, on the native and the rows
path;
- numeric-string controls, equal to their numbers at all three
positions.
  
The SQLite cell always runs. The PostgreSQL cell ran against the local
server: 3/3 passed at `09da7a4cc`. ⚠️ **No CI job provisions
`OS_TEST_POSTGRES_URL` for `@objectstack/rest`.** The `Temporal
Conformance (live PG + MySQL)` job runs `driver-sql`'s suite,
`metadata-protocol`'s `live-*` files and one `runtime` file, and a
`driver-sql`-only pin cannot reach an engine door. So the live cells are
red-capable and un-run in CI; the local run above is their measurement.
- **Re-pinned, test side only.** Four existing pins asserted the old
silent answer for a string on a numeric column:
- `engine-aggregate-having-temporal-door.test.ts`: the three "a string
on sum / count / avg keeps no group" rows move to a refusal pin in the
number door's words;
- `engine-aggregate-positions.test.ts`: the "unknown token on count" row
moves to a text column, which neither field-aware door judges, and the
count-column case is pinned in the new suite;
- `rest-aggregate-numeric-having.test.ts`: three rows move from `KEPT`
to a `REFUSED` table, SQLite and PostgreSQL both run locally;
- `data-query-having-temporal-door.test.ts`: "a string on sum" becomes a
number control plus a refusal pin.
- `pnpm --filter @objectstack/objectql exec vitest run --project local
--maxWorkers=2`: 330 files, 6118 tests passed. `--project repo`: 1 file,
5 passed.
- `pnpm --filter @objectstack/rest exec vitest run --project local
--maxWorkers=2`: 219 files, 3930 passed, 40 skipped. `--project repo`: 1
file, 8 passed.
- The live PostgreSQL run of the two PostgreSQL-capable REST files: 30
passed (15 live-postgres), 15 skipped (MySQL).
- `pnpm --filter @objectstack/objectql typecheck` and `pnpm --filter
@objectstack/rest typecheck`: exit 0. `check:test-typecheck` is OK for
both, with no debt added (objectql 40 files / 234 errors held; rest 0 /
0).

## Ablation (reverse verification)

The mutation is in the door's walk, which every position routes through:
`if (!meta || numberComparandFieldVerdict(meta) !== 'judged') continue;`
→ `if (meta || 'ABLATION_20351') continue;`. It is made with
`scripts/ablation-replace.mjs`: anchor 1 → 0, and blob `aa4a3247` →
`56a5bdf6`.

- **Mutated leg:** after `pnpm --filter @objectstack/objectql build`,
`ablation-dist-preflight` found the marker in 4 built files. The
objectql door suite went **20 failed / 8 passed**; the 8 are the guards
and partitions that do not depend on the door firing. The REST door
suite went **6 failed / 3 skipped**. The SQLite cell answered `200` with
`records: []`, the PostgreSQL cell `500 DATABASE_ERROR`, and the
per-aggregation `$in ["5","30"]` counted 0 instead of 2: the card's
defect, back.
- **Restore leg:** the blob is back to `aa4a3247` = HEAD and `git diff
HEAD` is empty. After a rebuild, `ablation-dist-preflight --absent`
found the marker absent from all 14 built files and the tree clean. Both
suites passed again (28/28 and 6 + 3 skipped at that commit,
`872d7708b`).

## Gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at **`09da7a4cc`** derives 65 commands, the
same list as at the first merged head. All 65 ran with each exit code
recorded before any pipe:

- 63 exited 0 on the first pass;
- `check:dual-build-cjs-loads` and `check:type-check-debt` answered exit
3 (PREREQUISITE NOT MET) until the whole workspace was built (`turbo run
build --filter=!@objectstack/docs`, 72/72), then exited 0.

`dispatch-gates --ran`: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. The
branch merged `origin/main` twice with true merge commits, no rebase and
no force-push; the last merge base is `45f428d8f`.

## Acceptance notes

- **`having` words.** A numeric aggregated column has no declared
`FieldType`, so the door hands the verdict `number` (the member of the
numeric class the column holds). The spec's words then read "compares a
declared number field against … at having.total.$gt". The `not-a-number`
clause ("backends answer it differently (PostgreSQL with a server
error)") is the `where` fact: `having` is evaluated by the engine on
every driver, and there it kept no group, or every group under `$ne`.
The words are the contract's, and the path names the position.
- **Out of the contract, measured, unchanged:** a boolean or a `Date`
compared against a number field is not judged (the contract judges
strings). `$gt true`: no rows on memory, every row on SQLite, 500 on
PostgreSQL. A `Date`: no rows · no rows · 500. Both hold on the base and
on this branch. Handed to the seat below.
- **Not measured:** MySQL (no server in this container);
`driver-mongodb` (the door sits in front of it); the NativeSQL analytics
path (read at source).
- **Line budget:** n/a (no `skills/**` path in the diff).

## Out of scope, handed to the seat (not filed by this dev)

1. **Class (a), reach measured at REST.** A boolean or a `Date`
comparand against a number field answers `500 DATABASE_ERROR` on
PostgreSQL. It is `POST /api/v1/data/:object/query` with `where: {
amount: { $gt: true } }` against a `number` field, on a local PostgreSQL
16 server, on the base and on this branch. The contract review of PR
objectstack-ai#20414 said to file this only if it answered 500; it does. Dedupe words:
`boolean comparand number field postgres 500` · `Date comparand numeric
column database_error` · `non-string comparand declared number type`.
2. **Carrier: none. Noted, not filed (read at source, reach not
measured).** `NativeSQLStrategy.canHandle` does not consult the number
verdict, so a raw-SQL analytics deployment does not fall through to this
door. Dedupe words: `native sql decline number comparand` · `analytics
raw sql non-numeric string`.
3. **Carrier: none. Noted, not filed (read at source, no named
producer).** No authoring rule reads `numberComparandDoorVerdict`, so a
stored view or report filter with a non-numeric string on a number field
saves clean and is refused at query time. Dedupe words: `stored view
filter non-numeric number field lint` · `authoring number comparand
verdict`.
4. **Carrier: none. Noted, not filed.** The runtime RLS compile
(`judgeCompiledComparands`) does not consult the number verdict. The
authoring judge does, when present. Dedupe words: `rls compiled
predicate number comparand` · `policy using string against number
field`.

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

---------

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

Projects

None yet

2 participants