Skip to content

fix(spec,drivers): a datetime $lte or $between maximum on 9999-12-31 includes the whole last supported day (#20600) - #20643

Merged
objectstack-fleet[bot] merged 19 commits into
mainfrom
claude/issue-20600-last-day-unbounded-above
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 19 commits into
mainfrom
claude/issue-20600-last-day-unbounded-above

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #20600

Clause-②: yes (widening) — three new exports on @objectstack/spec (data) and @objectstack/core: the constant UNBOUNDED_ABOVE, its type UnboundedAbove and the guard isUnboundedAbove; nextUtcCalendarDay answers the constant for 9999-12-31, the one input that used to answer the five-digit '10000-01-01'; a $lte / $between maximum on that day that answered no rows on SQLite now answers the whole day. Nothing any door accepted before is refused, and nothing is removed or renamed.

BREAKING for TypeScript and JavaScript callers of nextUtcCalendarDay (seat ruling B, 5892121354): its return type gains a member and its answer for one input changes from a string to a symbol, landing as minor under the launch-window convention; the changeset carries the banner and the ADR-0087 disposition not-required (no-migration-prescription).

Direction from triage 5886142901 (binding, not re-opened): neither null nor a five-digit year. Every supported value is at most the last millisecond of 9999-12-31, so the whole-day bound past the last day is "unbounded above": the helper answers that explicitly, as a distinct value, and the drivers compile no upper bound for it.

The change

  • packages/spec/src/data/calendar-day.ts: new exports UNBOUNDED_ABOVE, its type UnboundedAbove (a symbol with a structural brand) and the guard isUnboundedAbove. nextUtcCalendarDay returns string | UnboundedAbove | null and answers UNBOUNDED_ABOVE for the one real day whose successor has no YYYY-MM-DD spelling. The type is structural because ./data ships as index.d.mts and index.d.ts: a unique symbol was two unrelated types in a program meeting both (the red Type Check · workspace), while two copies of the branded alias are one type. It stays a symbol, so a template literal, a relational comparison and a string target still refuse it; readers narrow with isUnboundedAbove (=== compares but does not narrow). @objectstack/core re-exports all three.
  • Every reader of the answer handles it explicitly (census below). What each compiles for the last day:
    • a lone $lte (or its AST spelling): no bound, only "the value is not null" (IS NOT NULL on SQL and the analytics echo, $ne: null on memory and mongo, a value check in the engine's having);
    • a $between / between maximum, an explicit analytics dateRange end: the range keeps its minimum alone;
    • the type-blind in-process evaluators (formula's RLS check, the draft preview): every value that denotes an instant is inside the bound, and any other value is compared as written.
  • $gte, $gt, $lt and $eq on 9999-12-31 do not read the helper and are unchanged (midnight-anchored; $eq is that midnight instant). 9999-12-30 and every earlier day compile the same bound as before.
  • The shared temporal conformance kit (packages/spec/src/data/temporal-conformance.ts) gains the row z_last (9999-12-31T10:00:00.000Z, native writer form) and five last-day cases ($lte on datetime and on date, two $between maxima with their analytics dateRange spellings, and the 9999-12-30 control), so every backend the kit drives, the Temporal Conformance (live PG + MySQL) job included, is held to this answer. Three existing $gte / $gt cases now also expect z_last.

Reader census (the answer of nextUtcCalendarDay, measured with git grep at c1d8051e0a)

reader site today, for the last day now
driver-sql calendarDayExclusiveUpperBound (+ calendarDayUpperBoundRewrite, calendarDayBetweenRewrite, the where emitter) sql-driver.ts bound 10000-01-01…; SQLite: no rows IS NOT NULL / the minimum only
driver-turso remote lowering (inherits the rewrite) turso-driver.ts toRemoteUpperBound same bound, sent to the transport $null: false / $gte only
driver-memory $lte, $between and their AST spellings memory-driver.ts (4) $lt '10000-01-01': no rows $ne: null / $gte only
driver-memory cube lte (mingo and SQL echo), dateRange window memory-analytics.ts (3) same $ne: null / IS NOT NULL / start only
driver-mongodb $lte, $between mongodb-filter.ts (2) $lt the stored form of 10000-01-01 $ne: null / $gte only
formula lteBound (RLS check) matches-filter.ts a '2026-…' value sorts above '10000-01-01': write denied instant: admitted; else as written
objectql wholeDayUpperBound (having, per-aggregation filter) having-filter.ts same text bound a value / min only
service-analytics preview lteBound, dateRange preview-evaluator.ts (2) same text bound instant: in; window: start only
service-analytics native SQL lte, dateRange native-sql-strategy.ts (2) same bound IS NOT NULL / start only
service-analytics ObjectQL echo dateRange objectql-strategy.ts echoed 10000-01-01 start only (the driver runs no upper bound)
spec utcInstantMs validity test calendar-day.ts reads !== null unchanged, pinned
test readers core analytics-date-range.test.ts, spec date-range-presets.test.ts read the answer as a string assert it is a string

The seat's list also named core temporal-storage-form.ts / analytics-date-range.ts, spec date-range-presets.ts / temporal-conformance.ts and driver-mongodb mongodb-temporal.ts: they mention the helper in comments only and read no answer. The claimed service-analytics files analytics-service.ts, cube-registry.ts, dataset-executor.ts, dataset-compiler.ts do not read it and are untouched.

Shape, measured (hypothesis 2)

Round 1 kept unique symbol and measured 3 of 16 reader sites refused and 13 silent under the widened type. The at-tier review found the type nominal per declaration file: Type Check · workspace went red in @objectstack/dogfood (TS2367 at turso-driver.ts 2640, TS2339 at 2641), reproduced locally at e197faa978 with CI's own command. Round 2 types the sentinel symbol plus a structural brand and adds the shared guard: the same workspace typecheck is green at 49a2585988 (135 of 135 tasks, dogfood included), and a scratch consumer compiled against the built dist/data/index.d.ts and index.d.mts together compares and narrows across the two files while a template literal, a relational comparison and a string target still refuse the member (TS2731, TS2469, TS2322). A string sentinel is excluded by ruling; a tagged object compiles silently in a template literal.

Verification

Premise, on the base c1d8051e0a, through POST /api/v1/data/:object/query (the new packages/rest/src/data-query-calendar-day-last-day.test.ts, process in America/New_York, a private PostgreSQL 16.13 with server zone Asia/Shanghai):

where opened_at SQLite, base PostgreSQL 16, base head, both
$lte '9999-12-31' none c26, prev, open, mid, last the same five
$between ['2026-01-01', '9999-12-31'] none the same five the same five
$between ['9999-12-31', '9999-12-31'] none open, mid, last open, mid, last
$not of $lte '9999-12-31' all six, none included none none
$lte '9999-12-30' (control) c26, prev c26, prev the same
$gte / $gt / $lt / $eq '9999-12-31' unchanged unchanged unchanged

Rows: c26 2026-07-15T14:00Z (the card's control), prev 9999-12-30T10:00Z, open 9999-12-31T00:00Z, mid 9999-12-31T10:00Z (the card's row), last 9999-12-31T23:59:59.999Z, none no value.

Reverse verification (fix committed first at f4d15dd506, mutation through scripts/ablation-replace.mjs under a trap restore to the HEAD blob): the helper's last line was replaced by return next || 'ABLATION_20600';, so it answers the successor spelling again (the five-digit '10000-01-01' for the last day; the marker never fires and only proves the mutation reached dist); anchor 1 to 0 and replacement 0 to 1 on disk; pnpm --filter @objectstack/spec build; scripts/ablation-dist-preflight.mjs found the mutation in dist. Mutated leg: spec calendar-day 2 failed; driver-sql 9 failed (the SQLite and legacy-storage cells; the PostgreSQL cells stayed green, as on the base); driver-memory 11; driver-mongodb 3; formula 10; objectql 3; service-analytics 17; driver-turso 12; rest 1 (the SQLite cell). Restore: blob equals HEAD, git diff HEAD empty, spec rebuilt, the preflight found the mutation in no built file, and the same battery went green (16, 135 plus 1 MySQL skip, 83, 50, 84, 89, 122, 150, 4).

Tests and gates at 49a2585988 (origin/main f4ce10c89d merged): the whole-workspace typecheck of the Type Check · workspace job, 135 of 135 tasks; the reader battery under America/New_York on SQLite (spec 28, core 73, driver-sql 91, driver-memory 83, driver-mongodb 84, formula 84, objectql 89, service-analytics 122, driver-turso 150, driver-sqlite-wasm 71, rest 4) all green; check:generated all 15 up to date after regenerating api-surface and export-origins; 94 derived gates all exit 0 and reconciled with --ran (94 run, 0 NOT MEASURED); narrowed lint 23 files, 0 errors, 0 warnings. The round-1 full suites (at f7a61d512e and e197faa978) and the live PostgreSQL cells stand from round 1; round 2 changes the readers' test from === UNBOUNDED_ABOVE to isUnboundedAbove(…), whose body is that comparison.

Acceptance notes

  • Hypothesis 3, one part falsified. The dispatch expected $eq '9999-12-31' to "still mean that whole day". No reader applies the whole-day reading to $eq: on every backend a bare day under $eq on a datetime is that day's midnight instant, and none of them reads nextUtcCalendarDay for it. It is unchanged here and pinned as such ($eq '9999-12-31' answers open, the midnight row, before and after).
  • Type-blind emitters on a non-temporal field. The memory and mongo drivers, the memory cube filter and the native-SQL lte widen without knowing the column type, as their bounded rule always has ($lte day becomes "before the next day" on any column). On the last day they compile "has a value" the same way, so a bare-day $lte '9999-12-31' on a text or number field answers every non-null row there. formula's check and the draft preview, which see the value, admit an instant and compare anything else as written. The SQL drivers and the engine's having scope the rule to datetime columns and are exact.
  • Remote Turso and a contested $null. The remote lowering assigns lowered keys into one operator map, so the new $null: false shares a key with an author's own $null beside a last-day $lte, as its existing $lte to $lt and $between to $gte lowerings already share theirs (the memory and mongo translators assemble such writes into $and branches; the remote path does not). Read from the code, not reproduced; noted, not filed.
  • Engine having / per-aggregation filter, $between on a missing value. A null aggregate value passes a $between there, whatever its bounds (neither ordered(null, …) comparison is true). Unchanged by this PR; read from the code, not reproduced.
  • MySQL and a real mongod were not run locally. No mysqld in this container; the Temporal Conformance (live PG + MySQL) job runs the new kit rows on MySQL 8.0. The real-mongod half of the mongo conformance matrix is opt-in (OS_TEST_MONGODB_MEMORY_SERVER_ENABLED) and was not run; the mongo translator is pinned server-free in mongodb-filter.test.ts.
  • The widened return type is compile-visible. A TypeScript caller that uses nextUtcCalendarDay's answer as a string stops compiling (3 of the 16 in-repo sites did). Declared yes (widening), not (narrowing): no input the platform accepted is refused, nothing is removed or renamed. The changeset states the one-line handling for such a caller.
  • Out of scope, reported to the seat: the memory cube filter never widens a declared datetime. MemoryAnalyticsService converts a comparand to the field's storage form before its lte row asks nextUtcCalendarDay, so on a DECLARED datetime the helper sees an instant and declines, on every day. Measured on the built @objectstack/driver-memory at this head, rows at 2026-07-28T10:00Z and 2026-07-27T10:00Z, where { created_at: { $lte: '2026-07-28' } }: undeclared field, both rows (the SQL echo compiles the half-open bound at '2026-07-29'); declared datetime, only the 07-27 row (the echo compiles an inclusive bound at '2026-07-28T00:00:00.000Z'), while find() answers both. Not the last-day defect, so not fixed here. → filed by the seat as [finding] driver-memory analytics: a cube where $lte bare day on a declared datetime field drops the rest of that day — comparandsFor converts to storage form before the whole-day rule runs #20661.

Generated by Claude Code

… not the five-digit '10000-01-01'

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…nd for a whole-day bound on 9999-12-31

Every reader of nextUtcCalendarDay handles UNBOUNDED_ABOVE: a lone $lte asks
only for a value, a $between or dateRange keeps its minimum. The shared
temporal conformance kit carries the last-day cases to every backend.

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…ompiles its bound

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…ay, never UNBOUNDED_ABOVE

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
… and the mongo translator's last-day pins

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
… as a day, never UNBOUNDED_ABOVE

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…erasing the query options

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

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 9 package(s): @objectstack/core, @objectstack/driver-memory, @objectstack/driver-mongodb, @objectstack/driver-sql, @objectstack/driver-turso, @objectstack/formula, @objectstack/objectql, @objectstack/service-analytics, @objectstack/spec, touching 38 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/core/src/utils/datetime.ts, packages/spec/api-surface/data.json, packages/spec/export-origins/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/data-modeling/drivers.mdx (via SqlDriver (symbol, a top-level class), TursoDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/deployment/validating-metadata.mdx (via dateRange (symbol, a field of const object TEMPORAL_CASES))
  • content/docs/permissions/tenant-audit-census.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SqlDriver (symbol, a top-level class), TursoDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/types.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/ui/dashboards.mdx (via dateRange (symbol, a field of const object TEMPORAL_CASES))

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

  • content/docs/releases/v14.mdx (via generateSql (symbol, a method of class NativeSQLStrategy; a method of class ObjectQLStrategy))
  • content/docs/releases/v15.mdx (via dateRange (symbol, a field of const object TEMPORAL_CASES))
  • content/docs/releases/v16.mdx (via dateRange (symbol, a field of const object TEMPORAL_CASES))
  • content/docs/releases/v17/17-0.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-4.mdx (via dateRange (symbol, a field of const object TEMPORAL_CASES))
  • content/docs/releases/v17/17-5.mdx (via SqlDriver (symbol, a top-level class), TursoDriver (symbol, a top-level class), dateRange (symbol, a field of const object TEMPORAL_CASES), generateSql (symbol, a method of class NativeSQLStrategy; a method of class ObjectQLStrategy), nextUtcCalendarDay (symbol, a top-level function))

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

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/core/src/utils/datetime.ts, packages/spec/api-surface/data.json, packages/spec/export-origins/data.json) — pages documenting those are invisible to this run
  • 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 — 145 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 f4ce10c89dcb87b496def16aacee9107155d5d57 → packageMentionDocs.

Which tree this was computed on

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

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

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

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

…ondition | undefined

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

Copy link
Copy Markdown
Contributor Author

Contract review

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

Inputs: card #20600 (body; triage 5886142901; claim 5886817601; os-dev-report 5891602308), PR #20643 (body, its one comment, the 26-file list, the net diff against the merge-base f1e921ab8e, byte-equal to origin/main...head: +838/−58), the check-runs on the head (read once, 2026-09-29T14:02Z), and git grep/git show against origin/main c6b37cd08d, the merge-base and the head. Nothing built, run or re-run.

① Derived judgments

The helper (right at run time, wrong at the type level as shipped). nextUtcCalendarDay answers UNBOUNDED_ABOVE (Symbol.for('objectstack.calendarDay.unboundedAbove')) for 9999-12-31 and for no other real day (the last-year walk pins exactly one), null still means "not a calendar day" (9999-12-31T10:00Z, a Date, 9999-12-32 and 10000-01-01 pinned null), and every other day answers the next day. That is the explicit, distinct answer triage 5886142901 asked for: neither null nor a five-digit year, and a value no string sink can sort. utcInstantMs('9999-12-31') still reads that day's midnight (it tests !== null, unchanged, pinned). ⛔ But the value is only impossible to confuse at RUN time. The type is unique symbol, which is nominal per declaration, and @objectstack/spec's ./data export ships two type files (dist/data/index.d.mts under import, dist/data/index.d.ts under require), each declaring its own UNBOUNDED_ABOVE. Symbol.for unifies the value; nothing unifies the type. A program whose graph reaches the constant through both conditions sees two unique symbols with no overlap, and that is exactly what CI measured (below): TS2367 on the === UNBOUNDED_ABOVE guard, then TS2339 on .op/.value because the guard no longer narrows. A published consumer importing the constant from @objectstack/spec/data while reaching typeof UNBOUNDED_ABOVE through @objectstack/core under the other module condition hits the same wall. Cosmetic, not wrong: unknown | typeof UNBOUNDED_ABOVE collapses to unknown on calendarDayExclusiveUpperBound, calendarDayBetweenRewrite.upper and wholeDayUpperBound, so those signatures promise nothing at compile time and rely on the run-time check.

Reader census (my own, on origin/main, non-test, non-comment call sites of nextUtcCalendarDay): 18 sites in 9 source files, every one handled. memory-analytics.ts ×3 (cube lte mingo, cube lte SQL echo, dateRange), memory-driver.ts ×4 (the AST less-or-equal arm, the AST between arm, $between, $lte), mongodb-filter.ts ×2 ($lte, $between), sql-driver.ts ×1 (calendarDayExclusiveUpperBound, consumed by calendarDayUpperBoundRewrite, calendarDayBetweenRewrite and the two arms of the where emitter), turso-driver.ts ×1 indirect (toRemoteUpperBound via the inherited rewrite), matches-filter.ts ×1 (lteBound), having-filter.ts ×1 (wholeDayUpperBound, consumed by $lte and $between), preview-evaluator.ts ×2 (lteBound, dateRange), native-sql-strategy.ts ×2 (dateRange, lte), objectql-strategy.ts ×1 (dateRange echo); plus utcInstantMs (reads === null, right unchanged), the core re-export, and two test readers (analytics-date-range.test.ts, date-range-presets.test.ts) now asserting a string. No caller outside packages/ on origin/main; none in the pinned objectui (git grep at dd3f7e1b: empty); driver-sqlite-wasm (never named by the dev) extends SqlDriver and calls nothing itself. No missed site.

Per operator, each named right: a lone $lte (or its AST less-or-equal spelling) compiles only a not-null check — SQL whereNotNull / orWhereNotNull on the raw column (right: a legacy-repair columnExpr is NULL exactly when the column is), memory and mongo put('$ne', null) so an author's own $ne survives beside it (pinned), Turso remote $null: false (the transport has a $null arm), having null → false, native-SQL IS NOT NULL. A $between / between maximum keeps only its minimum on every reader; an explicit analytics dateRange end keeps only its start on all four (memory both stored forms, preview, native-SQL, ObjectQL echo, whose driver leg compiles the same not-null). The type-blind formula check and draft preview admit any value utcInstantMs reads as an instant and compare anything else as written — right, and internally consistent: a number is an instant under utcInstantMs on the control day too, so the last day answers what 9999-12-30 answers for every value class. $gte/$gt/$lt/$eq and every earlier day: unchanged; the $or and $not shapes over the unbounded $lte are pinned on SQL ($not answers exactly the null row).

Hypothesis 3 (the dev's partial falsification): true. Every call site above sits in an lte / $lte / AST less-or-equal / between / $between / dateRange arm; no $eq arm reads the helper on any reader, so $eq '9999-12-31' is the midnight instant everywhere, before and after, and the REST file pins it (open).

Conformance: right. Row z_last (9999-12-31T10:00Z, native) joins a ten-row fixture with no null; I re-derived the five new expected lists and the three amended $gte/$gt lists by hand against the rows and they are right ($between ['2026-07-29','9999-12-31'] keeps f_next on its inclusive midnight and drops the 2024 leap row; the one-day $between on the last day answers z_last alone; the 9999-12-30 control drops it; the date $lte keeps all ten). Every un-amended case is a two-sided window z_last falls outside or a time case from the other table — none wrongly lacks it. The kit is consumed at the head by driver-sql (SQLite, plus live PG and MySQL in Temporal Conformance (live PG + MySQL)), driver-sqlite-wasm, driver-turso local and remote, driver-memory, formula, service-analytics native-SQL and preview, and objectql's per-aggregation/having test — all held. The mongo row cell (describe.skipIf(!sharedMongod)) is gated on OS_TEST_MONGODB_MEMORY_SERVER_ENABLED, which no workflow sets, so the kit never drives a real mongod in CI: a pre-existing gap, not this diff's; the translator is pinned server-free by the three new mongodb-filter.test.ts cases.

Out-of-scope finding 1: confirmed from the code. MemoryAnalyticsService.comparandsFor maps every comparand through driver.filterComparandStorageForm → toStorageForm → coerceTemporalValue → temporalStorageForm, which turns a bare YYYY-MM-DD on a declared datetime into T00:00:00.000Z BEFORE the lte row asks nextUtcCalendarDay(comparands[0]) (the row is handed raw too and does not read it). The helper declines the instant, so the whole-day rule never reaches a declared datetime on the cube face, on any day — the ADR-0053 D-E ordering breach the ADR names in its own words ("converting first … the whole-day window would silently narrow back to an instant"). Reach: no non-test composition in packages/, apps/ or examples/ constructs MemoryAnalyticsService (only driver-memory's index.ts exports it), so no in-repo door — the published package export alone. Not this diff's; escalated in ③.

Gate coverage (the check-runs on this head, read once): 35 runs — 30 success, 3 skipped, 2 failure. Green: Lint & Repo Gates, Check Changeset, Build Core, Test Core (6 shards), Temporal Conformance (live PG + MySQL), Dogfood Regression Gate (3 shards) and Dogfood Verify CLI, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Spec property liveness, Governed Surface Queue Guard, Flag docs affected by code changes, the five PR-hygiene checks, Auto Label, Check PR Size. Skipped by filter or opt-in: Build Docs, Console Pin Gate (the pinned sibling has no caller, so nothing it could break), Packed-tarball smoke. ⛔ Red, and this diff's: Type Check · workspace (job 109419335107) and its rollup TypeScript Type Check. The job runs turbo run typecheck over every package against built dist/*.d.ts; the failing script is @objectstack/dogfood's tsc --noEmit, whose tsconfig path-maps @objectstack/driver-turso to SOURCE while @objectstack/spec/data, @objectstack/core and @objectstack/driver-sql resolve to built types. Its annotations, verbatim: This comparison appears to be unintentional because the types '{ op: string; value: unknown; } | unique symbol | null' and 'unique symbol' have no overlap (line 2640) and Property 'op' / 'value' does not exist on type '{ op: string; value: unknown; } | unique symbol' (line 2641) — lines 2640–2641 of packages/drivers/driver-turso/src/turso-driver.ts at this head are toRemoteUpperBound's if (rewritten === UNBOUNDED_ABOVE) and { [rewritten.op]: rewritten.value }, inside this PR's own hunk. The same check is green on origin/main c6b37cd08d and on the merge-base f1e921ab8e. The dev's ten per-package typechecks (each mapping the workspace to source) could not see it; the dev's NOT-MEASURED pair (check:dual-build-cjs-loads, check:type-check-debt) is not this failure.

② Semver level

Levels per package: right. @objectstack/spec minor (a new export and a new answer on a published helper), @objectstack/core minor (re-export), driver-sql, driver-turso, driver-memory, driver-mongodb, formula, objectql, service-analytics patch (a bug fix in a released package, never skip-changeset). All nine are published at 17.5.0, none private. @objectstack/rest changes a test only and correctly takes none; driver-sqlite-wasm changes no source and inherits through its dependency. The line Clause-②: yes (widening) is present, well-formed, and carried in both the PR body and the changeset, as the ADR-0087 gate requires; the value is right (the accept set does not narrow, the public surface grows, a query that answered no rows now answers rows) and the arm is right on scripts/pm/clause2-line.mjs's vocabulary, which reads direction on the accept set and the public surface — (narrowing) would be wrong-worded here.

The Clause-② open question: B, as an amendment, not a re-arming. Judged on the repo's own rules: (1) check-adr-0087-registration.mjs keeps the **BREAKING** banner as a carrier independent of the arm ("Signal (2) is KEPT, not replaced"; widening adds no signal and removes none), and its type-surface-only docblock states the repo's reading of this class — a published type-surface change after which "a consumer's code can stop compiling" "declares BREAKING truthfully"; (2) the precedent .changeset/19920-exported-types-family-close.md declared a type-only change with an unchanged runtime accept set as **BREAKING for TypeScript code that annotates with …** plus adr-0087: not-required (no-migration-prescription) at minor under the launch-window lockstep (no .changeset/pre.json at this head; check-changeset-no-major forbids major, so the banner IS the carrier); (3) this diff is stronger than that precedent, not weaker: nextUtcCalendarDay is a published export of two packages (api-surface/data.json line 864, re-exported by core), its return type widens compile-visibly (the changeset itself says TypeScript now refuses the answer in a template literal, a relational comparison and a string parameter; three in-repo sites stopped compiling), and its run-time answer for one input changes from a string to a symbol that THROWS at every unmigrated string sink (the dev's own measurement) — a change a consumer must be told about; (4) the changeset body carries no arrow / FROM-TO shape, so no-migration-prescription is admissible, while type-surface-only is not (its predicate 2 refuses a diff touching packages/spec). So the changeset owes a **BREAKING for TypeScript and JavaScript callers of nextUtcCalendarDay** banner and the adr-0087: not-required (no-migration-prescription) … marker in the gate's HTML-comment spelling, keeping Clause-②: yes (widening) and the minor bumps as they are. Option A's grounds (nothing removed or renamed, every in-repo caller migrated, the pinned sibling has no caller) are all true and none of them is the test the repo applies to a published TypeScript surface. This is a rules judgment the maintainer may overrule to A; the record's other FAIL reason stands either way.

③ Boundary flags

  • Deviation 1 (PR body verifies a9223b7a7b; the head's results live in the report): acknowledged; the head's check-runs are the verdict of record and are read above. Not a defect.
  • Deviations 2, 3, 5 (PG data dir outside the scratchpad and removed; model-free trailer; lock contention with every suite eventually run): acknowledged, nothing to act on.
  • Deviation 4 (formula and driver-memory suites at f7a61d512e, not the head): the head's delta is one test type annotation; Test Core is green on the head. Closed.
  • NOT MEASURED locally (check:dual-build-cjs-loads, check:type-check-debt; MySQL; the per-package-only typecheck): Type Check · debt ledger and Temporal Conformance (live PG + MySQL) are green on the head; the whole-workspace program is the one that is red, and it is this diff's — FAIL reason 1 above. Escalated to the dev: the fix must hold in @objectstack/dogfood's program and in a published consumer's, not only under source path-mapping (e.g. the helper's exported shape, or a guard the readers share, that does not depend on the nominal identity of a unique symbol across index.d.mts / index.d.ts), and the record wants the whole-workspace turbo run typecheck measured, not the ten per-package runs.
  • Real mongod not run (opt-in): no workflow enables OS_TEST_MONGODB_MEMORY_SERVER_ENABLED, so the kit's mongo row cell never runs in CI — a pre-existing coverage gap the seat should know about; the translator is pinned server-free here. Escalated as a note, not this diff's.
  • driver-sqlite-wasm, unnamed by the dev: inherits SqlDriver; its kit consumer ran in Test Core (green). Closed.
  • Open question (Clause-②): answered B in ② with the evidence; overrulable by the maintainer.
  • Out-of-scope finding 1 (memory cube face converts before it widens): confirmed from the code, an ADR-0053 D-E ordering breach reachable only through the @objectstack/driver-memory export. Escalated to the seat to file as a class-(a) card with the dev's dedupe words; not this diff's.
  • Out-of-scope findings 2 and 3 (Turso remote $null key sharing; having $between over a null aggregate): confirmed as read from the code and pre-existing in class; noted, not filed, not this diff's.
  • Type-blind widening on a non-temporal field (acceptance note): the memory and mongo where, the memory cube lte and native-SQL lte compile "has a value" for a last-day $lte on a text or number column, as their existing bounded rule already widened there; the SQL drivers and having scope to datetime and are exact. Accepted as consistent; no new class.
  • Docs: the Docs Drift comment names ten hand-written pages by symbol (advisory); none states the last-day exception. No doc change is owed by this card, noted for the seat.

FAIL reasons: (1) Type Check · workspace is red on this head and green on origin/main and the merge-base, on this PR's own lines (turso-driver.ts 2640–2641) — the unique symbol published through two type entries has two nominal identities, so the readers' === UNBOUNDED_ABOVE guard does not narrow in a program that resolves the constant under both conditions; (2) the changeset omits the **BREAKING** banner and ADR-0087 disposition the repo's rules and the #19920 precedent require for a published, compile-visible return-type change (maintainer may overrule to A).

Implemented-by: claude/issue-20600-last-day-unbounded-above
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: FAIL


Generated by Claude Code

…hared guard, one type across the ./data declaration files

A unique symbol is nominal to each declaration file, and @objectstack/spec
ships ./data as index.d.mts and index.d.ts; a program meeting both saw two
unrelated types (TS2367 / TS2339 in @objectstack/dogfood). The sentinel's type
is now symbol & a structural brand, and every reader narrows with
isUnboundedAbove.

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…UtcCalendarDay, with its ADR-0087 disposition (seat ruling B)

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…bove and isUnboundedAbove

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 49a2585988b92e62dead92c7bd1ac2894009e59b
Local-runs: none

Inputs: card #20600 (body; triage 5886142901; claim 5886817601; round-1 report 5891602308; the seat's round-2 ruling 5892121354; round-2 report 5894482668), PR #20643 (its body as patched by the seat from the round-2 pr_body_lines; its two comments, the round-1 record 5891983885 (FAIL) included; the 26-file list; the net diff against the merge-base f4ce10c89d, 26 files, +903/−66, matching the file list), the 42 check-runs on the head (read once, 2026-09-29T16:45Z), and git show / git grep against the head, origin/main bae38590a8, the merge-base and the round-1 head e197faa978. Nothing built, run or re-run.

① Derived judgments

Round-1 reason 1 (two nominal identities): closed, from the code and from CI. The sentinel's type is UnboundedAbove = symbol & { readonly __objectstackCalendarDayBound: 'unbounded-above' } (calendar-day.ts line 67); UNBOUNDED_ABOVE is Symbol.for('objectstack.calendarDay.unboundedAbove') as UnboundedAbove (line 81); isUnboundedAbove(value: unknown): value is UnboundedAbove is value === UNBOUNDED_ABOVE (lines 90–92). An alias to an intersection of the symbol primitive and an anonymous object type is compared structurally, so the copy in dist/data/index.d.mts and the copy in dist/data/index.d.ts are mutually assignable: comparable (no TS2367), and a predicate typed against one narrows a union holding the other. The measurement agrees: Type Check · workspace (109477605512; pnpm exec turbo run typecheck --filter='./packages/*' --filter='./packages/*/*' --filter='./apps/*', which takes in packages/qa/dogfood, whose tsconfig path-maps @objectstack/driver-turso to source while spec, core and driver-sql resolve to built types — the mixed program that was red at e197faa978) is success on this head, as are Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger and the TypeScript Type Check rollup.

The ruling's ⛔ holds: the sentinel is not hidden inside string. symbol & {…} is disjoint from string, so no caller can compile the member as a bound: a template literal is refused (TS2731 — the intersection carries the symbol flag), a relational operator is refused (the type is not number-, string- or bigint-like), a string parameter or target is refused (TS2345 / TS2322). Two precisions the dev's prose blurs, neither a defect: (a) === UNBOUNDED_ABOVE DOES narrow its true branch (equality narrowing filters the union by comparability) and does NOT narrow its false branch (the branded type is not a unit type), so a reader that used === and then used the answer as a string would still be refused, not silently compiled — the guard is the right tool because it narrows both branches; (b) typeof answer === 'symbol' narrows equally. No reader at this head uses === (census below).

Runtime value and guard: right. Still Symbol.for(…), so every bundled copy (ESM, CJS, browser) answers one value. The guard answers true for the constant and for Symbol.for of its key, false for an unregistered Symbol('objectstack.calendarDay.unboundedAbove'), for null, undefined, '10000-01-01', '9999-12-31', {} and the control day's answer (pinned, calendar-day.test.ts lines 1342–1356). nextUtcCalendarDay answers UNBOUNDED_ABOVE for 9999-12-31 and for no other day of the last year (the day-by-day walk pins exactly one); null still means "not a calendar day" (an instant on that day, a Date, 9999-12-32, 10000-01-01); utcInstantMs('9999-12-31') reads === null, unchanged and right (the symbol is not null, so the day's midnight is read; pinned).

Reader census (my own git grep at the head, non-test source): 17 direct call sites of nextUtcCalendarDay in 9 files plus the Turso indirect reader, every one narrowing with isUnboundedAbove, none comparing with ===.

  • memory-analytics.ts 273 → 274 (cube lte, mingo: { $ne: null }), 468 → 469 (cube lte, SQL echo: IS NOT NULL), 1005 → 1006–1007 (dateRange: the start alone, both stored forms).
  • memory-driver.ts 1403 → 1404 (AST less-or-equal: $ne: null), 1478 → 1479 (AST between: $gte alone), 1728 → 1729 ($between: break after the $gte write), 1745 → 1746 ($lte: put('$ne', null), collected so an author's own $ne survives beside it — pinned).
  • mongodb-filter.ts 1165 → 1166 ($lte: put('$ne', null), pinned as an $and beside an author's $ne), 1259 → 1260 ($between: the min alone).
  • sql-driver.ts 15418 → 15420 (calendarDayExclusiveUpperBound hands the sentinel on), consumed at 15443 (calendarDayUpperBoundRewrite), 16976 ($between emitter: a bounded flag drops the $lt leg on both the raw-column and the normalised-column path) and 16990 ($lte emitter: whereNotNull / orWhereNotNull on the raw column — right, a legacy-repair columnExpr is NULL exactly when the column is).
  • turso-driver.ts 2640 (toRemoteUpperBound hands it back), 2583 ($between: $gte alone), 2592 ($lte: { $null: false }, an arm the remote transport compiles as IS NOT NULL, remote-transport.ts 3293–3297).
  • matches-filter.ts 865 → 866 (lteBound: an instant is admitted, anything else compared as written).
  • having-filter.ts 1325 → 1326 (wholeDayUpperBound), consumed at 1425 ($lte: a null aggregate fails, otherwise no bound) and 1441 ($between: the min alone).
  • preview-evaluator.ts 105 → 106 (lteBound), 685 → 690 (dateRange: inUpper true).
  • native-sql-strategy.ts 545 → 551 (dateRange: the lower clause alone), 1244 → 1248 (lte: IS NOT NULL).
  • objectql-strategy.ts 548 → 551 (dateRange echo: $gte alone).
    Plus calendar-day.ts 158 (utcInstantMs, === null, right), the core re-export (datetime.ts 199–200, reaching the root through export * from './utils/datetime.js'), and the two test readers (analytics-date-range.test.ts, date-range-presets.test.ts) now asserting a string. No caller outside packages/ (the only other hit is the read-only release page content/docs/releases/v17/17-5.mdx); none in the pinned objectui (git grep at dd3f7e1be3: empty); driver-sqlite-wasm extends SqlDriver and calls nothing itself. No site left silent.

Per operator, each right. A lone $lte / AST less-or-equal on the last day compiles only "has a value" on every emitter (SQL IS NOT NULL; memory and mongo $ne: null; Turso remote $null: false; having a null check; native-SQL IS NOT NULL). A $between / between maximum and an explicit analytics dateRange end keep the minimum alone on every reader (memory in both stored forms, preview, native-SQL, the ObjectQL echo whose driver leg compiles the same not-null). The type-blind formula check and draft preview admit any value utcInstantMs reads as an instant and compare anything else as written. $gte / $gt / $lt / $eq and every earlier day are unchanged (no reader applies the whole-day rule to $eq; pinned as the midnight instant on REST). The $not and $or shapes over the unbounded $lte are pinned on SQL and memory ($not answers exactly the null row).

Conformance kit: right. Row z_last (9999-12-31T10:00Z, native) and five last-day cases; I re-derived the five expected lists and the three amended $gte / $gt lists against the ten rows: right (the ['2026-07-29', '9999-12-31'] window keeps f_next on its inclusive midnight and drops h_leap; the one-day window answers z_last alone; the 9999-12-30 control drops it; the date $lte keeps all ten). Consumed by driver-sql (SQLite in Test Core, PG and MySQL in Temporal Conformance (live PG + MySQL)), sqlite-wasm, turso local and remote, memory, formula, service-analytics and objectql — all green on this head.

Public surface, each named. (1) @objectstack/spec ./data gains exactly UNBOUNDED_ABOVE (const), UnboundedAbove (type) and isUnboundedAbove (function): api-surface/data.json +3 and export-origins/data.json +3, all three mapped to src/data/calendar-day.ts, nothing else moved — right; check:generated sits inside the green Lint & Repo Gates. (2) @objectstack/core re-exports the three (core has no api-surface shard; nothing owed). (3) nextUtcCalendarDay's return widens to string | UnboundedAbove | null on both packages: a compile-visible widening, the BREAKING carrier in ②. (4) Not an export, but named because it is compile-visible to a subclasser: SqlDriver's three protected calendar-day seams widen their declared answers; calendarDayExclusiveUpperBound (unknown | UnboundedAbove | null) and calendarDayBetweenRewrite.upper collapse to unknown (no visible change), while calendarDayUpperBoundRewrite now declares { op; value } | UnboundedAbove | null, so an out-of-repo subclass reading .op without the guard stops compiling — what TursoDriver met. A protected seam is not a Clause-② public-surface item, and the changeset's remedy paragraph (narrow with isUnboundedAbove) is the same remedy; flagged in ③, not a defect. (5) No accept set narrows: every filter accepted before is accepted after; the only answers that move are last-day upper bounds, which answered no rows (SQLite) or the day (PG) and now answer the whole day everywhere.

The merge of origin/main f4ce10c89d (commit 6f1f2869a3): nothing of main's lost. The combined diff (git show --cc) is empty, so no hunk was hand-resolved; the files main moved in f1e921ab8e..f4ce10c89d (48) and the files the branch moved are disjoint; the diff main contributed through the merge equals main's own diff (48 files, +3923/−248 both ways); main did not touch api-surface/data.json or export-origins/data.json in the window, so keeping the branch's bytes was right, and the two regenerated lines at 49a2585988 are the only later delta.

The dev's new noted item (other exported unique symbols): this diff adds none and leaves none on its own surface. The net diff contains unique symbol only in prose; calendar-day.ts declares none; the one exported unique symbol in packages/spec/src (contracts/automation-service.ts RESUME_AUTHORITY_SERVICE) is untouched and outside this card; formula's NO_OFFSET_BASE is module-private. The wider class is not this card's.

Gate coverage (42 check-runs on the head, read once): 36 success, 5 skipped, 1 in progress, 0 failure. Type identity → Type Check · workspace, · source gates, · consumer gates, · debt ledger, rollup TypeScript Type Check: success. The runtime answer, every reader's pins, the REST door (SQLite cell) and the kit's SQLite / sqlite-wasm / turso / memory / formula / analytics / objectql consumers → Test Core (6 shards + rollup): success. PG and MySQL → Temporal Conformance (live PG + MySQL): success. Generated shards, ADR anchors, lint and the spec surface gates → Lint & Repo Gates: success. Build Core, Dogfood Regression Gate (3 shards + rollup), Dogfood Verify CLI, Spec property liveness, Governed Surface Queue Guard, Flag docs affected by code changes, Check Documentation Links, the five PR-hygiene checks, Auto Label, Check PR Size, filter: success. Skipped by filter or opt-in: Build Docs, Console Pin Gate (pin unchanged, no sibling caller), Packed-tarball smoke, and the Auto Label / Check PR Size cells of the 16:40 batch. ⛔ Not concluded at read time: Check Changeset 109512530147 (in_progress), re-fired by the seat's PR-body edit (edited is a subscribed event: the job reads the Clause-② line from the PR payload). Its earlier run on this same head, 109477663577, concluded success with the identical changeset file and the same Clause-②: yes (widening) token; I do not presume the re-run green — the seat reads its conclusion before landing.

② Semver level

Levels per package: right. @objectstack/spec minor (three new exports and a widened published answer), @objectstack/core minor (re-export of the three), driver-sql, driver-turso, driver-memory, driver-mongodb, formula, objectql, service-analytics patch (a bug fix in a released package, no export added). All nine sit in the fixed group of .changeset/config.json, so the group bumps minor. @objectstack/rest changes a test only and correctly takes none; driver-sqlite-wasm moves no source. Not skip-changeset. The no-major gate's level axis (a PR declaring yes grades at least one moved package minor or above) is met.

Clause-②: yes (widening) — right, well-formed, carried in both places (PR body line 3 as patched by the seat; changeset line 15), naming the three exports on spec data and core. The arm is right on clause2-line.mjs's vocabulary: the accept set does not narrow and the public surface grows; (narrowing) would be wrong-worded.

Round-1 reason 2 (ruling B): closed. The changeset carries **BREAKING for TypeScript and JavaScript callers of nextUtcCalendarDay** (the gate's **BREAKING signal), re-derived for the new shape (the structural brand; isUnboundedAbove or typeof as the narrowing; === compares without narrowing; the JS TypeError and the better-sqlite3 / pg bind refusals), and exactly one ADR-0087 marker, adr-0087: not-required (no-migration-prescription) …, in the gate's HTML-comment spelling. The category is admissible: the body carries no FROM/TO block and no arrow, and its "If your code stops compiling" paragraph is the shape the #19920 precedent (.changeset/19920-exported-types-family-close.md, released in PR #17076) carried under the same disposition; type-surface-only is not claimable (its predicate 2 refuses a diff that moves packages/spec). The one concluded Check Changeset run on this head (109477663577) accepted the file; the re-run is named in ①. Banner and marker match ruling B to the letter, at minor; the maintainer may still overrule to A.

③ Boundary flags

  • Deviation 1 (CI commands run with --concurrency=2 --output-logs=errors-only, bounded, over several lock acquisitions): acknowledged; the head's check-runs are the verdict of record and are read above. Not a defect.
  • Deviations 2, 6, 7 (background lock waits and queue-timeout retries; worktree recreated at the pushed head; a restart notice that was right): nothing to act on.
  • Deviation 3 (one turbo run typecheck --filter=@objectstack/dogfood --only outside the lock executed dogfood's tsc, exit 0): acknowledged; CI's workspace run is the measurement. Closed.
  • Deviation 4 (round-2 full per-package suites not re-run): Test Core (6 shards) is green on this head. Closed.
  • Deviation 5 (no live PostgreSQL or MySQL this round): Temporal Conformance (live PG + MySQL) is green on this head and drives the five new kit cases. Closed.
  • Deviation 8 (check:dual-build-cjs-loads exit 3 on five unbuilt packages, then 0 after building them): acknowledged; the repo gates on the head are green. Closed.
  • open_questions: none declared.
  • Out-of-scope 1 (memory cube face converts before it widens): carried per the ruling; the seat files it. Not this diff's.
  • Out-of-scope 2 (Turso remote lowering writes into one operator map): confirmed from the code, and stated more exactly than the dev did: the class is pre-existing ($lte to $lt and $between to $gte already share a key an author can write), but the $null member IS this diff's — toRemoteFieldSpec wrote no $null before. Reach: only a self-contradicting filter, { $lte: '9999-12-31', $null: true } on a remote Turso datetime column, where local mode and every other driver answer the empty set and remote answers the null rows or the non-null rows depending on key order. Not a FAIL reason (the ruling keeps it noted, and no sane author writes it), but the class is a contract divergence between the two Turso modes; ESCALATED to the seat to file as one card under Prime Directive chore: version packages #10, naming the three shared keys.
  • Out-of-scope 3 (having $between over a null aggregate): pre-existing, read from the code; noted, not this diff's.
  • Out-of-scope 4 (other exported unique symbols across d.ts / d.mts): judged in ①: this diff adds none and leaves none on its surface. The wider census is the seat's to weigh as a card of its own, not this one's.
  • SqlDriver protected seams (① item 4): the banner is scoped to callers of nextUtcCalendarDay; a subclasser of SqlDriver reading calendarDayUpperBoundRewrite's answer meets the same member and the same remedy. Flagged for the seat to decide whether the banner should name it; not a defect, not a Clause-② item.
  • Type-blind widening on a non-temporal field (acceptance note): consistent with the emitters' existing bounded rule and with each other (formula and preview admit an instant, a finite number included, and compare anything else as written); SQL and having scope to datetime. Accepted; no new class.
  • Real mongod (opt-in) still not run in CI: the pre-existing gap round 1 named; the translator is pinned server-free by mongodb-filter.test.ts. Note stands.
  • Docs: the Docs Drift comment lists ten hand-written pages by symbol name (advisory); none states the last-day exception. No doc change is owed by this card; noted for the seat.
  • Check Changeset re-run in progress: named in ①; the seat reads its conclusion before landing.

Implemented-by: claude/issue-20600-last-day-unbounded-above
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 17:01
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 1a75e39 Sep 29, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20600-last-day-unbounded-above branch September 29, 2026 17:27
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
… authoring and at construction (objectstack-ai#20586) (objectstack-ai#20669)

Fixes objectstack-ai#20586
Clause-②: yes (narrowing)

The `Clause-②` line above is the claim's (comment 5891827128), copied as
it stands. The changeset carries the same value.

Session `session_01DEvba2nBuD4tWzfq8r8NFY` (PM dispatch, `domain:engine`
seat 1, mode:subagent), branch
`claude/issue-20586-forced-local-refuses-sync-url`. The container
restarted mid-run. The branch was fast-forwarded to `origin/main`
`f4ce10c89` (BASE) before the first edit, and later got a true merge of
`origin/main` at `6bff748bb`. **Every final reading below was taken at
head `cfe05ec40`** unless it says otherwise.

## What changes

A turso config that forces `mode: 'local'` on a `file:` url (or
`:memory:`) beside a non-empty `syncUrl` is now refused at both doors,
with one message. Triage 5884612522 directed this: "A forced `mode:
'local'` beside `syncUrl` (or `sync`) is refused at both schema copies
and at the constructor, naming the conflict."

- **Authoring.** `tursoTransportIssues` in
`packages/spec/src/data/driver/turso.zod.ts` gains a forced-local arm,
placed after objectstack-ai#20437's forced-replica arm. It returns one `custom` issue
on **`mode`**, the key the runtime would ignore, as objectstack-ai#20437's does. It
reaches `DatasourceSchema` (as `config.mode`), `validateDriverConfig`,
`defineStack` / `os validate`, and a save or test connection through the
datasource admin service.
- **Construction.** `new TursoDriver()` refuses the same config with
`VALIDATION_ERROR` / 400, before `super()`, as the last of the sync-key
refusals. The message is a module constant,
`LOCAL_MODE_WITH_SYNC_URL_REFUSAL`, next to objectstack-ai#20437's
`REPLICA_MODE_WITHOUT_SYNC_URL_REFUSAL`. It is thrown through the same
`refuseIgnoredSyncKey` helper, and the parity table pins it byte-equal
to the schema's issue.
- **The driver mirror**
(`packages/drivers/driver-turso/src/spec/turso.zod.ts`) carries the same
arm byte for byte. The mirror declares no `mode` and strips an authored
one, so the arm is unreachable through it. It stays for copy parity,
like the mirror's other forced-mode branches (see Deviations).
- **"(or `sync`)".** `sync` with no `syncUrl` was already refused in
every mode (objectstack-ai#20200). `sync` beside a `syncUrl` under a forced local mode
is refused by this new arm. So every `sync` shape under a forced local
mode is covered, with no extra arm.

The message, the same text at both doors:

> `mode: 'local'` makes this datasource a plain local database, but
`syncUrl` names a remote to replicate from: the database would still be
synced with that remote as an embedded replica, so the declared local
mode would be ignored — the turso driver refuses this configuration when
it starts. For an embedded replica, drop `mode` and keep `syncUrl`
beside the local file: `url: 'file:./data/replica.db'`. For a plain
local database, drop `syncUrl` (and `sync`).

It names both ways out. It echoes no url and no `syncUrl`, and carries
no tracker id.

## H1: the premise, measured before the change (held)

At BASE `f4ce10c89`, a temporary probe ran on the driver source (deleted
after one run, never committed). The config was a `file:` url,
`syncUrl`, `sync: { intervalSeconds: 1 }`, and a client that counts
`sync()` calls:

| config | transportMode | syncs after connect | isSyncEnabled() |
interval | syncs after 1.3 s |
| --- | --- | --- | --- | --- | --- |
| forced `mode: 'local'` + `syncUrl` | `local` | 1 | true | started | 2
|
| `syncUrl`, no `mode` (the replica control) | `replica` | 1 | true |
started | 2 |
| forced `mode: 'local'`, no `syncUrl` | `local` | 0 | false | none | 0
|

So a forced local mode beside `syncUrl` ran exactly as a replica, and
only the label said `local`. The pins holding today's answer passed at
BASE:

- the parity row "file: + syncUrl under a forced mode: 'local'" read
constructor accept and spec accept (parity file 177 passed / 22
skipped);
- the spec test's accept fixture `{ url: 'file:./data/app.db', mode:
'local', syncUrl: … }`;
- the objectstack-ai#20200 file's control "sync beside syncUrl under a forced mode:
'local' stays accepted".

`isSyncEnabled()` is `!!this.tursoConfig.syncUrl && this.libsqlClient
!== null`, as H1 states.

## H2: objectstack-ai#20437 is the template (held), and what differs in this direction

It is mirrored arm for arm: one spec arm on `mode`, a byte-identical
mirror arm, a module constant, one constructor check, a new refusal test
file, a parity flip and a new D3 entry. What differs:

- The ignored key is `mode`, as in objectstack-ai#20437, but the cause is the
opposite. A `syncUrl` is present, so the conflict is between two
declared keys, not a missing one. The message therefore offers "drop
`mode`" (replica) or "drop `syncUrl` (and `sync`)" (local).
- The arm is reachable on an in-memory url too. A local mode accepts
`:memory:` (`localEngineDefect` passes it), so `:memory:` and
`file::memory:` beside `syncUrl` under a forced local mode meet this
refusal. The replica way out points at a file url, so it is correct
there as well.
- No config meets two issues here. objectstack-ai#20437 has one row where the schema
raises `sync` and `mode` together. This arm needs a non-empty `syncUrl`
and the `sync` refusal needs none, so they are exclusive, and every row
here raises exactly one issue.

## H3: ADR-0087 disposition — a new D3 entry, `registered`

Neither existing turso entry's `surface` names this shape.
`turso-config-transport-mismatch-refused` lists the url, in-memory,
WebSocket and remote-`syncUrl` combinations.
`turso-config-forced-replica-without-sync-url-refused` is the opposite
shape. The gate accepts `registered` only with an id that is new in this
diff, and `already-registered` only for an id that already covers the
refusal. So a new entry lands, following objectstack-ai#20437's precedent:
`packages/spec/src/migrations/entries/semantic/18.turso-config-forced-local-with-sync-url-refused.ts`,
id `turso-config-forced-local-with-sync-url-refused`.

- `src/migrations/registry.ts` was regenerated by
`gen:migration-registry`, never by hand: +50 / -0, one entry. It reads
`320 semantic, 236 retired-key, 207 retired-def`.
- The changeset carries the disposition marker `registered
turso-config-forced-local-with-sync-url-refused`.
- `check-adr-0087-registration` reads it as
`[BREAKING+bang+clause-②-narrowing] registered
turso-config-forced-local-with-sync-url-refused (new here: …)`.
- `check:generated` reads "All 15 generated artifacts are up to date",
including `check:spec-changes` and `check:upgrade-guide`.

## H4: refusal order (held; the new arm is last)

- **Spec.** The arm sits after objectstack-ai#20437's forced-replica arm, inside the
non-remote branch. Every url-shape refusal returns first, and they are
unchanged.
- **Constructor.** The check sits after the forced-replica check, which
is last among the existing refusals.
- **Exclusivity.** Every other refusal is exclusive of this one except
the url refusals, which both doors raise first. The remote refusals need
remote mode. The `sync` refusal needs no `syncUrl`. The replica refusal
needs a replica mode.
- **Pins.** ORDER rows cover a remote url, a bare path, `sync` with no
`syncUrl`, and objectstack-ai#20437's forced replica with no `syncUrl`. Each keeps its
own message.

## H5: producer census (before any edit, at BASE `f4ce10c89`, repo
`objectstack-ai/objectstack`)

| query | hits |
| --- | --- |
| `git grep -E "mode:\s*['\"]local['\"]\|\"mode\"\s*:\s*\"local\""`
(whole tree) | 49 in 11 files: 11 in two CHANGELOGs, 38 in
`packages/drivers/driver-turso` and `packages/spec` (source, tests,
README). Of those, 3 author the shape beside a `syncUrl`, all tests: the
parity row, the spec accept fixture and the objectstack-ai#20200 control, each flipped
here. The other `mode: 'local'` spellings carry no `syncUrl` or are
describe labels |
| `syncUrl` / `sync_url` / `SYNC_URL`, case-insensitive, per tree (turso
control count in brackets) | `examples/` 0 [2],
`packages/create-objectstack` 0 [0], `skills/` 0 [6], hand-written
`content/docs` 0 [109], `apps/` 0 [0] |
| env names read in `packages/**/src` (non-test) matching `TURSO_*` /
`OS_DATABASE_*` / `OS_TURSO_*` | `OS_DATABASE_URL`,
`OS_DATABASE_DRIVER`, `OS_DATABASE_AUTH_TOKEN`, `OS_DATABASE_POOL_MAX`,
`OS_DATABASE_SQLITE_JOURNAL_MODE`, `TURSO_DATABASE_URL`,
`TURSO_AUTH_TOKEN`, `TURSO_TOKEN` (plus code constants). None maps to
`mode` or `syncUrl` |
| who sets a turso `mode` | only `buildTursoDriverConfig`'s `mode`
reader
(`packages/services/service-datasource/src/turso-driver-config.ts:205`),
from an authored `datasource.config.mode`. Its two callers are
`packages/runtime/src/turso-driver-factory.ts:288` and
`packages/services/service-datasource/src/default-datasource-driver-factory.ts:1337`.
No other non-test `new TursoDriver` / `createTursoDriver` call sets
`mode` |

No shipped in-repo producer authors the shape, and no deployment default
sets it, so the `needs_decision` branch does not trigger.
**`objectstack-ai/cloud`: NOT MEASURED.** Attaching it read-only was
refused by the session's permission classifier. The seat or the
maintainer should census cloud before this lands.

## Tests (at `7ee1acb58`, the last code commit; the merge after it
touched no turso or spec path)

| suite | result |
| --- | --- |
| `@objectstack/driver-turso` vitest, whole package | 80 files · 2175
passed · 33 skipped · exit 0 |
| `@objectstack/driver-turso` typecheck (`tsc --noEmit`) | exit 0. `tsc
--listFilesOnly` lists all 3 touched test files |
| `@objectstack/spec` vitest `--project local`, 3 shards | 575 files ·
16946 passed · 1 todo (6106 + 5231 + 5609), exit 0 each |
| `@objectstack/spec` typecheck (tsc + scripts + `check:test-typecheck`)
| exit 0 |
| `@objectstack/spec` `check:generated` (after the spec build) | "All 15
generated artifacts are up to date" |

The 33 skips are the parity table's forced-mode rows for the mirror,
which strips `mode`. There were 22 before; this PR adds 11: 8 `mode`
rows, 2 ORDER `url` rows and 2 accept controls, less the 1 row that
moved.

- **`spec/turso-config-constructor-parity.test.ts`.** The row "file: +
syncUrl under a forced mode: 'local'" flips from `accept` to `refuse`,
`refusedOn: 'mode'`. Seven more `mode` rows are added: no `sync`, an
uppercase `FILE:` url, a url behind whitespace, `timeoutMs`, a `wss://`
`syncUrl`, `:memory:` and `file::memory:`. Two ORDER rows keep their
`url` refusal (a `libsql://` url and a bare path, each beside `syncUrl`
under a forced local mode). Two accept controls are added: a forced
local mode alone, and one beside an empty `syncUrl`. `SYNC_KEY_REFUSALS`
takes the new `mode` rows, so each of the 8 asserts the constructor's
message equals the spec issue's. New floors: `mode` at least 12, the
forced-local `mode` rows at least 8, sync-key refusals at least 20. The
unforced `file:` + `syncUrl` replica row ("a replica: file: + syncUrl")
is the unchanged control triage names.
- **`turso-driver-ignored-sync-key-refusal.test.ts`.** The control "sync
beside syncUrl under a forced mode: 'local' stays accepted" is removed,
because it pinned the defect. The header points to the new file. The
objectstack-ai#20200 refusal assertions are untouched.
- **`turso-driver-forced-local-with-sync-url-refusal.test.ts` (new).**
The refusal is asserted as the envelope (`code` + `status`), plus its
first sentence and both ways out. It covers `file:` and `FILE:` urls,
`:memory:`, `file::memory:`, and a config beside `sync`, `timeout` or
`encryptionKey`. With a supplied client it is refused before any client
work. It also covers `createTursoDriver()` and a check that neither url
is echoed. ORDER: a remote url, a bare path, `sync` with no `syncUrl`,
and a forced replica with no `syncUrl` each keep their own refusal.
CONTROLS: the unforced replica still connects and syncs once; a forced
local mode with no `syncUrl` or an empty one syncs nothing; a forced
replica beside `syncUrl` and `:memory:` under a forced local mode both
construct; and `detectMode` still answers `local`.
- **`packages/spec/src/data/driver/turso.test.ts`.** The accept fixture
becomes `{ …, mode: 'local', syncUrl: '' }`, because an empty `syncUrl`
is unset. A new block asserts the refusal on `mode` over 6 configs, the
url echo, url-refusal ORDER, both authoring doors (`config.mode` and
`validateDriverConfig`), and the controls (the unforced replica with and
without `sync`, and a forced local mode alone, beside an empty
`syncUrl`, and on `:memory:`).

**Reverse verification** ran through `scripts/ablation-replace.mjs` from
the committed state at `7ee1acb58`. Each direction was predicted before
the run, and all three matched:

1. **The constructor refusal disabled.** ` if (mode === 'local' &&
config.syncUrl) {` became `if (false && …) {`, and the mutation landed
(anchor 1 → 0, blob `f9af3e93c573` → `35630f956f8f`). Predicted 26 RED.
Got **26 failed** / 201 passed: the new file's 10 refusal cases, plus
the parity table's 8 constructor verdicts and 8 byte-equality pins. No
ORDER or CONTROLS case failed. Restored: blob == HEAD, `git diff HEAD`
empty.
2. **One byte of the driver copy.** A doubled space was put after the
constant's first sentence (blob → `019694bf2026`). Predicted exactly the
8 byte-equality pins. Got **8 failed**, all "the constructor's message
is the spec contract's, byte for byte", while every verdict and
first-sentence case stayed green. Restored the same way.
3. **The spec arm disabled** in
`packages/spec/src/data/driver/turso.zod.ts` (blob `eed1efdaa33a` →
`7850f9fcb53e`), against the spec's own source-level test. Predicted 3
RED. Got **3 failed** / 36 passed: the refusal, the url-echo and the
both-doors cases. ORDER and controls stayed green. Restored the same
way.

The driver tests import the driver from `src`, so ablations 1 and 2
needed no build. Ablation 3 was read on the spec's own source tests
only. The parity table's spec half reads the built spec dist, and that
half was not re-ablated.

## Gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` ran at head `cfe05ec40`, after the final
commit and the merge. It lists 10 paths vs merge base `6bff748bb` and
**91 commands**. Every command ran, with its exit code written to disk
before any pipe. `--ran` reconciliation reads `91 derived famil(ies)
accounted for — 89 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit
3)`, with 0 UNRUN.

- **NOT MEASURED (exit 3, PREREQUISITE NOT MET):**
`check:dual-build-cjs-loads` (workspace packages with no `dist/`) and
`check:type-check-debt` (it needs a whole-workspace build). Both are
CI's run.
- **Run twice:** `check:doc-formula-expressions` and
`check:lean-entry-closure` first answered exit 3. They are exit 0 after
building the `@objectstack/lint` and `@objectstack/objectql` closures.
- **Notable readings:**
- `check-adr-0087-registration`: `registered
turso-config-forced-local-with-sync-url-refused` (new here), BREAKING,
bang, clause-② narrowing;
  - `check-changeset-no-major`: "This diff introduces no `major` bump";
  - `check-empty-changeset`: exit 0;
- `check:migration-registry`, `check:spec-changes`,
`check:upgrade-guide`, `check:api-surface`, `check:authorable-surface`,
`check:docs`, `check:doc-authoring`, `check:nul-bytes`,
`check:test-source-alias`, `check:cross-package-test-inputs` and
`check:driver-conformance`: exit 0.
- **Narrowed lint:** `eslint --no-inline-config --format json` over the
9 changed TS files reports 9 files, 0 errors and 0 warnings. The
population is `eslint.config.mjs`'s lint object, `files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`; the changeset `.md` is
outside it. Invariance holds because the config enables no type-aware
linting (its one `parserOptions.project` mention is a comment saying
so), so this diff cannot move any untouched file's verdict. The full
`pnpm lint` is CI's.
- **Not run locally, left to CI:** the whole-workspace type-check lanes;
the Test Core, Dogfood and Build Core jobs; and the downstream suites of
`@objectstack/service-datasource`, `@objectstack/runtime` and
`@objectstack/cli`. The narrowing is declared. The public surface's
bytes are unchanged (`check:api-surface` and `check:authorable-surface`
are green, and refinements are not in the JSON Schema). The census above
finds no consumer fixture that authors the refused shape.

**Driver-conformance ledger:** `check:driver-conformance` read `OK — 50
covered cell(s), 0 in the DEBT ledger, 0 exempt` both before
(`f4ce10c89`) and after (`cfe05ec40`). `driver-turso` is `ok` on all 10
case-sets both times, so there was no movement.

## Deviations (declared)

- **"Both schema copies" is met as text, not as a verdict, at the
mirror.** Triage's pin names the refusal "at both schema copies". The
driver mirror declares no `mode` key and strips an authored one, so it
cannot see a forced mode. It carries the arm byte for byte and still
accepts this config, judging it as the replica its url and `syncUrl`
select. That is objectstack-ai#20437's documented reading, and the parity table skips
the mirror half of forced-mode rows. Giving the mirror a `mode` key is
outside this card: the parity test's header calls that shortness "not
this card's to change". The D3 entry's `surface` and the changeset say
this rather than claiming the mirror refuses.
- **Cloud producers were not measured** (H5). Attaching
`objectstack-ai/cloud` was refused by the permission classifier. Nothing
in-repo triggers `needs_decision`.
- **The H1 runtime probe used a supplied client.** A client that counts
syncs stood in for `@libsql/client`, so the probe made no network call.
The arm it exercises (`connect()`'s `syncUrl` branch) is the one a
driver-built client takes too.

## Acceptance notes

- `packages/drivers/driver-turso/README.md`: the constructor-refusal
list now reads four sync settings and gains an item for a forced `mode:
'local'` beside a non-empty `syncUrl`, with both ways out. Patch round 1
(`a0c4c033a`, README text only) made this change after contract review
5894260625 found the "three sync settings" sentence false at
`cfe05ec40`. Its gate re-run reconciles 91 derived, 89 run, 2
NOT-MEASURED, 0 UNRUN.
- The spec's `mode` key keeps its one-line TSDoc. The rule is carried by
the refusal text and by `TursoDriverConfig.mode`'s TSDoc in the driver,
which now names it (as does `syncUrl`'s).
- The existing D3 entries `turso-config-transport-mismatch-refused` and
`turso-config-forced-replica-without-sync-url-refused` are untouched.
Both stay true.
- Not touched: `turso-driver.ts`'s remote filter lowering
(`toRemoteUpperBound`, the `$between` / `$lte` arms), which the spec
lane's PR objectstack-ai#20643 edits. This PR's hunks there are the file header, the
`syncUrl` / `mode` TSDoc, the new constant beside the sync-key refusals,
and one constructor check.

---
_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

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

2 participants