Skip to content

fix(core,objectql)!: a temporal comparand is refused exactly when the write door refuses it — a real calendar day, an ISO datetime spelling, and a time instant with a four-digit UTC year - #20668

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20549-comparand-door-real-day-iso
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20549-comparand-door-real-day-iso

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20549
Fixes #20480

Clause-②: no (narrowing)

A family PR: two cards, one branch, one changeset, one commit per card.

Measured head: 0adb1bf84, rebased onto origin/main 19fc8d6f1. The two commits since main touch no file that main moved.

The change

@objectstack/core — packages/core/src/utils/temporal-comparand.ts

  • Moved, not copied. ISO_DATETIME_WRITE_FORM and namesRealCalendarDay leave record-validator.ts and sit beside readsAsCalendarDay. Both stay module-private, so there is no new root export.

  • readsAsCalendarDay (the date reading) now also requires the leading day to exist.

  • readsAsInstant (the datetime reading, which a time column also uses for an instant) now requires three things:

    • one of the ISO spellings;
    • a real leading day;
    • an instant that Date.parse reads.

    The bare-integer-string arm is gone (see H3). Epoch milliseconds as a NUMBER are untouched.

  • (core temporal rule: a time comparand spelled with an extended-ISO year (+010000-01-01T10:00:00Z) is compared as text on memory and SQLite: $gt answers 3 of 3 rows, $lt 0, where the same wall clock as a 2026 instant answers 2 / 1 #20480) There is a new private helper, keepsTimeOfDay(value). It asks temporalStorageForm(value, 'time') itself whether the rule keeps an HH:MM:SS time of day. The time arm now refuses an instant string the rule hands back unchanged. So does a finite number or a valid Date, which the time arm never judged before.

    • Year 0 spells 0000-…, so it is still read.
    • NaN, Infinity and an Invalid Date stay unjudged.

@objectstack/objectql

  • validation/record-validator.ts (the write door). The date / datetime arm is now readable && !isUninterpretableTemporalComparand(t, value).

    • Its private copies are deleted.
    • readable stays on top. It is the one place the two doors differ, on purpose: a NUMBER is refused as a written value but read as a comparand.
    • The time arm is not in the diff.
  • temporal-comparand-door.ts. The verdict is unchanged: core's predicate, INVALID_FILTER / 400, naming the field, before any read. The refusal text changes only as far as the new classes need, because "compare false for EVERY row" is untrue for them:

    Each has a where and a having sentence. The remedy now says "on a calendar day that exists" and "epoch milliseconds as a number". The junk class and the year class keep their exact words.

Measured, before and after

Through engine.find over InMemoryDriver, SqlDriver on SQLite, and SqlDriver on a live PostgreSQL 16.13. The process ran under TZ=America/New_York, and the server at Asia/Shanghai. Base is cd901d7a5; after is 0adb1bf84. Rows are r1 2026-03-02T10:00Z / 09:00, r2 2026-07-15T14:00Z / 10:30, and r3 2028-02-29T10:00Z / 12:00.

comparand base: memory / SQLite / PostgreSQL after, all three
datetime $eq "2026-02-30T10:00:00Z" [r1] (rolled over) / [r1] / [r1] 400 INVALID_FILTER
datetime $eq "07/15/2026 10:00", "2026/07/15 10:00" [r2] (process zone) / [r2] / [r2] 400
date $eq "2026-02-30" [] / [] / 500 DATABASE_ERROR 400
time $gt "+010000-01-01T10:00:00Z" (the #20480 card) 3 of 3 / 3 of 3 / 500 400
time $lt the same [] / [] / 500 400
time $gt "9999-12-31T23:00:00-02:00" (UTC year 10000) [] / [] / 500 400
time $gt the number or Date of +010000-01-01T10:00Z [] / 3 of 3 / 500 400
time $gt "07/15/2026 10:00" [] (14:00 UTC, the process zone) 400
datetime $gt "2026" every row (read as 2026 epoch ms) 400
datetime $gt 1769940000000 (a number) 3 of 3 unchanged, 3 of 3
controls: leap day 2028-02-29 on date and datetime; ISO Z, +08:00 and zone-naive "2026-07-15 14:00"; time $gt / $lt "2026-07-15T10:00:00Z"; time $gt "10:00" [r3], [r3], [r2]×3, [r2,r3] / [r1], [r2,r3] identical

After commit 1 alone, the #20480 card's literal row already read 400 on all three, because it is not an ISO spelling. The UTC-year-10000 ISO spelling and the number and Date spellings still answered as at base. The second commit closes those (see H1).

PM hypotheses — which held

  • H1: partly held. Tightening readsAsInstant to the ISO form refuses the card's +010000-… spelling as a side effect (measured after commit 1). It does not close the class: 9999-12-31T23:00:00-02:00 is an ISO spelling whose UTC year is 10000, and the number and Date never reached the string arm. So time needs its own arm, which is commit 2.
  • H2: held. The validator calls core's one predicate for both arms and deletes its copies. No root export was added: export * from temporal-comparand.ts exposes exactly the three symbols it did before. Clause-② stays no (narrowing).
  • H3: measured. One legitimate difference is kept, and one is removed.
  • H4: held. service-analytics/src/comparand-shape.ts is not edited. Its judgeTemporalLiterals calls core's predicate, so the raw-SQL decline moves with it. The whole suite is green on 0adb1bf84: 135 files, 3171 tests, under TZ=America/New_York. No pin flipped.
  • H5: nothing to carry. Core's namesRealCalendarDay stays private, so there is still nothing for packages/rest/src/import-coerce.ts to read in place of its own copy.

Compile faces — one conclusion each

The door is the engine's single filter collection point, in front of every driver. The pins show zero driver reads on every refusal: a recording driver in objectql, and the REST door over SQLite and PostgreSQL.

  1. driver-sql applyFilterCondition, with driver-sqlite-wasm and turso LOCAL: already compliant, behind the door. Measured: the refusal happens before any read on SQLite and PostgreSQL. The driver's deliberate pass-through (temporalFilterValue('t','at','not-a-date')) is unchanged.
  2. turso RemoteTransport.buildWhereSQL: already compliant, behind the same door. Not measured, because no turso server was available.
  3. service-analytics compileScopedFilterToSql (RLS read side): explicitly out of scope. It compiles the platform's injected RLS predicate, which the door never judges by design (the door's docblock: "an injected read filter is the platform's own").
  4. service-analytics lowerAnalyticsWhere: changed by inheritance. A comparand core now refuses is declined off the raw-SQL strategy to the ObjectQL strategy, whose engine.aggregate passes the door. The suite is green, as in H4.
  5. formula matchesFilterCondition: explicitly out of scope. It evaluates authored RLS check predicates and formula conditions, not a caller's where. Its own 2026-02-30 text-fallback pin (matches-filter.test.ts:180) is unchanged.

Pins

#20549 (commit 1):

  • core temporal-comparand.test.ts, a new describe:
    • impossible days on date and datetime (plus the datetime day part) are refused, and leap days and month ends are read;
    • 15 non-ISO datetime spellings are refused, each shown Date.parse-readable (or a bare integer) as the control;
    • 14 ISO spellings are read;
    • T25:00 and +99:99 are refused;
    • the date reading of an instant on a real day is kept;
    • the time instant half is covered;
    • the exemptions (empty string, blank, {today}, an epoch-ms number, a Date) are kept.
  • objectql engine-temporal-comparand-door.test.ts, a new describe:
    • 14 comparands × $eq / $gt / $in member → INVALID_FILTER / 400 naming the field, with zero reads, and not the junk class's words;
    • the leap day and ISO controls in the same it;
    • the per-aggregation filter and having positions;
    • a one-rule corpus pin: over 32 strings × 2 fields, a string is refused as a comparand exactly when engine.insert refuses it with invalid_date;
    • the number difference, asserted.
  • rest data-temporal-write-real-day-iso.test.ts, beside the write door's twin rows: POST /api/v1/data/:object/query answers 400 INVALID_FILTER for the card's values with no read, and the leap and ISO controls find their rows, on SQLite and on live PostgreSQL.
  • Driver halves:
    • sql-driver-20264-temporal-year-range.test.ts gains a leap row and an it over 7 ISO spellings, on the dialect matrix CI's Temporal Conformance (live PG + MySQL) job runs;
    • memory-20525-temporal-write-real-day-iso.test.ts gains comparand controls under America/New_York.

#20480 (commit 2):

  • core: a new describe covers the card's spelling, UTC year 10000 and year -1, numbers and Dates, and the Date-range extremes, all refused. The 2026 control and year 0 are read. An agreement pin: refused exactly when temporalStorageForm(v, 'time') === v.
  • objectql engine-temporal-comparand-door.test.ts: five spellings × $gt / $lt / $eq at where, plus the per-aggregation filter and having, with zero reads, beside the 2026 control and year 0.
  • engine-aggregate-having-temporal-door.test.ts: two refused rows, and two 2026 controls on max(time).
  • rest: a time field on SQLite and live PostgreSQL. The card's instant, the UTC-10000 ISO spelling and the number answer 400, with no read. The 2026 instant, the offset and the number answer 2 / 1.
  • Drivers:
    • sql-driver-time-live-dialects.test.ts gains the 2026 control in five spellings, on live PostgreSQL and MySQL (CI's temporal job);
    • memory-temporal-storage-form.test.ts gains four 2026 control rows.

Flipped pins, one round (sweep ①). Each keeps a load-bearing assertion of the new semantics:

The repo-wide sweep found no other same-semantic pin, re-run on 0adb1bf84. It covered quoted 4–14 digit strings under a filter operator on a temporal field, slashed or worded datetime comparands, and extended-year comparands on time fields, over every *.test.ts. Truly illegal shapes keep their refusal assertions verbatim.

Verification, on 0adb1bf84

Every suite below ran under TZ=America/New_York. PostgreSQL cells ran against a live PostgreSQL 16.13 at Asia/Shanghai.

NOT MEASURED:

  • MySQL cells: not provisioned in this container. They run in CI's Temporal Conformance (live PG + MySQL) job, in sql-driver-time-live-dialects.test.ts and sql-driver-20264-temporal-year-range.test.ts.
  • turso remote and MongoDB: no server.
  • The PostgreSQL cells in packages/rest ran locally, but no CI job provisions PostgreSQL for that package. CI's PostgreSQL coverage of this card is the driver-sql half above.

Acceptance notes (not filed)


Generated by Claude Code

…te door refuses — a real calendar day, and an ISO spelling for a datetime

The record validator's two private predicates, `namesRealCalendarDay` and
`ISO_DATETIME_WRITE_FORM`, move into `@objectstack/core`'s
`temporal-comparand.ts` beside `readsAsCalendarDay`, as a move with no
second copy. `isUninterpretableTemporalComparand` now reads a `date`
string only on a real leading day, and a `datetime` string only in an ISO
8601 spelling on a real day. The record validator's `date` / `datetime`
arm asks that one rule (with `Date.parse` readability kept on top, which
also keeps a number refused as a written value), so a string is refused
as a comparand exactly when it is refused as a written value.

The engine's temporal-comparand door (`where`, a per-aggregation
`filter`, `having`) and the analytics raw-SQL decline read the predicate,
so each now refuses `"2026-02-30T10:00:00Z"`, `"07/15/2026 10:00"`,
`"2026/07/15 10:00"`, `"2026-02-30"` on a date, and a bare integer
string, with INVALID_FILTER / 400 naming the field, before any driver
read. The door's refusal says why in the class's own words: a misread
value answers the wrong rows, it does not compare false for every row.
Epoch milliseconds stay a comparand as a number.

Measured before, TZ=America/New_York, engine.find over InMemoryDriver,
SqlDriver on SQLite, and SqlDriver on PostgreSQL 16 at Asia/Shanghai:
the datetime rows matched the rolled-over / host-zone row on all three,
and the date row answered 200 [] on memory and SQLite and 500 on
PostgreSQL. After: 400 INVALID_FILTER on all three; the 2028-02-29 leap
controls and the ISO controls answer the same rows as before.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
… UTC year is refused, in every spelling

A `time` column reads a comparand that is not a bare wall clock as an
instant, by the `datetime` rule, and keeps its UTC time of day only when
that instant spells a four-digit year. Any other it handed back as
written, so the driver compared `+010000-01-01T10:00:00Z` (or its number,
or `9999-12-31T23:00:00-02:00`, year 10000 in UTC) with stored `HH:MM:SS`
text. `isUninterpretableTemporalComparand('time', …)` now asks the
storage rule itself whether it keeps a time of day, for an instant string
the `datetime` reading admits and for a finite number or valid `Date`.
The temporal-comparand door refuses such a comparand with INVALID_FILTER /
400 at `where`, a per-aggregation `filter` and `having`, in the class's
own words. No time of day is read from an extended year. Year 0 spells
four digits and keeps its reading; NaN, Infinity and an Invalid Date stay
unjudged.

The first commit already refused the card's own spelling as a side
effect (it is not one of the ISO forms); this commit closes the class:
the four-digit spelling whose UTC year is 10000, and the number and
`Date` spellings, which the `time` arm never judged.

Measured before, TZ=America/New_York, rows 09:00 / 10:30 / 12:00:
`$gt "+010000-01-01T10:00:00Z"` answered 3 of 3 on memory and SQLite
and 500 on PostgreSQL 16; the number of that instant answered 0 on
memory, 3 on SQLite and 500 on PostgreSQL. After: 400 INVALID_FILTER on
all three. The 2026 control (`$gt` / `$lt "2026-07-15T10:00:00Z"`)
answers 2 / 1 on all three, before and after.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

16 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
  • 5 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 6bff748bbd484f00906c8d9a58f5a3621a20c2e6 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6bff748bbd484f00906c8d9a58f5a3621a20c2e6

⚠️ 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: 0adb1bf84e39d365124b3ac710470808ee9ce4e7
Local-runs: none

Inputs read: cards #20549 and #20480 (bodies and every comment, the triage rulings, the claims, the os-dev-report and the seat answer 5894035143 included); PR #20668's body, its 15-file list and the net diff of refs/review/20668 against origin/main (merge-base 19fc8d6f1; origin/main has since moved to 6c11ef9ecb, and none of the 15 files, nor packages/core/src/utils/, packages/objectql/src/ or service-analytics/src/comparand-shape.ts, moved with it); and the head's check-runs. Where a claim is about origin/main, I read the file there with git show.

① Derived judgments

Accept-set changes the diff implies — each judged right.

  1. date string comparand: a leading YYYY-MM-DD must now name a real day (readsAsCalendarDay is namesRealCalendarDay). Right — it is the write door's own predicate (record validator: the temporal write arms trust Date.parse — date 2026-02-30 is stored verbatim (500 on PostgreSQL), datetime 2026-02-30T10:00:00Z rolls over to March 2, and a non-ISO datetime is read in the host zone #20525), moved not copied: the two private functions are deleted from record-validator.ts and reappear byte-equal in core, module-private. Pinned in core (7 impossible days, 7 real days including 0004-02-29 and 2000-02-29) and at the engine.
  2. datetime string comparand: only an ISO_DATETIME_WRITE_FORM spelling on a real day that Date.parse reads; the bare-integer-string arm of readsAsInstant is deleted. Right by the governing text (triage 5883337906: "the comparand door refuses what the write door refuses"; the claim's "datetime: only the ISO 8601 spelling the write door admits"). On origin/main the write door already refused every bare integer string and every non-ISO spelling, so the narrowing is exactly the write door's set. Epoch milliseconds as a NUMBER are untouched on date/datetime (the pre-existing year-range judgement only), and the corpus pin (32 strings x 2 fields, refused as a comparand iff engine.insert answers invalid_date) holds the two doors to one rule.
  3. time column, string comparand (temporal comparand door admits what the write door now refuses: an impossible day (2026-02-30) is rolled over as a datetime comparand and is a 500 on PostgreSQL as a date comparand, and a non-ISO datetime comparand is read in the host zone #20549 commit, an alongside narrowing): a non-wall-clock string is read through the same readsAsInstant, so a non-ISO or impossible-day instant is refused on time too. Judged right, see ③ item 2.
  4. time column, every spelling (core temporal rule: a time comparand spelled with an extended-ISO year (+010000-01-01T10:00:00Z) is compared as text on memory and SQLite: $gt answers 3 of 3 rows, $lt 0, where the same wall clock as a 2026 instant answers 2 / 1 #20480): a finite number, a valid Date or an ISO instant string is refused when temporalStorageForm(v, 'time') hands it back unchanged (keepsTimeOfDay). Right — on origin/main canonicalTimeOfDay keeps a time of day only from an instant whose toISOString() spells YYYY-, so the predicate asks the rule rather than copying its year arithmetic; year 0 spells 0000- and stays read (pinned three ways); NaN/Infinity/Invalid Date stay unjudged. 9999-12-31T23:00:00-02:00 is UTC year 10000, as the changeset says. The card's +010000-01-01T10:00:00Z would already have fallen to item 2 alone, which is why the second arm was needed for the number, the Date and the offset spelling (H1 partly held, correctly reported).
  5. Write door (record-validator.ts date/datetime arm): readable && !isUninterpretableTemporalComparand(t, value). Judged equivalent to the old four-term guard on every input: for a string, old = Date.parse(raw) finite AND ISO form (trimmed) AND real day AND year in range; new = the same three plus Date.parse of the same string after the rule's own ISO normalisation, which cannot flip a string Date.parse already read. The rule's two comparand-only exemptions (blank, {token}) are stopped by readable as before; a NUMBER stays refused by readable — the one deliberate difference between the doors, now asserted. The changeset's "every written value answers exactly as before" is true. The time arm is untouched (verified: not in the diff).
  6. Refusal prose (temporal-comparand-door.ts): three misread classes on where and having, ordered year class, then misread, then junk. Each sentence judged true for every hit it can be shown on: the impossible-day class only fires when the leading day is refused and its year is in range; the not-ISO class only when Date.parse reads the string or it is a bare integer (so "the server's own parser decides the instant" is what did happen); the time year class only when core's isOutsideTemporalYearRange on the instant says so, and year 0 on time is never a hit, so "outside the years 0001 to 9999, so no time of day is read from it" never misstates. The datetime remedy now says "on a calendar day that exists" and "epoch milliseconds as a number"; both true. The junk class keeps its exact words (pinned by not.toContain('compare false for EVERY row') on the new classes).
  7. Public surface: packages/core/src/index.ts does export * from temporal-comparand.ts, whose exports are the same three symbols on origin/main and on the head (TemporalComparandKind, temporalComparandKind, isUninterpretableTemporalComparand, unchanged signatures). The door module is not re-exported from objectql's root; its four exports and the UninterpretableTemporalComparand members are unchanged; record-validator.ts loses only private symbols. No new root export, no signature change.

Compile faces — one conclusion each, and each checked. All eight faces the dispatch named carry a conclusion in the PR body.

  • driver-sql applyFilterCondition (with sqlite-wasm and turso local): behind the door — right; the engine runs assertTemporalComparandsInterpretable before any driver read, pinned by the recording driver (zero reads) and the REST door over SQLite and PostgreSQL.
  • turso RemoteTransport.buildWhereSQL: it exists here (packages/drivers/driver-turso/src/remote-transport.ts:1168) and is reached through TursoDriver behind the same door; "already compliant, not measured" holds.
  • service-analytics compileScopedFilterToSql: out of scope — right; it compiles the injected RLS predicate, which the door's docblock on origin/main (lines 388-389: "an injected read filter is the platform's own") excludes by design.
  • service-analytics lowerAnalyticsWhere: changed by inheritance — right; NativeSQLStrategy.canHandle (native-sql-strategy.ts:297) calls findUninterpretableTemporalMember, whose judgeTemporalLiterals (comparand-shape.ts:473) calls core's predicate, so the decline moves without an edit there (H4).
  • formula matchesFilterCondition: out of scope — right; it evaluates authored RLS check predicates, and its own impossible-day text-fallback pin ("an impossible day is not rolled over", matches-filter.test.ts ~line 180) is on origin/main and not in the diff.
  • objectql having-filter (applyHaving/matchesHaving): changed — right; assertHavingTemporalComparandsInterpretable runs at engine.ts:16488 ahead of them and gained the misread branch; pinned in two files.
  • driver-memory checkCondition: behind the door, measured — right.
  • driver-mongodb translateFieldOperators: behind the door, not measured — acceptable (see ③ item 5).

Producer sweep. I searched non-test source on origin/main for a temporal where built from Date.now()/getTime() as a string or from toLocale*String(): none. filter-tokens.ts emits toISOString() text. The five flipped pins are the only in-repo readers of the retired spellings, and each keeps a load-bearing assertion of the refusal (code and status, no read).

Nits, not failing. The changeset's "Who is affected" paragraph says "a JS Date and an epoch-millisecond number are unchanged" — true within that paragraph's stated scope (date/datetime fields), incomplete for time, where the same changeset's fourth bullet and the table's last row state the number/Date refusal. The PR body names the measured base as cd901d7a5 while the test docblocks say f1e921ab8e; neither is shipped prose and the after-cells are what the pins hold. The dev's theoretical edge (a year-0 instant in a non-ISO spelling on time would be named by the year class rather than the spelling class) is a message-class edge with the right verdict.

② Semver level

  • Changeset .changeset/20549-comparand-door-real-day-iso.md: @objectstack/core: minor, @objectstack/objectql: minor, a **BREAKING** banner, Clause-②: no (narrowing), and one adr-0087: not-required (no-migration-prescription) marker. The two packages whose source changed are exactly the two bumped; rest, driver-memory, driver-sql are test-only in this diff, and service-analytics changes by inheritance with no source edit. Right.
  • Level: an accept-set narrowing is BREAKING; check-changeset-no-major.mjs refuses major until GA (its header, Record the launch-window versioning convention's end condition — post-GA the fixed group returns to strict semver #14043), so minor plus the banner is the convention the repo runs. The changeset's sentence saying so is true.
  • Clause-②: no (narrowing): judged no — per ① item 7 there is no new root export and no published-signature change; the arm is (narrowing) because isUninterpretableTemporalComparand's accept set shrinks. The PR body and both claims carry the same line.
  • ADR-0087 disposition not-required (no-migration-prescription): substantively right (no authorable key, export or config field is removed or renamed; a filter VALUE class is refused; packages/spec untouched; no stored row read or rewritten), and mechanically the changeset carries no prescription in any arm the gate reads: no FROM/TO label, no arrow line, no migration-framed heading (the body has no headings), no retirement framing, and its one table's header (where, memory, SQLite, PostgreSQL, "now, on all three") has no OLD_COLUMN_RE cell, so it is not header-framed. The remedy line "send the number, or an ISO instant" is a sentence, not a rewrite frame.
  • Shipped CHANGELOG.md prose: every factual sentence judged true against origin/main and the diff — the ISO grammar matches the regex; the refused examples are each pinned; "2026-07-15 10:00:00+08:00" is outside the space form (no zone), so "write it with a T" is right; the before-cells for the three card rows are the cards' own measurements; the time number row's before-cells ([] memory, 3 of 3 SQLite, 500 PostgreSQL) follow from the drivers on origin/main (JS number-vs-string compares false; SQLite orders every TEXT above INTEGER; PostgreSQL has no operator comparing time with an integer); the datetime $gt "2026" row follows from instantMs reading a bare integer string as epoch ms; the Unchanged list is pinned (leap day, ISO spellings across two zones, date leading-day reading, the 2026 time control in five spellings on memory, SQLite and live PostgreSQL/MySQL cells).

③ Boundary flags

  1. open_questions — bare integer strings (H3). The card thread holds the seat's answer, comment 5894035143: A, as implemented. Judged consistent with the governing text in my inputs: triage 5883337906 and the claim's surface both say the comparand door refuses what the write door refuses, and on origin/main the write door refused "2026" and "1769940000000" on both kinds; the two earlier pins were labelled "the control" in their own test text, not an accepted spelling. No repo producer exists (sweep above). Answered; not escalated.
  2. The time column's instant half narrowed alongside (commit 1). The claim names only the extended-year class for time. Judged right and inside the claim's direction: a time column reads a non-wall-clock comparand through the shared datetime reading, so keeping the old reading for time alone would need a second copy of the pre-temporal comparand door admits what the write door now refuses: an impossible day (2026-02-30) is rolled over as a datetime comparand and is a 500 on PostgreSQL as a date comparand, and a non-ISO datetime comparand is read in the host zone #20549 predicate, which the claim forbids ("⛔ no second copy"), and would leave the host-zone defect ("07/15/2026 10:00" as the process zone's time of day) open on one kind. Declared in the changeset (bullet 3), pinned in core, and the driver/REST controls hold. Answered; not escalated.
  3. Pins placed outside the spec conformance kit. The kit is packages/spec/src/data/temporal-conformance.ts; the claim's file surface excludes packages/spec, and the kit's own header says storage form is asserted in each driver's own suite. The pins follow the landed temporal values outside the years a four-digit text or a backend holds: a datetime comparand for year 10000 or −1 misorders on memory/SQLite and 500s on PostgreSQL; a date in year 0000 500s on PostgreSQL; a date write stores +010000-… verbatim #20264/record validator: the temporal write arms trust Date.parse — date 2026-02-30 is stored verbatim (500 on PostgreSQL), datetime 2026-02-30T10:00:00Z rolls over to March 2, and a non-ISO datetime is read in the host zone #20525 layout (engine recording driver, REST over SQLite and PostgreSQL, driver halves on the live-dialect matrix and memory). Answered: right.
  4. History rewritten with --force-with-lease before any PR existed. The five conditions: branch named claude/issue-20549-…; both commits on the branch carry one author and one committer identity, so nobody else pushed it; the PR did not exist at the time (opened afterwards, no reviewer or approval); the lease spelling is the dev's statement, which the diff cannot show, but the outcome — two clean commits on the merge-base with no foreign author — is what the rule protects. Answered.
  5. NOT MEASURED — MySQL, turso remote, MongoDB. MySQL cells run in CI's Temporal Conformance (live PG + MySQL) job, in progress when read; its conclusion is that cell's verdict. turso remote and MongoDB have no server here and both faces sit behind the door, whose refusal precedes any driver. The rest package's PostgreSQL cells ran locally with no CI job for them, as declared. Answered; recorded as declared.
  6. Two out-of-scope findings. (a) The write door's time arm is wider than the comparand door: verified on origin/main record-validator.ts:1330-1335 — hasDate is the unanchored /\d{4}-\d{2}-\d{2}/, which matches inside +010000-01-01, and timeOfDay admits a Z/offset suffix the time storage rule never reads. A class (a) finding with a measured reach (POST /api/v1/data/:object: 201 on SQLite, 500 on PostgreSQL). Not fixed in place was right — matching core's rule would also refuse offset wall clocks, a narrowing no triage pinned. Escalated to the seat: file it as its own card, the write-side twin of core temporal rule: a time comparand spelled with an extended-ISO year (+010000-01-01T10:00:00Z) is compared as text on memory and SQLite: $gt answers 3 of 3 rows, $lt 0, where the same wall clock as a 2026 instant answers 2 / 1 #20480, domain:engine. (b) H5 — nothing to carry; noted.

Check-runs on the head, read at 2026-09-29T16:20Z, not waited on: 31 runs — 13 success (Auto Label, Type Check · source gates, Type Check · debt ledger, Governed Surface Queue Guard, filter, Check Changeset, Check PR Size, Flag docs affected by code changes, Part-of PR must not also close its card, The card this PR closes must claim this branch, No other open PR may claim the same single-writer path, No other open PR may claim the same issue, Check Documentation Links); 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke); 15 in_progress (Build Core, Test Core 1/6 to 6/6, Temporal Conformance (live PG + MySQL), Dogfood Regression Gate 1/3 to 3/3, Dogfood Verify CLI, Type Check · consumer gates, Type Check · workspace, Lint & Repo Gates). No failure. The derived families still running (tests, lint and repo gates incl. check:adr-0087-registration, the live-dialect cells) are adjudicated by their own conclusions; this record judges the contract and does not stand in for them — the PR is not a governed-surface PR, and the seat arms it only once every check is green, as the ordinary rule says.

Implemented-by: claude/issue-20549-comparand-door-real-day-iso
Reviewed-by: session_01DEvba2nBuD4tWzfq8r8NFY

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 16:23
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 2473e26 Sep 29, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20549-comparand-door-real-day-iso branch September 29, 2026 16:44
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…rc to the commits that decided them (objectstack-ai#20673)

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

## What changed

This is stage 4 of the `domain:cli` lane of the dead-citation sweep:
`packages/types/src/**`. Every comment or docblock site in scope that
cited a tracker number answering 404 now cites, in ruling C+D's form C
(comment 5749154545 on objectstack-ai#19123), the commit in this repository's history
that decided what the line describes, and says in its own words what
that commit decided. PR objectstack-ai#20533 is the method; PR objectstack-ai#20624 (`runtime`), PR
objectstack-ai#20632 (`rest`) and PR objectstack-ai#20656 (`cli`) are the landed stages this
follows. The card stays open for the form-D stage and the rest of the
lane, so this PR says `Part of`.

That is **83 comment sites on 83 lines in 17 files, covering 12
numbers**: the census's 52 (all of them) and 31 more in test comments,
which the census defers. Each rewritten line cites one of **12 distinct
commits**. No ADR or ruling-record file records any of these twelve
decisions, so every anchor is a commit.

Only comments changed. Every touched file keeps its line count (94 lines
out, 94 in, over 17 files), so no line citation into these files moves.
Eleven of the 94 lines held no dead site; each is the other half of a
sentence that had to change: `thrown-http-error.ts:316-319`,
`node.ts:1103`, `:1429`, `:1447`, `:1452`, `:1476`, and
`node.test.ts:449`, `:2420` (see "Wordings to check").

**No citation number is added.** Over the 94 line pairs, every tracker
number on an added line was already on the line it replaces (per-pair
check: 0 added), and no PR number stands on an added line. No code token
moves (see the guard below). No site was left: no dead comment site in
scope lacked a deciding commit, and no open PR touches
`packages/types/src`.

One file outside `packages/types/src`: a `patch` changeset for
`@objectstack/types`, in PR objectstack-ai#20632's form and level.

## Census: `packages/types`, before and after

**Instrument.** The gate's own `node scripts/check-issue-citations.mjs
--census --json`, read-only and unchanged, run with the fleet token. Its
surface is comment prose in `packages/**/src/**/*.ts` with string
literals blanked, and it defers `*.test.ts`. The count is its
`allocated-but-absent` findings under `packages/types/`. Both runs
enumerated the whole board (185 pages), so neither read a truncated
board.

| reading | tree | board | whole-repo `allocated-but-absent` | types
sites | lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `6bff748bbd`, run 2026-09-29T15:31:59Z to 15:41:36Z |
enumerated, 185 pages, frontier objectstack-ai#20663, 18,490 numbers | 1,510 | **52**
| 52 | 7 | 11 |
| after | head `686a4c60cb`, run 2026-09-29T16:04:17Z to 16:12:39Z |
enumerated, 185 pages, frontier objectstack-ai#20671, 18,498 numbers | 1,458 | **0** |
0 | 0 | 0 |

The before count equals the card's 52 at `f11b5f20a2`: no drift. The
whole-repo drop is 52, exactly this diff's 52 sites, and the whole-repo
resolving count rises by one (32,882 to 32,883): the live objectstack-ai#12751 that
`index.ts:4` now spells so the grammar reads it. Both runs read this
worktree, the base and then the base plus this one commit, so no other
change entered either count.

**Supplementary instrument, the whole scope.** The census does not read
test files or strings, and this stage's scope includes test comments. So
a second reading runs the gate's own exported `extractCitations`
(whole-file and comment-prose projections) and `classifyCitation` over
every `.ts` file under `packages/types/src` (42 files), against a board
from the gate's own `probeBoard`. The lit controls objectstack-ai#20594, objectstack-ai#19123 and
objectstack-ai#20656 answered 200 and are on both boards; the dead controls objectstack-ai#11671,
objectstack-ai#10514 and objectstack-ai#14828 answered 404 and are on neither.

| reading | tree | board | citations | dead | src comment | test comment
| src string | test string |
|---|---|---|---|---|---|---|---|---|
| before, 15:34Z | `6bff748bbd` | probed, frontier objectstack-ai#20661 | 622 |
**101** | 52 | 31 | 1 | 17 |
| after, 16:04Z | `686a4c60cb` | probed, frontier objectstack-ai#20668 | 540 | **18**
| 0 | 0 | 1 | 17 |

Its src-comment column equals the census's 52, site for site (the two
site lists are identical), which is the control on the second
instrument. The drop of 82 citations is the 83 dead sites removed plus
one live number the grammar now reads: `index.ts:4` spelled
`[objectstack-ai#11343/objectstack-ai#12751]`, whose second half the grammar skips after a slash,
and now reads `[commit c0714eb / objectstack-ai#12751]` like its module doc, so the
live objectstack-ai#12751 is judged (resolving src comments 273 to 274). Resolving
pull requests (20), cross-repo citations (14) and the other resolving
counts are unchanged. A separate scan for slash-joined pairs in
`packages/types/src` found six (`objectstack-ai#11343/objectstack-ai#12751`, `objectstack-ai#3878/objectstack-ai#3899`,
`objectstack-ai#7525/objectstack-ai#8016`, `objectstack-ai#4728/objectstack-ai#4825`, `objectstack-ai#8621/objectstack-ai#8622`, `objectstack-ai#5352/objectstack-ai#5367`); every
second half answers 200, so no dead number hid behind a slash here.

## Per-number table

Sites and files are the dead comment sites in scope at the base, test
sites counted in brackets. `strings kept` counts string-literal sites,
which are tokens and stay as they were. Every anchor was read in its
message or its diff, not only its subject: it is the commit that made
the change the line describes.

| number | comment sites / files | rewritten | strings kept | anchor |
|---|---|---|---|---|
| `objectstack-ai#8824` | 1/1 (1 test) | 1 | 0 | `8ac232306` |
| `objectstack-ai#9934` | 5/4 (2 test) | 5 | 2 | `79c46da90` |
| `objectstack-ai#10943` | 10/2 (4 test) | 10 | 2 | `46d34ab7c` |
| `objectstack-ai#10944` | 1/1 | 1 | 0 | `e598b1cbc` |
| `objectstack-ai#11343` | 3/3 (1 test) | 3 | 1 | `c0714eb5d` |
| `objectstack-ai#12281` | 1/1 | 1 | 0 | `0783d7b80` |
| `objectstack-ai#13197` | 5/2 (2 test) | 5 | 2 | `56c093c4d` |
| `objectstack-ai#13279` | 8/5 (2 test) | 8 | 0 | `6a180e42d` |
| `objectstack-ai#13324` | 15/3 (7 test) | 15 | 5 | `4cda78c9b` |
| `objectstack-ai#15044` | 8/2 (3 test) | 8 | 1 | `088f761e5` |
| `objectstack-ai#15045` | 21/2 (8 test) | 21 | 1 | `288fe9c34` |
| `objectstack-ai#16657` | 5/2 (1 test) | 5 | 4 | `5a95b0e93` |
| **total** | **83** | **83** | **18** | **12 distinct commits** |

Every cited sha matches exactly one object (`git rev-parse
--disambiguate`, count 1 for each of the 12), is a commit, has one
parent, and is an ancestor of the base (`merge-base --is-ancestor`, exit
0 for all 12). The checkout is not shallow (`--is-shallow-repository`
false); the control leg `f5a9bc2f3` (2026-08-10, older than the oldest
anchor, `8ac232306` of 2026-08-15) exits 0 and the negative control
(this branch's own `686a4c60cb`, not on `main`) exits 1.

**Anchors reused from earlier stages**, so each number carries one
anchor across the tree: `79c46da90` for objectstack-ai#9934 (stages 1 and 2, the spec
lane), `46d34ab7c` for objectstack-ai#10943, `e598b1cbc` for objectstack-ai#10944 and `288fe9c34`
for objectstack-ai#15045 (stage 3), `0783d7b80` for objectstack-ai#12281 (stage 1), `56c093c4d` for
objectstack-ai#13197 (stage 2, the spec lane), `6a180e42d` for objectstack-ai#13279 (stages 1 and 2,
`plugin-sharing`) and `c0714eb5d` for objectstack-ai#11343 (`plugin-auth`).

**New anchors, and how each was found:**
- `objectstack-ai#8824` → `8ac232306`: objectstack-ai#8824 is that commit's own PR number (its
subject ends `(objectstack-ai#8824)`), so the sha is the object the number named.
`error-leak.test.ts:180` read 「PR objectstack-ai#8824 corrected the」 and now reads
「Commit 8ac2323 corrected the」.
- `objectstack-ai#13324` → `4cda78c9b`: its subject does not name the number, but its
changeset heading does (「require a missing-table error to name the table
that was READ (objectstack-ai#13324)」), and its diff adds `readObject` and every
`[objectstack-ai#13324]` marker this module carries. It landed in
`packages/metadata/src/utils/schema-sync-errors.ts`, the file
`6a180e42d` then moved here (a rename at 86 percent similarity).
- `objectstack-ai#15044` → `088f761e5`: 「Part of objectstack-ai#15044」, the only commit whose
message names the number; it made the objectstack-ai#13330 succeeding leg recognise
the package root by the name the declaration promises, and added the
`BOUNDARY` pin at `node.test.ts:1863` that `:2175` and `:2419` point at.
- `objectstack-ai#16657` → `5a95b0e93`: it added `operatorFacingErrorText`,
`DECLARED_DATABASE_FAULT_CODE` and the raw-path fragment, and its
message calls itself the fourth prose round on objectstack-ai#16657.

## Wordings to check

- **A stale future tense, corrected by its anchor.**
`thrown-http-error.ts:315-320` said objectstack-ai#12281 「is a separate card with its
own measurement-first step, so nothing here applies it; this function is
the shape it will read」. `a81aa9dd5` wrote that on 2026-08-29;
`0783d7b80` landed the next day and its message says 「the door now reads
`serverFaultProvenance`」. Citing the commit in the future tense would
contradict itself, so the six lines now read 「Commit 0783d7b — the
prose axis of the same 2026-08-27 ruling — reads the `'declared'` limb
of this same function … It landed separately, after its own
measurement-first step, so nothing here applies it; this function is the
shape it reads rather than a second copy it would have had to grow.」
- **An open question named by a number that had already landed.**
`node.ts:1447-1452` called where a relative specifier should resolve
from 「an open policy question owned by objectstack-ai#10944」 and ended with 「Answering
half of another card's undecided question」. `e598b1cbc` (objectstack-ai#10944's
landing) had merged 40 minutes before `46d34ab7c` wrote those lines, and
it refuses the relative spelling. The lines now read 「the policy
question commit e598b1c settled for `serve` (it refuses a relative
`plugins: [...]` entry rather than silently re-basing it)」 and
「Answering half of another change's question」.
- **「the card」 once the antecedent became a commit.**
`node.ts:1102-1103` 「objectstack-ai#15045 is the card about telling an operator which
one was measured」 now reads 「commit 288fe9c is the change that tells
an operator which one was measured」. `node.ts:1476` 「the second
verification axis the card holds open」 now reads 「the second
verification axis that commit left unbuilt」, which is what `288fe9c34`'s
message says (「deliberately not built here」). `node.test.ts:449` 「The
card's own 4-row matrix」 now reads 「The 4-row matrix behind that
commit」.
- **Headings that named a defect by its number now say so.**
`node.test.ts:1566` reads 「Fixed by commit 088f761: the SUCCEEDING leg
recognised the package by the DECLARATION KEY」 and `:1881` reads
「Reworded by commit 288fe9c: the location sub-case REFUSES correctly
and EXPLAINED itself wrongly」 (`288fe9c34` changed the wording and kept
the refusal). The dash-rule headings trim trailing dashes:
`node.ts:1381`, `node.test.ts:1566`.
- **A quoted triage.** `node.test.ts:2160` quoted 「objectstack-ai#15045's triage」; it
now reads 「quoted from the triage commit 288fe9c landed」. That
commit's changeset records the same decision in its own words: the key
stays the expectation 「because widening it would accept any directory
sitting at the key and trade a wrong REMEDY for a wrong LOAD」.
`node.test.ts:2053` said objectstack-ai#15045 「asked for this sentence」; it now says
`288fe9c34` 「wrote this sentence」, and that commit's own test comment
says the card asked for it.
- **A defect that proved a point.**
`driver-error-classification.callers.test.ts:24-25` said the omission is
the shape 「objectstack-ai#13324 existed to close」 and that prose 「is exactly what
objectstack-ai#13324 proved insufficient」; it now reads 「the … shape commit 4cda78c
closed」 and 「prose is exactly what that commit's defect proved
insufficient」.
- **「pre-#N」 spellings** (the card's control sites
`driver-error-classification.ts:608` and `node.ts:1428`, plus
`callers.test.ts:20` and `node.test.ts:594`) now say 「before commit X」.

## Mechanical guard: no code token moves

The check compares the TypeScript parser's leaf tokens (TypeScript
6.0.3, JSDoc nodes excluded, comments being trivia) of each touched file
at base `6bff748bbd` against the working tree at `686a4c60cb`, over all
17 touched files, and lists EVERY differing token, not only the first.
Controls mutate the head text in memory only, so nothing on disk moved
for them.

- Real run: 31,911 base tokens, token counts equal in every file, **0
differing tokens** (exit 0).
- Comment-insertion control (a new line comment in `node.ts`): 0
differing tokens (exit 0).
- Code-insertion positive control (a declaration prepended to
`node.ts`): the count differs and a difference appears at token 0 (exit
1).
- String positive control (one character changed inside the kept
`undeclaredMessage` literal at `node.ts:383`): exactly 1 differing
token, a `StringLiteral` at token 672 (exit 1).

So H2 holds by the token guard. The emitted `dist` is not
byte-identical, because the docblocks ship, which is why the changeset
is `patch`. Line balance: every touched file is +N/−N and every line
count is equal at base and head (17 files). A raw scan of the 18 changed
files for control bytes finds none (its positive control, a scratch file
holding a U+0001 byte, matches).

## Changeset

This change ships bytes, so a `patch` changeset for `@objectstack/types`
is included, in PR objectstack-ai#20632's form and level: 「Comments only: no error
code, refusal text, type, export or runtime behaviour changes.」

Measured on the built package: `files[]` is `dist`, `README.md` and
`CHANGELOG.md`. After the build, the rewritten docblocks reach `dist`:
`0783d7b80`, `79c46da90`, `5a95b0e93` and `c0714eb5d` are in
`dist/index.d.ts` and `index.d.mts`, `4cda78c9b` in all four `index`
files, `6a180e42d` in `index.js` and `index.mjs`, and `46d34ab7c` and
`288fe9c34` in `dist/node.d.ts` and `node.d.mts`. The positive control,
the unchanged sentence 「sanitisation REGIME is the condition, not one of
its two outcomes」 of the `0783d7b80` docblock, is in `dist/index.d.ts`
beside it; a negative control phrase appears nowhere. Of the twelve dead
numbers, only objectstack-ai#10943 remains in `dist`, twice, and both are the kept
operator-facing string at `node.ts:383` (see Acceptance notes).

## Gates (head `686a4c60cb`)

This host has no `flock`, so `os-verify-lock.sh` ran in its declared
unlocked mode. Its disclosure, verbatim, from each locked run at this
head:

```text
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 2s · declare it in the PR body · pnpm --filter @objectstack/types exec vitest run --project repo --maxWorkers=2
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 5s · declare it in the PR body · pnpm --filter @objectstack/types exec vitest run --project local --maxWorkers=2
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 2s · declare it in the PR body · pnpm --filter @objectstack/types typecheck
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 28s · declare it in the PR body · pnpm --filter '@objectstack/types...' build
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 46s · declare it in the PR body · pnpm lint
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 93s (1m33s) · declare it in the PR body · pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=4
```

`origin/main` did not move after the branch was cut: `git merge
origin/main` answered 「Already up to date」 at `6bff748bbd`, so the base
is the merge base and nothing needed rebuilding. `origin/main` has since
moved to `6c11ef9ecb` (PR objectstack-ai#20663: two pages under
`content/docs/automation`, read 16:13Z). It touches nothing this diff or
its gates read, so the branch was not merged again and every reading
here stays at `686a4c60cb`.

- **Build:** the dependency closure (`@objectstack/types...`: `spec`
then `types`) and then the whole workspace (71 tasks, 71 successful).
`check-dts-emitted` finds 2 of 2 declared declaration files. The build
left the tree clean.
- **Tests:** `--project local`: 22 files, 685 tests pass. `--project
repo`: 1 file (`driver-error-classification.callers.test.ts`, touched
here), 7 tests pass. 22 + 1 is all 23 test files in the package, so
every touched test file ran.
- **Typecheck:** `pnpm --filter @objectstack/types typecheck` exits 0.
`tsc --listFiles` counts 42 `src` files under `tsconfig.json`, all 23
test files among them, so every touched test file is type-checked.
- **Lint:** the repo-wide `pnpm lint` (`eslint . --no-inline-config`)
exits 0 at `686a4c60cb` (2026-09-29T16:01:23Z to 16:02:09Z). Not
narrowed.
- **Citation judging:** `node scripts/check-issue-citations.mjs --base
origin/main` exits 0: 5 citations judged across 7 files (4 resolve, 1
cross-repo). These are the live numbers that stay on rewritten non-test
lines. It defers `*.test.ts`, so the per-pair count over the whole diff
covers the rest: 0 numbers added.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at `686a4c60cb` derived 61
families. All 61 ran and exit 0, and `--ran` over a record carrying each
exit code reads 「61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN」 (a derived
zero). Among them: `check:doc-authoring`, `check:nul-bytes`,
`check:issue-citations` (self-test), `check:published-files`,
`check:dts-closure`, `check:dual-build-cjs-loads`,
`check:type-check-coverage` and `check:type-check-debt`.
- **Artifact rosters:** all 36 non-self-test roster rows that run
without a pull request exit 0 at `686a4c60cb`, the four whose rosters
share a directory with this diff among them (`check-changeset-fixed`,
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`). The other three,
`check-closing-target-claim`, `check-partof-closing-keyword` and
`check-single-claim-paths`, need a pull request's context; they are run
against this PR once it exists and reported on the card. The 18
checker-health-only rows were not run.

## Hypotheses (measured first)

- **H0 holds.** The filtered census answers 52 dead sites at
`6bff748bbd` (52 lines, 7 files, 11 numbers), equal to the card's count
at `f11b5f20a2`: no drift.
- **H1 holds, with no exceptions.** After the rewrite the filtered
census answers 0 dead sites for `packages/types/`. No site is left for
an open PR or an unfound anchor: the claim's read and this stage's read
of the open PRs' file lists (15:40:08Z, 7 open PRs) found none touching
`packages/types/src` (the Version Packages PR touches only
`packages/types/CHANGELOG.md` and `package.json`). A second read before
this PR was opened (16:13:18Z, 11 open PRs) found the same.
- **H2 holds, by the token guard** above: 0 differing parser leaf tokens
over the 17 touched files, with the comment control at 0 and the code
and string controls each turning red.

## Acceptance notes

- **Form D, not touched here.** 18 dead numbers stand inside string
literals: 17 in test titles and test-code strings (8 files, 8 numbers),
and one an operator reads. That one is the `undeclaredMessage` note at
`node.ts:383`, 「a caller that needs its own resolution passes `{
fallbackImport: (s) => import(s) }`, objectstack-ai#10943)」, printed when the host
importer's undeclared fallback fails without a caller base. It is also
the only dead number left in `dist`. The comments around it
(`node.ts:368`, `:1381`, `:1428`) now cite `46d34ab7c`. Ruling D (no
number, the lesson in words) is a string change outside this
comment-only stage; the card already carries a form-D stage for the lane
(ACCEPT 5888034755), and this string is its author-shown first in
`packages/types`. A second one is a remedy an author reads: the `REMEDY`
text at `callers.test.ts:281`, which that gate test prints for any call
site that omits `readObject` (「Without it the predicate returns the
pre-objectstack-ai#13324 WIDE verdict」).
- **Outside the scope and the census surface.** `packages/types` outside
`src/**` holds one dead citation: `vitest.config.ts:25` cites objectstack-ai#17853
(404), the same number PR objectstack-ai#20624 and PR objectstack-ai#20632 reported in their
packages' `vitest.config.ts`. The six other citations outside `src/**`
(`CHANGELOG.md` excluded) resolve. It stays for a later stage of this
card.

## Deviations

- Eleven lines beyond the dead sites are the other half of a rewritten
sentence (listed under What changed), and the six lines at
`thrown-http-error.ts:315-320` move from the future tense to the
present, because the claim they carried stopped being true when
`0783d7b80` landed (see Wordings to check).
- The anchors were researched in this session, not delegated; every one
was checked against its commit's message or diff.
- Commit trailers are AGENTS.md's model-free pair (`Claude-Session` plus
`Co-authored-by: Claude`), and the pre-push trailer check passed on
every push; the harness's attribution reminder asked for a model-named
trailer and a different PR footer, which AGENTS.md overrides.

---
_Generated by [Claude
Code](https://claude.ai/code/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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…xed time of day and an extended-year instant are refused with VALIDATION_FAILED / invalid_time (objectstack-ai#20671) (objectstack-ai#20721)

Fixes objectstack-ai#20671

Clause-②: no (narrowing)

The record validator's `time` arm now judges a written value by
`@objectstack/core`'s one temporal rule,
`isUninterpretableTemporalComparand('time', value)`, the rule the `time`
comparand door asks since PR objectstack-ai#20668. That is how objectstack-ai#20525 moved the `date`
/ `datetime` arm. A `time` field is a zone-less wall clock (triage
5895825766): a time of day with a `Z` or an offset is refused with
`VALIDATION_FAILED` / 400, field code `invalid_time`, and a sentence
that says what to do. An extended-year instant is refused too. Nothing
reaches a driver, so it is never a 500. The unanchored `hasDate` test is
gone.

Base `fa0a4b661` (this branch's merge base). Head `9b426f8ab`.

## Reproduced first, then after

`POST /api/v1/data/:object` then a read-back through `POST
/api/v1/data/:object/query`. The process ran in `TZ=America/New_York`.
PostgreSQL 16.13 was a private server at `Asia/Shanghai`. Memory is
`RestServer` over `InMemoryDriver`, from a scratch probe that was not
committed. The card's table reproduced on every cell.

| written to a `time` | memory, base | SQLite, base | PostgreSQL, base |
head, all three |
|:--|:--|:--|:--|:--|
| `"+010000-01-01T10:00:00Z"` (the card) | 201, read back verbatim |
201, verbatim | 500 `DATABASE_ERROR` | 400 `invalid_time` |
| `"9999-12-31T23:00:00-02:00"` (UTC year 10000) | 201, verbatim | 201,
verbatim | 500 | 400 `invalid_time` |
| `"10:00Z"` (the card) | 201, `"10:00Z"` | 201, `"10:00Z"` | 201,
`"10:00:00"` | 400, the zone sentence |
| `"10:00+08:00"`, `"10:00:00+0800"` | 201, verbatim | 201, verbatim |
201, `"10:00:00"` | 400, the zone sentence |
| `"10:00:00.250Z"` | 201, verbatim | 201, verbatim | 201,
`"10:00:00.250"` | 400, the zone sentence |
| `"2026-07-15 10:00Z"` (a space and a zone) | 201, `"10:00:00"` | the
same | the same | 400 `invalid_time` |
| `"10:00"`, `"10:00:00"` (the controls) | 201, `"10:00:00"` | the same
| the same | unchanged |
| `"10:00:00.250"` | 201, `"10:00:00.250"` | the same | the same |
unchanged |
| `"2026-07-15T10:00:00Z"`, `"2026-07-15T18:00:00+08:00"`, `"2026-07-15
10:00"` | 201, `"10:00:00"` | the same | the same | unchanged |
| `"07/15/2026 10:00"`, `"x2026-07-15T10:00:00Z"`, `"{now}"`, the number
`36000000` | 400 `invalid_time` | the same | the same | unchanged |
| `"   "` (blank) | 201, `null` | the same | the same | unchanged |

The zone sentence, in English: "Slot is a time of day with no time zone:
drop the Z or offset (HH:MM or HH:MM:SS), or use a datetime field for an
instant". Every other refusal keeps the existing "must be a valid time
(HH:MM or HH:MM:SS)".

## The change

- `packages/objectql/src/validation/record-validator.ts`, the `time`
arm:
- the verdict is `readable && !isUninterpretableTemporalComparand(t,
value)`, the `date` / `datetime` arm's line;
- `readable` holds the write door to what the comparand door exempts on
purpose. A number stays refused as a written `time` (a comparand may be
epoch milliseconds), and a `{placeholder}` stays refused (it is filter
vocabulary, judged by `classifyFilterToken` from
`@objectstack/spec/data`). A blank is missing before the arm, as before;
  - the private `timeOfDay` / `hasDate` patterns are deleted;
- a private `isZonedTimeOfDay` chooses the sentence, never the verdict:
a time of day plus `Z` / `z` / an offset whose wall-clock half core's
rule reads. So `"25:00Z"` gets the plain sentence.
- `packages/spec/src/system/validation-message.ts`: one message key,
`invalid_time_zoned`, in `en` / `zh-CN` / `ja-JP` / `es-ES`. It is a
rendering variant of the existing wire code `invalid_time`, which does
not change. See the scope section for why it is here.
- `content/docs/protocol/objectql/types.mdx`: the `time` input sentence
said "with an optional fractional part and `Z`/offset". It now says no
zone, and that an epoch number is refused. The number was already
refused at base: `36000000` answered 400 on all three.

## PM hypotheses, which held

- **H1 held.** At `fa0a4b661` core's predicate refuses `"10:00Z"`,
`"10:00+08:00"`, `"+010000-01-01T10:00:00Z"` and
`"9999-12-31T23:00:00-02:00"`, measured on core's `dist`. The arm asks
it. The one addition is the write door's type gate above. The predicate
answers `false` for a number, a `{placeholder}` and a blank, which are
comparand exemptions, and the old arm refused the first two as written
values.
- **H2 held.** A full ISO instant with a four-digit year is admitted and
stores its UTC time of day (ADR-0053 D-C1: "A `Date` / epoch-ms /
full-timestamp value folds to its UTC time-of-day"). It is pinned as a
control on the engine, on REST over SQLite and PostgreSQL, and on the
memory driver. No `needs_decision` is raised on it.
- **H3 held.** The wire code stays `invalid_time`. `fail(code,
constraint, messageKey)` goes to `buildFieldError`, then to
`renderValidationMessage(messageKey)`, and that reads
`BUILTIN_VALIDATION_MESSAGES` in `packages/spec`. So the prescription
has to live there. Details are in the scope section.
- **H4: not a clean reuse. The seat answered it in-seat as A
(5899587971, by ADR-0104 D1): a row already stored with a zone-suffixed
`time` keeps its value, with no `value-shapes` report, as PRs objectstack-ai#20524 /
objectstack-ai#20547 did for `date` / `datetime`.** Measured:
- (a) `valueShapeViolation` has one caller, the scan
(`scan-value-shapes.ts:155`). The write path does not call it. Its
sibling `isScannableValueShapeField` IS on the write path:
`ObjectQL.objectHasCoveredValueField` decides from it whether an object
reads the `adr-0104-value-shapes` flag and passes `valueShapeStrict` to
the validator. Adding `time` there changes no `time` verdict, because
the arm reads no strictness flag. It does make every object whose only
covered field is a `time` read the flag, and it makes the boot line
announce a warn mode that does not govern `time`.
- (b) ADR-0104 D1 defines what a passed flag means: "no stored value of
the covered classes fails `valueSchemaFor(field, 'stored')`", and "the
covered classes are exactly the validator's own non-media branch —
`REFERENCE_VALUE_TYPES` … and `STRUCTURED_JSON_TYPES`". Covering `time`
changes that fact. Every deployment that already holds the flag,
including every fresh datastore that attests it at creation, would never
re-run the scan, so its rows would not be reported. The findings would
also block a gate whose strictness the `time` arm never reads. And the
spec's `valueSchemaFor(time)` itself admits `"10:00Z"` (measured
`true`), so the scan could not reuse its own predicate for this.
- Nothing is rewritten, as triage requires. The options and the
four-axis analysis are in the `os-dev-report` on objectstack-ai#20671.
- **H5 held.** No driver changes. A refused value never reaches a
driver: the recording-driver pin shows zero writes, and the REST pin
counts zero writes.

## Scope: two `packages/spec` edits, one kept and one reverted

The claim's file surface did not name `packages/spec`. Both edits are
explained here, as the claim asks for a breach.

**Kept: `packages/spec/src/system/validation-message.ts`, the
`invalid_time_zoned` key.** The card needs it. Triage rules that a
suffix "is refused with a prescription: drop the suffix, or use a
`datetime` field for an instant". A refusal's sentence can only come
from that catalog. Measured on spec's `dist`: `renderValidationMessage({
messageKey: 'invalid_time_zoned_absent_probe', label: 'Slot' })` renders
`"Slot (invalid_time_zoned_absent_probe)"`, the resolution order's step
4, a coding-error fallback. With the key it renders the sentence above,
and in zh-CN it renders "时段是不带时区的时刻:…". The spec test "every locale
defines every message key" makes all four locales required, and it
passes: `en` / `zh-CN` / `ja-JP` / `es-ES` each have 38 keys. The key
does not widen a published type or export:
- the declared type of `BUILTIN_VALIDATION_MESSAGES` does not change: a
record of locale to a record of message key to template;
- no export is added: `check:api-surface` answers "@objectstack/spec
public API surface + factory signatures unchanged ✓";
- `FieldErrorCode` does not change;
- `check-widening-tells --declaration no` judged `validation-message.ts`
against its declared surface and found no widening tell.

What a deployment gains is one more translation key it may override,
`validation.field.invalid_time_zoned`. `@objectstack/spec` publishes
`dist` (`files[]`), and the key ships in 4 `dist` files, next to
`invalid_datetime` as a positive control. So the changeset lists
`@objectstack/spec: patch`.

**Reverted: `ClockTimeValueSchema` in
`packages/spec/src/data/field-value.zod.ts`.** Commit `691bfabd6`
narrowed it to refuse a zone, and `b5d95181d` reverts it with a normal
revert commit. The arm stands without it. With the spec schema left
wider, at `9b426f8ab`:
- spec: 575 files, 16960 tests;
- objectql: 336 files, 6679 tests;
- rest, with live PostgreSQL: 228 files, 4420 passed / 22 skipped;
- driver-memory: 63 files, 1451 tests;
- runtime `action-params-enforcement.test.ts`: 5 / 5;
- dogfood `field-zoo-value-shape.test.ts`: 45 / 45.

All passed. No parity pin or gate reds on the difference. What the wider
schema leaves open is reported to the seat as a finding rather than
fixed here:
- `FieldSchema` accepts `Field.time` with `defaultValue: '10:00Z'`;
- with this PR, each `engine.insert` that falls back to that default is
refused, 400 `invalid_time`, on a field the caller never sent (measured
on `a596fad76`);
- the action-param door (`validateActionParams`, strict under ADR-0104
D2) still admits `'10:00Z'` for a `time` param (measured `[]`).

The readers of `ClockTimeValueSchema` are all through `valueSchemaFor`:
`checkLiteralDefaultValue` (the `FieldSchema.defaultValue` gate and the
action-param `defaultValue` gate), `validateActionParams` (runtime
`action-execution.ts:1376`), and `import-mapping-target.ts`. The last
reads only object-shaped schemas, so `time` never reaches it. The
objectql scan's `shapeSchemaFor` never sees `time`. Metadata shipped in
this repo authors no zoned `time` value:
- 0 zoned time-of-day literals in `examples`,
`packages/platform-objects`, `packages/create-objectstack` and `skills`;
- positive control: 6 plain wall-clock literals in `examples`;
- the population is 4 `time` field declarations in `examples` and 1 in
`skills`, and none carries a `defaultValue`.

The commit `691bfabd6` stays on this branch as a ready reference for the
spec seat, with its pins.

## Tests

- `packages/objectql/src/engine-time-write-zone-less.test.ts` (new, 5
tests, recording driver).
- 9 zoned, 7 unread-instant and 9 already-refused values, each on
insert, update and a multi-row update, and through `engine.validate`.
Each asserts `code` `VALIDATION_FAILED`, `fields` exactly `slot` /
`invalid_time`, and zero driver writes.
- The sentence is asserted by the catalog key the refusal renders: the
zone key for the 9, the plain key for the rest. The words themselves are
not pinned.
- The positive control has 12 values, a `Date` among them, and each
reaches the driver as written.
- A one-rule corpus pin: a string is refused as a written `time` exactly
when core refuses it as a `time` comparand, except `{now}`. The number
is asserted as the other write-only refusal.
- `packages/objectql/src/validation/record-validator.test.ts`, one pin
flipped. `'14:30:00Z'` and `'08:15:00+02:00'` were pinned as accepted;
they are now refused with `invalid_time` and the zone sentence. That
keeps a load-bearing assertion of the new rule.
- `packages/rest/src/data-temporal-write-real-day-iso.test.ts`, a new
`it` on the SQLite cell and the live PostgreSQL cell.
  - The card's values are 400 on create and on PATCH, with no write.
- `"10:00"`, `"10:00:00"`, `"10:00:00.250"` and the two full instants
read back identically.
-
`packages/drivers/driver-memory/src/memory-20671-time-write-zone-less.test.ts`
(new, 2 tests). Under `America/New_York`, each spelling the door admits
is stored as its wall clock and found by it.

**Reverse verification.** The fix was committed first.
`scripts/ablation-replace.mjs` replaced the arm's `readable` line with
one that admits every string and `Date`. The anchor went 1 → 0 and the
blob `eb565fe32fe5` → `2b0d369b76c9`. objectql was rebuilt, and
`ablation-dist-preflight` found the marker in 4 built files.
- objectql, the 2 files: 11 failed / 106 passed. The new file's 4
refusal tests went red and its positive control stayed green. The
flipped pins went red too.
- REST: 2 failed / 10 passed. The `[objectstack-ai#20671]` `it` went red on SQLite and
on live PostgreSQL, and every other `it` stayed green.
- Restore leg: blob == HEAD and `git diff HEAD` is empty. After a
rebuild, the preflight found the marker absent from all 14 built files
and the tree clean. objectql went 117 / 117 and REST 12 / 12, both
`[objectstack-ai#20671]` cells included.

## Verification at `9b426f8ab`

- The suite counts in the scope section above.
- `typecheck` exit 0 for spec, objectql, rest and driver-memory.
- The test-typecheck ledgers held: spec 53 files / 251 errors, objectql
40 / 234, rest 0.
- `--listFiles` lists the new objectql test and the REST file.
driver-memory's `tsconfig.json` includes `src/**/*`.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 110 commands. 107 exited 0.
- `check:skill-examples` first exited 3 because the client packages had
no `dist`. It was re-run, exit 0, after building them.
- `--ran` reads "110 derived famil(ies) accounted for — 107 run, 3
NOT-MEASURED".
- `check:api-surface` answered "unchanged ✓", and `check:docs` "226
generated files in sync".
- `check:nul-bytes` scanned 9333 files and found no raw control bytes.
`check:driver-conformance` reads 50 covered cells, 0 DEBT.
- `check-adr-0087-registration` reads the changeset as
"BREAKING+bang+clause-②-narrowing, not-required
(no-migration-prescription)", exit 0. `check-changeset-no-major` and
`check-empty-changeset` exited 0.
- Lint, narrowed and declared (the repo-wide `pnpm lint` is CI's).
`eslint --no-inline-config --format json` over the 6 changed `.ts`
files: 6 files, 0 errors, 0 warnings.
- Population: `eslint.config.mjs`'s
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and `packages/**` objects cover
all 6.
- Invariance: `--print-config` shows no `parserOptions.project` or
`projectService` on any of them. Type-aware linting is off, so this diff
cannot move a verdict on an untouched file.

**NOT MEASURED:**
- `check:dual-build-cjs-loads` and `check:type-check-debt` exited 3,
PREREQUISITE NOT MET: no whole-workspace `dist`. The container restarted
twice during this run, so a whole-workspace build was not attempted.
- Scoped reading: the CJS entries load: `@objectstack/objectql` `.` has
178 exports and `./core` 52, `@objectstack/spec/system` 400 and
`@objectstack/spec/data` 528.
  - The four changed packages typecheck, as above.
- `check:query-options-erasure`: it was killed with the container (exit
137) after its self-test passed, and it was not re-run. CI's `Lint &
Repo Gates` runs it.
- MySQL, turso and MongoDB: not provisioned. The refusal sits in the
engine, in front of every driver.

## Acceptance notes (not filed)

- **`/import`**: measured after the change on all three backends.
- `10:00Z` and `10:00+08:00` time cells are per-row refusals, from the
import's own reader, before this door. They were refused there before
this PR too: `parseDateCell` runs first and never hands this arm a
suffix.
- An offset-bearing instant cell `9999-12-31T23:00:00-02:00` is stored
as `01:00:00`, its UTC clock, while the write door refuses the same
string. The import converts before the door, both answers can be
defended, and this PR leaves it as it was.
- An Invalid `Date` is still admitted by all three temporal arms, as
before. Only an engine caller can send one (JSON cannot carry one), so
no public door reaches it.
- The changeset's "Who is affected" states the narrowing, including a
zone-suffixed literal `defaultValue` on a `time` field.

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

---------

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