Repository navigation
fix(spec,drivers): a datetime $lte or $between maximum on 9999-12-31 includes the whole last supported day (#20600) - #20643
Conversation
…r (red on base) Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
… 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>
…st-day-unbounded-above
… as a day, never UNBOUNDED_ABOVE Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…ABOVE Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
changeset) Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…st-day-unbounded-above
…erasing the query options Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…st-day-unbounded-above
…st-day-unbounded-above
📓 Docs Drift CheckThis PR changes 9 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 145 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…ondition | undefined Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #20600 (body; triage ① Derived judgmentsThe helper (right at run time, wrong at the type level as shipped). Reader census (my own, on Per operator, each named right: a lone Hypothesis 3 (the dev's partial falsification): true. Every call site above sits in an Conformance: right. Row Out-of-scope finding 1: confirmed from the code. Gate coverage (the check-runs on this head, read once): 35 runs — 30 success, 3 skipped, 2 failure. Green: ② Semver levelLevels per package: right. The Clause-② open question: B, as an amendment, not a re-arming. Judged on the repo's own rules: (1) ③ Boundary flags
FAIL reasons: (1) Implemented-by: VERDICT: FAIL Generated by Claude Code |
…st-day-unbounded-above
…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>
Contract reviewServed-tier: Inputs: card #20600 (body; triage ① Derived judgmentsRound-1 reason 1 (two nominal identities): closed, from the code and from CI. The sentinel's type is The ruling's ⛔ holds: the sentinel is not hidden inside Runtime value and guard: right. Still Reader census (my own
Per operator, each right. A lone Conformance kit: right. Row Public surface, each named. (1) The merge of The dev's new noted item (other exported Gate coverage (42 check-runs on the head, read once): 36 success, 5 skipped, 1 in progress, 0 failure. Type identity → ② Semver levelLevels per package: right. 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 Round-1 reason 2 (ruling B): closed. The changeset carries ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
… 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>
…a declared datetime field (objectstack-ai#20661) (objectstack-ai#20714) Fixes objectstack-ai#20661 Clause-②: no The memory analytics (cube) face now applies the whole-day rule for a bare-day `$lte` BEFORE it converts the bound to the field's storage form, as ADR-0053 D-E3 orders. On a declared `datetime` field, `where { created_at: { $lte: '2026-07-28' } }` now answers the same rows as `find()`, and the SQL echo shows the bound that runs. ## The change `packages/drivers/driver-memory/src/memory-analytics.ts`: - New module function `lteUpperBound(authored, comparand, storageForm)`. It asks `nextUtcCalendarDay` about the AUTHORED value and returns one of three decisions: `before` (a bare day: the next day's midnight, converted to storage form, exclusive), `unbounded` (`9999-12-31`, which has no next day), or `through` (anything else: the authored comparand in storage form, inclusive). - Both `lte` rows, the mingo `$match` row (`CUBE_OPERATOR_TO_MONGO_PREDICATE`) and the SQL echo row (`CUBE_OPERATOR_TO_SQL_PREDICATE`), now only render that one decision. They used to call `nextUtcCalendarDay(comparands[0])`, and `comparands` is already in storage form. On a declared `datetime` field that value is the instant `2026-07-28T00:00:00.000Z`, which the helper correctly refuses to widen. - The storage-form conversion still has one home. `comparandsFor` became `storageFormFor(cube, member)`, which returns the per-member conversion (still `driver.filterComparandStorageForm`). Each exit maps its comparands through it and passes the same function to its builder as `storageForm`, for the one bound a builder derives instead of receiving. `comparands` is still computed for every operator, so every conversion (and any refusal it raises) happens exactly as before. - `nextUtcCalendarDay` (`packages/spec` `calendar-day.ts`) is untouched. The driver's own filter path (`memory-driver.ts`) was already in this order (`nextUtcCalendarDay(val)`, then `store(nextDay)`) and is untouched. The landing site is the one the claim expected. The producer is the emitter, not `filterComparandStorageForm`: ADR-0053 D-D1 keeps the storage-form conversion operator-blind and puts the operator-sensitive rewrite on the emitters. ## Before and after, measured at the published export Built `@objectstack/driver-memory` `dist/index.mjs` (`MemoryAnalyticsService`, `InMemoryDriver`). Before = `origin/main` at `fa0a4b6610` (this branch's base; `e4e5222b7` plus one commit that does not touch this package). After = this branch. Rows `r28` `created_at: 2026-07-28T10:00:00.000Z` and `r27` `2026-07-27T10:00:00.000Z`. "Declared" means `syncSchema` with `created_at: datetime` and `made_on: date`. | field | `where` | `find()` | cube `query()` before | echo before | cube `query()` after | echo after | |:--|:--|:--|:--|:--|:--|:--| | `created_at` undeclared | `$lte '2026-07-28'` | r27, r28 | r27, r28 | `created_at < '2026-07-29'` | r27, r28 | unchanged | | `created_at` declared `datetime` | `$lte '2026-07-28'` | r27, r28 | **r27** | **`created_at <= '2026-07-28T00:00:00.000Z'`** | r27, r28 | `created_at < '2026-07-29T00:00:00.000Z'` | | `made_on` declared `date` (control) | `$lte '2026-07-28'` | r27, r28 | r27, r28 | `made_on < '2026-07-29'` | r27, r28 | unchanged | | `created_at` declared `datetime` | `$lte` a `Date` at `2026-07-28T00:00Z` (control) | r27 | r27 | `created_at <= '2026-07-28T00:00:00.000Z'` | r27 | unchanged | | `created_at` declared `datetime` | `$lte '9999-12-31'`, rows `c26` `2026-07-15T14:00Z`, `mid` `9999-12-31T10:00Z`, `none` | c26, mid, none | **c26, none** | **`created_at <= '9999-12-31T00:00:00.000Z'`** | c26, mid, none | `created_at IS NOT NULL` | | `created_at` undeclared | `$lte '9999-12-31'`, same rows | c26, mid, none | c26, mid, none | `created_at IS NOT NULL` | same | unchanged | In the last-day rows, `none` was written with `created_at: null`, and the driver's `create` stamps its `created_at` default in its place, so that row has a value. The pinned test uses no such row. ## Dispatch hypotheses - **H1, held.** At `fa0a4b6610`, both `lte` rows called `nextUtcCalendarDay(comparands[0])` (`:273`, `:468`). `comparands` came from `comparandsFor` (`:1555`, called at `:886` and `:1367`), and the builder context also carried `raw`. The fix widens from `raw[0]` and converts only the widened bound, through the same conversion the comparands take. It is one function that both rows render, so the two exits cannot drift. - **H2, held.** `$between` is not in `MONGO_TO_CUBE_OPERATOR`. Measured on both exits with a declared `datetime` field and a bare-day maximum: `INVALID_FILTER` / 400 ("declared by the Filter Protocol but cannot be compiled by driver-memory's analytics (cube) face"). No row added. The new test pins that refusal on the date shape, so widening the table to take `$between` turns it red and points at `lteUpperBound`. - **H3, held.** A `timeDimensions[].dateRange` end widens the raw `end` string before it builds the string and `Date` bounds (`:1005`), so it already follows D-E3. On a declared `datetime` field, `['2026-07-01', '2026-07-28']` answers r27, r28 before and after, the same as `find()` with `$gte` / `$lte`. Pinned, not changed. - **H4, held.** On a declared `date` field the storage form of a bare day is that bare day, and the answer and echo are byte-identical before and after (table above). Pinned. - **H5, held.** objectstack-ai#20643's `isUnboundedAbove` path still answers "has a value". Its existing pins in `memory-driver-calendar-day-upper-bound.test.ts` (undeclared field) are green. On a DECLARED `datetime` field the last day was also broken by this card's order (`<= '9999-12-31T00:00:00.000Z'`, dropping `mid`). It now answers `IS NOT NULL` / `$ne: null` and is pinned. ## Verification Tests: new `packages/drivers/driver-memory/src/memory-analytics-20661-lte-whole-day-first.test.ts`, 7 cases. Every case compares the cube's rows with `find()` and with a literal id list, and checks the echo's `WHERE`: - the card's pair, undeclared and declared `datetime`; - a full-timestamp `$lte` (stays inclusive, in storage form); - the declared `date` control; - the declared-`datetime` last day and the day before; - the `dateRange` sibling; - the `$between` refusal on both exits, asserting the envelope `code` and `status`. - `pnpm --filter @objectstack/driver-memory test`: 63 files, 1456 tests passed. - `pnpm --filter @objectstack/driver-memory typecheck` (`tsc --noEmit && tsc --noEmit -p tsconfig.typecheck.json`): exit 0. `--listFiles` on the first program lists the new test file. Reverse verification. The fix was committed first (HEAD `cb155edf6c`). The subject is loaded from `src` through the test's relative import, so no `dist` is involved. The mutation went through `scripts/ablation-replace.mjs` under a `trap` restoring `HEAD`: the anchor `nextUtcCalendarDay(authored)` was replaced with `nextUtcCalendarDay(comparand)`, which is the old order. On disk the anchor went 1 to 0, the replacement 0 to 1, and the blob `014497594cc6` became `a41c37fac411`. Run over the new file and `memory-driver-calendar-day-upper-bound.test.ts`: 2 failed, 15 passed. The two failures were the declared-`datetime` pair (`expected [ 'r27' ] to deeply equal [ 'r27', 'r28' ]`) and the declared last day (`expected [ 'c26', 'prev' ] …`). The undeclared, `date`, instant, `dateRange`, `$between` and objectstack-ai#20600 cases stayed green, which is the expected direction. Restore: the blob equals the `HEAD` blob `014497594cc6` and `git diff HEAD` is empty. Gates, at `8cae2ae398`: - `node scripts/pm/dispatch-gates.mjs --commands`: 60 commands, all exit 0. `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` first exited 3 (PREREQUISITE NOT MET, no built closure). They were re-run green after `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` (71 of 71 tasks). - `--ran` reconciliation: "60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN". Lint, narrowed and not the repo-wide run: - `eslint --no-inline-config --format json` over the 2 touched `.ts` files: 2 files, 0 errors, 0 warnings. - Both files are in eslint's own population: `--print-config` resolves a config for each, with 6 and 5 rules. - `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`; its own header says so at `:327`), so this diff cannot move the verdict for any untouched file. The `Clause-②: no` line above is the claim's. ## Acceptance notes - **Reach unchanged.** No in-repo door constructs `MemoryAnalyticsService`: `git grep 'new MemoryAnalyticsService('` finds 0 non-test hits against 31 in test files (the control). `service-analytics` `plugin.ts:245` mentions it in a comment as a registered fallback "from dev-plugin", but no code in this repo registers it. - **No other analytics or preview face has this order, by reading.** - `service-analytics` `native-sql-strategy.ts:1244` widens `values[0]` and then coerces `nextDay`. - `objectql-strategy.ts:548` widens the raw `$lte` bound. - The preview evaluator `lteBound` is type-blind and converts nothing. - `driver-mongodb` `mongodb-filter.ts:1165` widens `value` and then stores `nextDay`. - None of these was measured at runtime. - **Behind `origin/main` by 2 commits.** They were `9b384f63ae` (service-storage docs) and `5757463712` (platform-objects translations), with no path in `driver-memory`, `spec` or `core`. The branch was not merged forward; CI's merge ref covers them. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…rds instead of a tracker number (stage 6) (objectstack-ai#21593) Part of objectstack-ai#20749 Clause-②: no Stage 6 of the `domain:spec` lane's share of the runtime-string burn-down (ruling `5902360492`, form D): class (c), the case notes and names of the eight shared conformance modules in `packages/spec/src`, with the two test seams that bind their text. Every rewritten note or name now states in words what the cited decision was, or drops a citation its sentence already explained. Text only. ## What changed - **58 messages / 68 tracker ids / 40 distinct cards** in eight modules: `contracts/metadata-service-roundtrip-conformance.ts` and, in `data/`, `aggregation-conformance.ts`, `filter-comparand-type-conformance.ts`, `filter-logic-conformance.ts`, `filter-text-conformance.ts`, `filter-text-operator-declared-type.ts`, `temporal-conformance.ts`, `value-roundtrip-conformance.ts`. Fifty-two are `note` / `why` texts and six are case names. - **The selector seam** (`packages/lint/src/validate-empty-combinators.test.ts:275`): selects the same four empty-combinator cases by a phrase the rewritten notes carry instead of `objectstack-ai#5322` (A3 below). - **The name-pin seam** (`packages/services/service-analytics/src/__tests__/icontains-dialect-sql.test.ts:369`): pins the renamed infix `icontains` case verbatim, the same strength (A4 below). - One `@objectstack/spec` **patch** changeset, `Clause-②: no`: the conformance tables ship in the package's `dist` (measured: the new note text is in `dist/data/index.mjs`; the two test files ship nowhere, since `@objectstack/lint` and `@objectstack/service-analytics` publish `dist` only and neither dist carries the seam text, with the package's own symbols as the positive control). - No generated artifact moves (`check:generated` green; the tables are values behind annotated types, so the API surface is unchanged). ## Census at the base (A1) Stage 3's instrument (`census.cjs`, byte-identical copy, md5 `e5fe562290fcd1ac392bd560cb86d861`) at base `cc645f2385`: class (c) is **58 messages / 68 ids in 8 files, 40 distinct cards**, exactly stage 3's count (the `[c-SELECTOR]` 4 / 7 and `[c-NAMEPIN]` 1 / 1 rows included). Under the ~60-card bar, so one stage. Lines are the base's. | file (under `packages/spec/src`) | line:id | messages / ids | field | consumer | |:--|:--|--:|:--|:--| | `contracts/metadata-service-roundtrip-conformance.ts` | 194:objectstack-ai#6725 · 202:objectstack-ai#6725 · 280:objectstack-ai#7378 · 296:objectstack-ai#7378 · 304:objectstack-ai#7378 | 5 / 5 | `why` | none prints it: both runners title a row by its `id`; the text is a reader's note in the shared table | | `data/aggregation-conformance.ts` | 342:objectstack-ai#6409 · 398:objectstack-ai#11065,objectstack-ai#11151 · 407:objectstack-ai#11065 · 417:objectstack-ai#11152 419:objectstack-ai#11249 · 535/550/561:objectstack-ai#15546 · 577/596:objectstack-ai#6401 | 9 / 11 | `note` | the assertion message in the driver-memory, driver-sql, driver-turso, driver-sqlite-wasm, objectql and mongodb-translation suites | | `data/filter-comparand-type-conformance.ts` | 153:objectstack-ai#7956 · 160:objectstack-ai#7872 · 272/282/289:objectstack-ai#19757 (names) · 178:objectstack-ai#5234 · 191:objectstack-ai#6050 (notes) | 7 / 7 | 5 `name`, 2 `note` | names are test titles in five driver suites and in `filter-save-door-face-parity.test.ts`; notes are assertion messages | | `data/filter-logic-conformance.ts` | 339/362:objectstack-ai#3774 · 420:objectstack-ai#5322 · 426:objectstack-ai#5322,objectstack-ai#5134 · 432/438:objectstack-ai#5322,objectstack-ai#5297 · 470:objectstack-ai#5298 · 476:objectstack-ai#5146 · 503/509:objectstack-ai#5298,objectstack-ai#13356 · 539:objectstack-ai#5299,objectstack-ai#5962 · 561/567/573/579/595:objectstack-ai#20444 | 16 / 22 | `note` | the assertion message in the eleven filter-logic harnesses; **the one selector**: `validate-empty-combinators.test.ts:275` picks the four `objectstack-ai#5322` notes | | `data/filter-text-conformance.ts` | 300:objectstack-ai#8934 (name) · 309:objectstack-ai#8934 · 349:objectstack-ai#6518,objectstack-ai#6682 · 428:objectstack-ai#4706 · 436:objectstack-ai#5710,objectstack-ai#6993 · 452:objectstack-ai#5240 | 6 / 8 | 1 `name`, 5 `note` | names are test titles in the text-conformance suites; **the one name pin**: `icontains-dialect-sql.test.ts:369`; notes are assertion messages | | `data/filter-text-operator-declared-type.ts` | 392:objectstack-ai#8296 · 553:objectstack-ai#8371 · 554:objectstack-ai#8296 | 3 / 3 | `note` | the assertion message in `engine-text-operator-declared-type-door.test.ts` | | `data/temporal-conformance.ts` | 211:objectstack-ai#3773 · 215/216:objectstack-ai#3777 · 220:objectstack-ai#20600 (row `why`) · 270:objectstack-ai#3777 · 393:objectstack-ai#3773 · 448/457:objectstack-ai#1874 · 549:objectstack-ai#3994 (case `note`) | 9 / 9 | 4 `why`, 5 `note` | a row `why` is seeded as DATA into each harness's `why` string column (never filtered on); a case `note` is an assertion message | | `data/value-roundtrip-conformance.ts` | 213:objectstack-ai#11535 · 225/226:objectstack-ai#11782 | 3 / 3 | `note` | printed in the TEST TITLE `round-trips NAME (NOTE)` in five driver suites | No non-test code reads any of these texts (every `.note` / `.why` read outside a test is another table's: `RETIRED_FILTER_OPERATORS`, the driver-conformance ledger). ## Delivered (A2): each site, the decision as read, the new words Each cited card was read with its body and every comment; the decision column names the record (comment id, or the landing record where the card carries none). Line = the base line of the edit anchor. "Old → new" shows only the words that changed. | file:line | ids | decision as read | old → new | |:--|:--|:--|:--| | `contracts/metadata-service-roundtrip-conformance.ts:194` | objectstack-ai#6725 | objectstack-ai#6725: card answers 404 here; landing record: changesets 1507ba3 / bbee302 (objectql and spec CHANGELOGs): MetadataFacade.register('object') wrote into a map none of its own object reads consulted | objectstack-ai#6725 would have failed → that catches an object write landing in a store none of the object reads consult — the hole a shipped implementation once fell through | | `contracts/metadata-service-roundtrip-conformance.ts:202` | objectstack-ai#6725 | objectstack-ai#6725: card answers 404 here; landing record: changesets 1507ba3 / bbee302 (objectql and spec CHANGELOGs): MetadataFacade.register('object') wrote into a map none of its own object reads consulted | produced objectstack-ai#6725 → let an object write land in the generic store while every object read looked elsewhere | | `contracts/metadata-service-roundtrip-conformance.ts:280` | objectstack-ai#7378 | objectstack-ai#7378: maintainer ruling 5261734580 (2026-08-12): row 1 refuse a name/data.name disagreement, row 2 one answer converged with check:meta-type-normalized, row 3 refuse non-object data | objectstack-ai#7378, maintainer 2026-08-12, row 1 → maintainer 2026-08-12, row 1 of the round-trip ruling | | `contracts/metadata-service-roundtrip-conformance.ts:296` | objectstack-ai#7378 | objectstack-ai#7378: maintainer ruling 5261734580 (2026-08-12): row 1 refuse a name/data.name disagreement, row 2 one answer converged with check:meta-type-normalized, row 3 refuse non-object data | objectstack-ai#7378, maintainer 2026-08-12, row 2 → maintainer 2026-08-12, row 2 of the round-trip ruling | | `contracts/metadata-service-roundtrip-conformance.ts:304` | objectstack-ai#7378 | objectstack-ai#7378: maintainer ruling 5261734580 (2026-08-12): row 1 refuse a name/data.name disagreement, row 2 one answer converged with check:meta-type-normalized, row 3 refuse non-object data | objectstack-ai#7378, maintainer 2026-08-12, row 3 → maintainer 2026-08-12, row 3 of the round-trip ruling | | `data/aggregation-conformance.ts:342` | objectstack-ai#6409 | objectstack-ai#6409: card body, executing the objectstack-ai#6188 split ruling: count_distinct stays declared and is enforced as distinct non-null values on the SQL family | objectstack-ai#6409: → count_distinct was kept and enforced on every face, as distinct non-null values: | | `data/aggregation-conformance.ts:398` | objectstack-ai#11065 objectstack-ai#11151 | objectstack-ai#11065: card answers 404 here; landing record: driver-memory changeset 2095040: avg/sum over a boolean answer the same numbers as every SQL face (a boolean is worth 1 or 0) · objectstack-ai#11151: PR objectstack-ai#12819 (triage 5446817173): driver-mongodb's boolean aggregand answers the ruled values too | objectstack-ai#11065/objectstack-ai#11151: an arithmetic accumulator that drops booleans answers its identity 0 here — a plausible number, which → A boolean aggregand is worth 1 or 0 on every face — driver-memory and then driver-mongodb were both moved onto that answer: an arithmetic accumulator that drops booleans answers its identity 0 here — a plausible number, which | | `data/aggregation-conformance.ts:407` | objectstack-ai#11065 | objectstack-ai#11065: card answers 404 here; landing record: driver-memory changeset 2095040: avg/sum over a boolean answer the same numbers as every SQL face (a boolean is worth 1 or 0) | objectstack-ai#11065: the rate-over-a-flag-column shape (an SLA-violation rate, a win rate). → The rate-over-a-flag-column shape (an SLA-violation rate, a win rate) that driver-memory answered null for until it counted a boolean as 1 or 0. | | `data/aggregation-conformance.ts:417` | objectstack-ai#11152 objectstack-ai#11249 | objectstack-ai#11152: maintainer ruling 2026-08-28, option A (changeset f6fa22c in the driver CHANGELOGs; decision request 5446817173): booleans aggregate as numbers, min/max answer 0/1 · objectstack-ai#11249: ruling 5386670755 (false/true for min/max over a boolean), superseded by objectstack-ai#11152's 2026-08-28 ruling | objectstack-ai#11152 ruling (2026-08-28): booleans aggregate as numbers with no per-aggregate exception, so the order statistics answer 0/1 in the same domain sum/avg answer in — not false/true (objectstack-ai#11249, superseded) → Ruled 2026-08-28: booleans aggregate as numbers with no per-aggregate exception, so the order statistics answer 0/1 in the same domain sum/avg answer in — not the false/true an earlier ruling had chosen, which this one superseded | | `data/aggregation-conformance.ts:535` | objectstack-ai#15546 | objectstack-ai#15546: ruling 5572006116 (option A): a non-empty group whose aggregand is NULL in every row sums to 0 on every face | objectstack-ai#15546: → Ruled: a non-empty group whose aggregand is NULL in every row sums to 0. | | `data/aggregation-conformance.ts:550` | objectstack-ai#15546 | objectstack-ai#15546: ruling 5572006116 (option A): a non-empty group whose aggregand is NULL in every row sums to 0 on every face | objectstack-ai#15546: the reachability control for the → The reachability control for the all-NULL-sums-to-0 | | `data/aggregation-conformance.ts:561` | objectstack-ai#15546 | objectstack-ai#15546: ruling 5572006116 (option A): a non-empty group whose aggregand is NULL in every row sums to 0 on every face | objectstack-ai#15546: the partial-null control → The partial-null control for the same ruling | | `data/aggregation-conformance.ts:577` | objectstack-ai#6401 | objectstack-ai#6401: dev report 5229051388 (PR objectstack-ai#6849): GroupByNode.alias is enforced, not removed; every SQL face projects the group under alias ?? field | objectstack-ai#6401: t → A structured group node's `alias` names the projected group column on every face — the SQL faces that ignored it were made to honour it. T | | `data/aggregation-conformance.ts:596` | objectstack-ai#6401 | objectstack-ai#6401: dev report 5229051388 (PR objectstack-ai#6849): GroupByNode.alias is enforced, not removed; every SQL face projects the group under alias ?? field | objectstack-ai#6401: the degenerate alias. Its twin above → The degenerate alias, under the same every-face `alias` rule. Its twin above | | `data/filter-comparand-type-conformance.ts:153` | objectstack-ai#7956 | objectstack-ai#7956: ACCEPT 5264821447: a measurement only; its control cell ({qty: {$eq: 100}}) returned the row on every driver, so the zeros were real answers | objectstack-ai#7956 control cell → control cell of the cross-driver comparand-type measurement | | `data/filter-comparand-type-conformance.ts:160` | objectstack-ai#7872 | objectstack-ai#7872: maintainer ruling 5265944890 and ACCEPT 5273103599 (PR objectstack-ai#8234): a shared comparand-type door; a safe-range bigint narrows to its exact number, beyond 2^53 is refused | (objectstack-ai#7872) → at the shared comparand door | | `data/filter-comparand-type-conformance.ts:178` | objectstack-ai#5234 | objectstack-ai#5234: ACCEPT 5217619370 (PR objectstack-ai#6296): a non-$field object member of $in/$nin and an object LIKE comparand are refused; each member is judged on its own | right (objectstack-ai#5234) → right, each judged like a scalar comparand | | `data/filter-comparand-type-conformance.ts:191` | objectstack-ai#6050 | objectstack-ai#6050: ruling B 5211349926: an undefined comparand is refused loudly; null keeps its meaning as the null predicate | (objectstack-ai#6050's untouched half) — → — the half left untouched when an undefined comparand was ruled a loud refusal — so | | `data/filter-comparand-type-conformance.ts:272` | objectstack-ai#19757 | objectstack-ai#19757: ruling 5793368540 (letter 乙): an array in the implicit-equality slot is refused at the shared face, for every driver at once | (objectstack-ai#19757) → at the shared face | | `data/filter-comparand-type-conformance.ts:282` | objectstack-ai#19757 | objectstack-ai#19757: ruling 5793368540 (letter 乙): an array in the implicit-equality slot is refused at the shared face, for every driver at once | (objectstack-ai#19757) → at the shared face | | `data/filter-comparand-type-conformance.ts:289` | objectstack-ai#19757 | objectstack-ai#19757: ruling 5793368540 (letter 乙): an array in the implicit-equality slot is refused at the shared face, for every driver at once | too (objectstack-ai#19757) → too, at the shared face | | `data/filter-logic-conformance.ts:339` | objectstack-ai#3774 | objectstack-ai#3774: defect: driver-sql OR-ed a $or branch's own keys and operators; rule: everything inside one filter object ANDs at every depth (card body) | objectstack-ai#3774: compiled → A $or combines its branches, never the keys inside one: a driver that OR-ed a branch's own keys compiled this | | `data/filter-logic-conformance.ts:362` | objectstack-ai#3774 | objectstack-ai#3774: defect: driver-sql OR-ed a $or branch's own keys and operators; rule: everything inside one filter object ANDs at every depth (card body) | objectstack-ai#3774: a single-key branch is miscompilable too — the operator map is looped with the same → A single-key branch is miscompilable too — that driver looped the operator map with the same OR | | `data/filter-logic-conformance.ts:420` | objectstack-ai#5322 | objectstack-ai#5322: maintainer ruling 5185589944: every face reduces an empty combinator to its boolean identity ($and:[] all rows, $or:[] none, {} TRUE, $not:{} none), whole tree | objectstack-ai#5322: a → Ruled: every face reduces an empty combinator to its boolean identity. A | | `data/filter-logic-conformance.ts:426` | objectstack-ai#5322 objectstack-ai#5134 | objectstack-ai#5322: maintainer ruling 5185589944: every face reduces an empty combinator to its boolean identity ($and:[] all rows, $or:[] none, {} TRUE, $not:{} none), whole tree · objectstack-ai#5134: defect repaired by PR objectstack-ai#5243 (ACCEPT 5178806144): driver-sql dropped an empty $and/$or group instead of applying the boolean identity; empty $or and empty $not now compile to FALSE | objectstack-ai#5322/objectstack-ai#5134: a disjunction of zero conditions matches nothing. Fail-closed for an RLS scope — a disjunct list that loops to zero items hides every row instead of exposing the table → Ruled: every face reduces an empty combinator to its boolean identity. A disjunction of zero conditions matches nothing. Fail-closed for an RLS scope — a disjunct list that loops to zero items hides every row instead of exposing the table, as a SQL lowering that dropped the empty group once did | | `data/filter-logic-conformance.ts:432` | objectstack-ai#5322 objectstack-ai#5297 | objectstack-ai#5322: maintainer ruling 5185589944: every face reduces an empty combinator to its boolean identity ($and:[] all rows, $or:[] none, {} TRUE, $not:{} none), whole tree · objectstack-ai#5297: dispatch 5184116664: read-scope-sql's { $not: {} } compiled to nothing (an RLS scope with no WHERE) and dropped a {} disjunct; both aligned to the boolean identity | objectstack-ai#5322: collapsing to the surviving branches instead compiles `a = x` — a silently NARROWED scope (objectstack-ai#5297) → Ruled: every face reduces an empty combinator to its boolean identity, so `{}` is a TRUE disjunct. Collapsing to the surviving branches instead compiles `a = x` — a silently NARROWED scope, the answer the RLS read-scope compiler gave until it was aligned | | `data/filter-logic-conformance.ts:438` | objectstack-ai#5322 objectstack-ai#5297 | objectstack-ai#5322: maintainer ruling 5185589944: every face reduces an empty combinator to its boolean identity ($and:[] all rows, $or:[] none, {} TRUE, $not:{} none), whole tree · objectstack-ai#5297: dispatch 5184116664: read-scope-sql's { $not: {} } compiled to nothing (an RLS scope with no WHERE) and dropped a {} disjunct; both aligned to the boolean identity | objectstack-ai#5322: emitting nothing for it runs the query UNSCOPED — on an RLS lowering that is a permission bypass (objectstack-ai#5297) → Ruled: every face reduces an empty combinator to its boolean identity, so NOT of `{}` is FALSE. Emitting nothing for it runs the query UNSCOPED — on an RLS lowering that is a permission bypass, which the read-scope compiler was until it compiled this to an always-false clause | | `data/filter-logic-conformance.ts:470` | objectstack-ai#5298 | objectstack-ai#5298: ruling 5202271174 (option A, confirmed 5204511921 item 4): $ne / $nin / $notContains are NULL-safe on the non-negated path, a no-value row is included | objectstack-ai#5298 → Ruled NULL-safe on every face | | `data/filter-logic-conformance.ts:476` | objectstack-ai#5146 | objectstack-ai#5146: maintainer ruling 5181102507: $not is NULL-safe on every driver; a row with no value does not satisfy the negated condition, so the negation returns it | objectstack-ai#5146: the same ruling reached through the combinator → The same NULL-safe ruling reached through the combinator, where it was first made for `$not` itself | | `data/filter-logic-conformance.ts:503` | objectstack-ai#5298 | objectstack-ai#5298: ruling 5202271174 (option A, confirmed 5204511921 item 4): $ne / $nin / $notContains are NULL-safe on the non-negated path, a no-value row is included | objectstack-ai#5298 option A → Ruled NULL-safe | | `data/filter-logic-conformance.ts:503` | objectstack-ai#13356 | objectstack-ai#13356: PR objectstack-ai#13356: driver-memory's reference matcher realigned so a no-value row satisfies $nin / $notContains | PR objectstack-ai#13356 → it was realigned to the ruling | | `data/filter-logic-conformance.ts:509` | objectstack-ai#5298 | objectstack-ai#5298: ruling 5202271174 (option A, confirmed 5204511921 item 4): $ne / $nin / $notContains are NULL-safe on the non-negated path, a no-value row is included | objectstack-ai#5298 option A → Ruled NULL-safe | | `data/filter-logic-conformance.ts:509` | objectstack-ai#13356 | objectstack-ai#13356: PR objectstack-ai#13356: driver-memory's reference matcher realigned so a no-value row satisfies $nin / $notContains | PR objectstack-ai#13356 → it was realigned to the ruling | | `data/filter-logic-conformance.ts:539` | objectstack-ai#5299 objectstack-ai#5962 | objectstack-ai#5299: ruling 5219858433 item 2, kept by 5238865560: $exists means has a value (non-null), never key-presence, since SQL cannot tell a missing key from null · objectstack-ai#5962: PR objectstack-ai#5962 (ACCEPT 5205011148 on objectstack-ai#5298): the NULL-safe operators and the $exists has-a-value reading shipped | objectstack-ai#5299 cell 2 / objectstack-ai#5962: `$exists` means HAS A VALUE, never key-presence → Ruled: `$exists` means HAS A VALUE, never key-presence, because SQL cannot tell a missing key from a stored null | | `data/filter-logic-conformance.ts:561` | objectstack-ai#20444 | objectstack-ai#20444: card body executing ruling A on objectstack-ai#20399 (5865693155): every compile surface answers $empty by the field's declared type; $empty: false is the exact complement | objectstack-ai#20444: null is empty on every row of the ruled table → Every face answers `$empty` by the field's declared type, and null is empty under every type's arm | | `data/filter-logic-conformance.ts:567` | objectstack-ai#20444 | objectstack-ai#20444: card body executing ruling A on objectstack-ai#20399 (5865693155): every compile surface answers $empty by the field's declared type; $empty: false is the exact complement | objectstack-ai#20444: the exact complement → `$empty: false` is the exact complement by ruling | | `data/filter-logic-conformance.ts:573` | objectstack-ai#20444 | objectstack-ai#20444: card body executing ruling A on objectstack-ai#20399 (5865693155): every compile surface answers $empty by the field's declared type; $empty: false is the exact complement | objectstack-ai#20444: `$empty` spells its NULL case out, so it → The ruled `$empty` arms spell their NULL case out, so `$empty` | | `data/filter-logic-conformance.ts:579` | objectstack-ai#20444 | objectstack-ai#20444: card body executing ruling A on objectstack-ai#20399 (5865693155): every compile surface answers $empty by the field's declared type; $empty: false is the exact complement | objectstack-ai#20444: → Under the same declared-type arms, | | `data/filter-logic-conformance.ts:595` | objectstack-ai#20444 | objectstack-ai#20444: card body executing ruling A on objectstack-ai#20399 (5865693155): every compile surface answers $empty by the field's declared type; $empty: false is the exact complement | objectstack-ai#20444: a face that lowers `$empty` → A face that lowers `$empty` to the field's declared-type arm | | `data/filter-text-conformance.ts:300` | objectstack-ai#8934 | objectstack-ai#8934: ruling A 5305708745: icontains joins the view and infix vocabularies; ilike/$ilike and icontains/$icontains are never aliased onto each other | objectstack-ai#8934 → ruled never an alias of ilike | | `data/filter-text-conformance.ts:309` | objectstack-ai#8934 | objectstack-ai#8934: ruling A 5305708745: icontains joins the view and infix vocabularies; ilike/$ilike and icontains/$icontains are never aliased onto each other | capability (objectstack-ai#8934) → capability, ruled when `icontains` joined the view and infix vocabularies | | `data/filter-text-conformance.ts:349` | objectstack-ai#6518 objectstack-ai#6682 | objectstack-ai#6518: ACCEPT 5226386725 (PR objectstack-ai#6706): the SQL family's $contains family is case-exact (GLOB on the SQLite dialects), per objectstack-ai#4706 Q2 = A · objectstack-ai#6682: ACCEPT 5251849449 and the memory half: the hardcoded case-folding flag came off driver-mongodb and driver-memory, the last two folding faces | match. SQLite's LIKE folds ASCII — the defect objectstack-ai#6518 replaced with GLOB on the SQLite dialects; a JS backend's equivalent is a RegExp carrying the `i` flag, which objectstack-ai#6682 took off → match: the `$contains` family is case-sensitive on every backend, by ruling. SQLite's LIKE folds ASCII — the defect the SQL family replaced with GLOB on the SQLite dialects; a JS backend's equivalent is a RegExp carrying the `i` flag, since taken off driver-memory and driver-mongodb, | | `data/filter-text-conformance.ts:428` | objectstack-ai#4706 | objectstack-ai#4706: ruling B 5199214776: $regex retired under ADR-0049 with a loud refusal naming the replacement, $icontains added | objectstack-ai#4706 retired the operator over → `$regex` was retired over, by ruling, with a loud refusal naming `$icontains` | | `data/filter-text-conformance.ts:436` | objectstack-ai#5710 objectstack-ai#6993 | objectstack-ai#5710: ACCEPT 5201171068 (PR objectstack-ai#5812): plugin-auth's adapter stopped emitting bare $regex for better-auth contains, unblocking the $regex retirement · objectstack-ai#6993: ACCEPT 5231388316 (PR objectstack-ai#7054): the expired status claims were re-measured by executing each face (2026-08) and rewritten | objectstack-ai#5710 flipped that producer before any backend enrolled this case (re-verified 2026-08, objectstack-ai#6993 → That producer was moved off `$regex` before any backend enrolled this case (re-verified 2026-08 by executing each face | | `data/filter-text-conformance.ts:452` | objectstack-ai#5240 | objectstack-ai#5240: maintainer ruling 5181107825: { field: {} } is refused loudly (INVALID_FILTER) on every backend, not read as TRUE or FALSE | objectstack-ai#5240 refused `{ field: {} }` over → for which a field with zero operators, `{ field: {} }`, is refused by ruling | | `data/filter-text-operator-declared-type.ts:392` | objectstack-ai#8296 | objectstack-ai#8296: standing ruling recorded in 5277439872: a where on a formula field is refused (INVALID_FIELD 400) because no driver materialises the column | objectstack-ai#8296 door refuses EVERY formula filter with INVALID_FIELD 400 whatever the `returnType` → unmaterializable-field door refuses EVERY formula filter with INVALID_FIELD 400 whatever the `returnType` (no driver stores a formula column) | | `data/filter-text-operator-declared-type.ts:553` | objectstack-ai#8371 | objectstack-ai#8371: ruling 5300373151 (option 2): a type-directed verdict on the dotted head segment; the structured/JSON head stays deliberately unjudged | unjudged there, objectstack-ai#8371 → left unjudged there, by ruling | | `data/filter-text-operator-declared-type.ts:554` | objectstack-ai#8296 | objectstack-ai#8296: standing ruling recorded in 5277439872: a where on a formula field is refused (INVALID_FIELD 400) because no driver materialises the column | objectstack-ai#8296 → the unmaterializable-field door | | `data/temporal-conformance.ts:211` | objectstack-ai#3773 | objectstack-ai#3773: defect: SQLite read an epoch-ms Field.datetime as a Julian day so date buckets were NULL; repaired by storage-aware bucketing (card body; cross-update 5099832227 on objectstack-ai#3777) | (objectstack-ai#3773) → as SQLite bucketing once did | | `data/temporal-conformance.ts:215` | objectstack-ai#3777 | objectstack-ai#3777: defect: a bare-day $lte on a datetime column stopped at midnight and dropped the rest of the final day; the bound keeps the whole day (card body; 5099832227) | by the objectstack-ai#3777 bug → when a bare-day upper bound stopped at midnight | | `data/temporal-conformance.ts:216` | objectstack-ai#3777 | objectstack-ai#3777: defect: a bare-day $lte on a datetime column stopped at midnight and dropped the rest of the final day; the bound keeps the whole day (card body; 5099832227) | by the objectstack-ai#3777 bug → when a bare-day upper bound stopped at midnight | | `data/temporal-conformance.ts:220` | objectstack-ai#20600 | objectstack-ai#20600: triage direction 5886142901, landed PR objectstack-ai#20643: the whole-day bound past 9999-12-31 is unbounded above and the drivers compile none | (objectstack-ai#20600) → and none is compiled | | `data/temporal-conformance.ts:270` | objectstack-ai#3777 | objectstack-ai#3777: defect: a bare-day $lte on a datetime column stopped at midnight and dropped the rest of the final day; the bound keeps the whole day (card body; 5099832227) | objectstack-ai#3777: the default dashboard window → The default dashboard window, whose bare-day $lte keeps the whole final day | | `data/temporal-conformance.ts:393` | objectstack-ai#3773 | objectstack-ai#3773: defect: SQLite read an epoch-ms Field.datetime as a Julian day so date buckets were NULL; repaired by storage-aware bucketing (card body; cross-update 5099832227 on objectstack-ai#3777) | objectstack-ai#3773 famil → family of the SQLite bucketing defect that read an epoch-ms datetime as a Julian da | | `data/temporal-conformance.ts:448` | objectstack-ai#1874 | objectstack-ai#1874: the templates' date-equality family that surfaced ADR-0053, whose Phase 1 stores Field.date as its calendar day (ADR-0053 Surfaced-by line; card body, no comments) | objectstack-ai#1874: `date == today` silently matched nothing while dates were stored as instants. E → `date == today` silently matched nothing while dates were stored as instants; ADR-0053 stores a date as its calendar day, so e | | `data/temporal-conformance.ts:457` | objectstack-ai#1874 | objectstack-ai#1874: the templates' date-equality family that surfaced ADR-0053, whose Phase 1 stores Field.date as its calendar day (ADR-0053 Surfaced-by line; card body, no comments) | from the objectstack-ai#1874 family → that surfaced ADR-0053 | | `data/temporal-conformance.ts:549` | objectstack-ai#3994 | objectstack-ai#3994: Field.time takes one canonical HH:MM:SS[.fff] text form on write, filter and read (ADR-0053 addendum D-C1..D-C3; driver-sql changeset 9774b78) | objectstack-ai#3994, measured → Measured before `Field.time` took one canonical `HH:MM:SS[.fff]` text form | | `data/value-roundtrip-conformance.ts:213` | objectstack-ai#11535 | objectstack-ai#11535: defect: a single-to-multi-value change kept the old text column on Postgres, so arrays came back as stringified literals; the drift detector now reports the base-type mismatch (claim 5395496220) | objectstack-ai#11535's exact shape → the exact shape a single-value column kept for a multi-value field corrupted | | `data/value-roundtrip-conformance.ts:225` | objectstack-ai#11782 | objectstack-ai#11782: landing 5406878917 (PR objectstack-ai#12019): a declared boolean answers JSON booleans on every read door and dialect; MySQL's tinyint 1/0 leaked before | objectstack-ai#11782 read this back as 1 on MySQL → MySQL read this back as 1 until every read door presented a declared boolean as true/false | | `data/value-roundtrip-conformance.ts:226` | objectstack-ai#11782 | objectstack-ai#11782: landing 5406878917 (PR objectstack-ai#12019): a declared boolean answers JSON booleans on every read door and dialect; MySQL's tinyint 1/0 leaked before | objectstack-ai#11782 → that MySQL read-back | | `lint: validate-empty-combinators.test.ts:275` | objectstack-ai#5322 | objectstack-ai#5322: maintainer ruling 5185589944: every face reduces an empty combinator to its boolean identity ($and:[] all rows, $or:[] none, {} TRUE, $not:{} none), whole tree | objectstack-ai#5322 → every face reduces an empty combinator to its boolean identity | | `analytics: icontains-dialect-sql.test.ts:369` | objectstack-ai#8934 | objectstack-ai#8934: ruling A 5305708745: icontains joins the view and infix vocabularies; ilike/$ilike and icontains/$icontains are never aliased onto each other | objectstack-ai#8934 → ruled never an alias of ilike | ## The selector seam (A3) The filter over `FILTER_LOGIC_CASES` now tests `(c.note ?? '').includes('every face reduces an empty combinator to its boolean identity')` where it tested `.includes('objectstack-ai#5322')`. The phrase is the objectstack-ai#5322 ruling in words, and the four rewritten notes open with it. Measured with an AST extractor over the table (`name` and `note` of all 36 cases, then the selector applied exactly as the test applies it): - base `cc645f2385` with `objectstack-ai#5322` → **4 of 36**: `$not of {} is FALSE — NOT TRUE`, `a {} branch is a TRUE disjunct and absorbs its $or`, `empty $and is TRUE — the AND identity`, `empty $or is FALSE — the OR identity`. - head with the phrase → **the same 4 of 36**, the same names. - cross: the old selector on the head selects 0 (so the move is required), the new phrase on the base selects 0. - control outside the set: the broader word `Ruled` selects 8, the four plus `$exists true selects exactly the valued rows`, `$ne …`, `$nin …` and `$notContains returns the rows with no value`; none of those four is picked by the phrase. - mutation control on a scratch copy (anchor hit 1, replacement present 1, anchor left 0): dropping the phrase from the empty-`$or` note leaves 3, which the test's own guard (`toEqual` on the four sorted names, `toBe(4)`) turns red. The test asserts exactly what it asserted before (the names pin, the count, and the row-set wording per case); only the selector moved. `validate-empty-combinators.test.ts` ran green: 21 of 21, the three identity cases included. ## The name pin (A4) `'icontains (the infix/view spelling, objectstack-ai#8934) lowers to $icontains — % stays a LITERAL through that door too'` becomes `'icontains (the infix/view spelling, ruled never an alias of ilike) lowers to $icontains — % stays a LITERAL through that door too'` in `FILTER_TEXT_CASES`, and the `toEqual` pin in `icontains-dialect-sql.test.ts:369` moves to the new string verbatim. No other consumer keys on that name (searched the whole tree for the old name and its fragments: the source and the pin only); the other consumers use it as a test title. The file ran green: 16 of 16. The five renamed `FILTER_COMPARAND_TYPE_CASES` names have no pin anywhere; they are test titles only. ## Text only (A5) Stage 3's `skeleton.cjs` (one line changed: the TypeScript load path to this worktree), TypeScript 6.0.3: leg 1 an AST skeleton with every string's text masked (a `+` chain's adjacent string operands read as one string), leg 2 the text of every string group, each changed group required to carry a tracker id before and none after, every other group byte-identical. Base copies vs the committed files, **10 of 10 SAME on both legs, exit 0**: | file | tokens | string groups | changed | |:--|--:|--:|--:| | `metadata-service-roundtrip-conformance.ts` | 1352 | 157 | 5 | | `aggregation-conformance.ts` | 1268 | 133 | 9 | | `filter-comparand-type-conformance.ts` | 1309 | 107 | 7 | | `filter-logic-conformance.ts` | 1682 | 223 | 16 | | `filter-text-conformance.ts` | 1093 | 137 | 6 | | `filter-text-operator-declared-type.ts` | 1821 | 114 | 3 | | `temporal-conformance.ts` | 2041 | 374 | 9 | | `value-roundtrip-conformance.ts` | 1197 | 175 | 3 | | `validate-empty-combinators.test.ts` | 2684 | 129 | 1 | | `icontains-dialect-sql.test.ts` | 4345 | 217 | 1 | Parse diagnostics 0 / 0 throughout. 58 changed groups in the eight modules, one per seam. No case's filter, input, expected rows, verdict, code, operator, dialect, order or count moves. Edits were applied by a script whose 62 anchors each had to hit exactly once before any write, and were read back from disk after it (each anchor gone, each replacement present once). Census after the edit: class (c) **0 / 0**; non-test 118 → 60 messages, 297 → 229 ids; test strings unchanged (1804 / 1920). ## Pins, titles and quotes (A6) - Pins moved: the two seams above, nothing else. Every old note and name was searched across the tree in 32-character windows: no test asserts a changed phrase; the remaining hits are code comments and other modules' own prose that share a phrase with an unchanged part of a note. - Titles that follow the text: the comparand-type and text-case names (test titles in the driver suites), and the `VALUE_ROUNDTRIP_CASES` notes (`round-trips NAME (NOTE)`). No skip list, ledger or snapshot names any of those titles. - Quotes: no `content/docs/**` page and no `skills/**` file quotes a changed note or name. - `TEMPORAL_ROWS[].why` is seeded into a `string` column (a `varchar` on Postgres and MySQL), so the four rewritten `why` texts stay short (164 characters at most). ## Gates Head `1427993cc3` (the merge of `origin/main` `901e7cf13a`; that merge touched no file under `packages/spec`, `packages/lint` or `packages/services/service-analytics`, 0 diff lines there). - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 11 paths vs merge base `901e7cf13`, 153 changed lines; **88 derived commands, 88 exit 0** on `1427993cc3`, each exit code written from `$?` before any pipe; `--ran`: "✓ dispatch-gates --ran: 88 derived famil(ies) accounted for — 88 run, 0 NOT-MEASURED". - `pnpm --filter @objectstack/spec build` exit 0 (the dependency closure is empty: no workspace dependencies); `check:generated`: "✓ All 15 generated artifacts are up to date". - `pnpm --filter @objectstack/spec test`: "Test Files 606 passed (606) / Tests 17952 passed | 1 todo (17953)"; `typecheck` exit 0 ("check:test-typecheck: OK — … 52 file(s) / 246 error(s) / 135 pinned signature(s) held"). - `pnpm --filter @objectstack/lint test`: "Test Files 119 passed (119) / Tests 5620 passed (5620)"; `typecheck` exit 0. - `pnpm --filter @objectstack/service-analytics test`: "Test Files 175 passed (175) / Tests 4152 passed | 253 skipped (4405)"; `typecheck` exit 0; `icontains-dialect-sql.test.ts` alone 16 of 16. - `pnpm check:doc-authoring` (self-test, then the run): "✓ doc authoring guard: 17337 customer-facing string(s) across 1251 spec sources clean" and "sibling-package prose ids hold the baseline — 0 pinned site(s) … no growth, no burn-down unrecorded". `pnpm check:nul-bytes`: "check-nul-bytes: OK (scanned 9947 text file(s) …; no raw ASCII control bytes)". - Changeset gates: `check-changeset-no-major.mjs`, `check-empty-changeset.mjs`, `check-adr-0087-registration.mjs` (each `--base origin/main`) exit 0; with this body as the `--event` payload, see the report. - ESLint, a proven narrowing: `eslint --no-inline-config --format json` over the 10 changed TS files reads 10 files, 0 errors, 0 warnings; the population is ESLint's own (`calculateConfigForFile` returns a config for each, `isPathIgnored` false); invariance: `eslint.config.mjs` enables no type-aware linting (`parserOptions.project` / `projectService` null for all 10), so a string-text edit cannot move an untouched file's verdict. Repo-wide `pnpm lint` is CI's. - Spec `test:repo` was not run locally: none of its 51 files names a changed module or table. ## Acceptance notes - **Two cited cards answer 404** in this repository (objectstack-ai#6725 and objectstack-ai#11065; the same numbers in objectui are unrelated PRs). Their decisions were read from the shipped landing records instead: the objectql and spec CHANGELOG entries `1507ba3` / `bbee302` (`MetadataFacade.register('object')` wrote into a map none of its own object reads consulted) and the driver-memory entry `2095040` (a boolean aggregand counts as 1 or 0, as on every SQL face). Neither decision read as unclear. - Two decisions live in shipped records rather than card comments: objectstack-ai#11152's 2026-08-28 ruling (option A, booleans aggregate as numbers, superseding objectstack-ai#11249) is recorded in changeset `f6fa22c` and matches the source comment above those cases; objectstack-ai#3994's in ADR-0053's D-C addendum. - Left for later stages, untouched here: the seam files' own test titles (`(objectstack-ai#5322/objectstack-ai#5659)`, `[objectstack-ai#16028]`) and every other test string; code comments (including the section comments beside these cases); class (f) in `api/error-code-ledger.zod.ts` and `kernel/public-auth-features.ts`; `migrations/registry.ts`. - Not governed: no `.claude/**`, `skills/**`, ADR, NORTH-STAR or AGENTS.md path. ## Line budget 153 changed lines (+90 / -63) over 11 files vs merge base `901e7cf13` (dispatch-gates), under the 5000 human-merge threshold: the eight modules, the two test files (one line each) and the changeset. No generated file, no governed surface. --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20600
Clause-②: yes (widening) — three new exports on
@objectstack/spec(data) and@objectstack/core: the constantUNBOUNDED_ABOVE, its typeUnboundedAboveand the guardisUnboundedAbove;nextUtcCalendarDayanswers the constant for9999-12-31, the one input that used to answer the five-digit'10000-01-01'; a$lte/$betweenmaximum 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 asminorunder the launch-window convention; the changeset carries the banner and the ADR-0087 dispositionnot-required (no-migration-prescription).Direction from triage
5886142901(binding, not re-opened): neithernullnor a five-digit year. Every supported value is at most the last millisecond of9999-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 exportsUNBOUNDED_ABOVE, its typeUnboundedAbove(asymbolwith a structural brand) and the guardisUnboundedAbove.nextUtcCalendarDayreturnsstring | UnboundedAbove | nulland answersUNBOUNDED_ABOVEfor the one real day whose successor has noYYYY-MM-DDspelling. The type is structural because./dataships asindex.d.mtsandindex.d.ts: aunique symbolwas two unrelated types in a program meeting both (the redType Check · workspace), while two copies of the branded alias are one type. It stays a symbol, so a template literal, a relational comparison and astringtarget still refuse it; readers narrow withisUnboundedAbove(===compares but does not narrow).@objectstack/corere-exports all three.$lte(or its AST spelling): no bound, only "the value is not null" (IS NOT NULLon SQL and the analytics echo,$ne: nullon memory and mongo, a value check in the engine'shaving);$between/betweenmaximum, an explicit analyticsdateRangeend: the range keeps its minimum alone;formula's RLScheck, the draft preview): every value that denotes an instant is inside the bound, and any other value is compared as written.$gte,$gt,$ltand$eqon9999-12-31do not read the helper and are unchanged (midnight-anchored;$eqis that midnight instant).9999-12-30and every earlier day compile the same bound as before.packages/spec/src/data/temporal-conformance.ts) gains the rowz_last(9999-12-31T10:00:00.000Z,nativewriter form) and five last-day cases ($lteondatetimeand ondate, two$betweenmaxima with their analyticsdateRangespellings, and the9999-12-30control), so every backend the kit drives, theTemporal Conformance (live PG + MySQL)job included, is held to this answer. Three existing$gte/$gtcases now also expectz_last.Reader census (the answer of
nextUtcCalendarDay, measured withgit grepatc1d8051e0a)driver-sqlcalendarDayExclusiveUpperBound(+calendarDayUpperBoundRewrite,calendarDayBetweenRewrite, thewhereemitter)sql-driver.ts10000-01-01…; SQLite: no rowsIS NOT NULL/ the minimum onlydriver-tursoremote lowering (inherits the rewrite)turso-driver.tstoRemoteUpperBound$null: false/$gteonlydriver-memory$lte,$betweenand their AST spellingsmemory-driver.ts(4)$lt '10000-01-01': no rows$ne: null/$gteonlydriver-memorycubelte(mingo and SQL echo),dateRangewindowmemory-analytics.ts(3)$ne: null/IS NOT NULL/ start onlydriver-mongodb$lte,$betweenmongodb-filter.ts(2)$ltthe stored form of10000-01-01$ne: null/$gteonlyformulalteBound(RLScheck)matches-filter.ts'2026-…'value sorts above'10000-01-01': write deniedobjectqlwholeDayUpperBound(having, per-aggregationfilter)having-filter.tsminonlyservice-analyticspreviewlteBound,dateRangepreview-evaluator.ts(2)service-analyticsnative SQLlte,dateRangenative-sql-strategy.ts(2)IS NOT NULL/ start onlyservice-analyticsObjectQL echodateRangeobjectql-strategy.ts10000-01-01specutcInstantMsvalidity testcalendar-day.ts!== nullcoreanalytics-date-range.test.ts,specdate-range-presets.test.tsThe seat's list also named
coretemporal-storage-form.ts/analytics-date-range.ts,specdate-range-presets.ts/temporal-conformance.tsanddriver-mongodbmongodb-temporal.ts: they mention the helper in comments only and read no answer. The claimedservice-analyticsfilesanalytics-service.ts,cube-registry.ts,dataset-executor.ts,dataset-compiler.tsdo not read it and are untouched.Shape, measured (hypothesis 2)
Round 1 kept
unique symboland 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 · workspacewent red in@objectstack/dogfood(TS2367 atturso-driver.ts2640, TS2339 at 2641), reproduced locally ate197faa978with CI's own command. Round 2 types the sentinelsymbolplus a structural brand and adds the shared guard: the same workspace typecheck is green at49a2585988(135 of 135 tasks, dogfood included), and a scratch consumer compiled against the builtdist/data/index.d.tsandindex.d.mtstogether compares and narrows across the two files while a template literal, a relational comparison and astringtarget 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, throughPOST /api/v1/data/:object/query(the newpackages/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$lte '9999-12-31'c26,prev,open,mid,last$between ['2026-01-01', '9999-12-31']$between ['9999-12-31', '9999-12-31']open,mid,lastopen,mid,last$notof$lte '9999-12-31'noneincludednonenone$lte '9999-12-30'(control)c26,prevc26,prev$gte/$gt/$lt/$eq'9999-12-31'Rows:
c262026-07-15T14:00Z(the card's control),prev9999-12-30T10:00Z,open9999-12-31T00:00Z,mid9999-12-31T10:00Z(the card's row),last9999-12-31T23:59:59.999Z,noneno value.Reverse verification (fix committed first at
f4d15dd506, mutation throughscripts/ablation-replace.mjsunder atraprestore to theHEADblob): the helper's last line was replaced byreturn 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 reacheddist); anchor 1 to 0 and replacement 0 to 1 on disk;pnpm --filter @objectstack/spec build;scripts/ablation-dist-preflight.mjsfound the mutation indist. Mutated leg:speccalendar-day 2 failed;driver-sql9 failed (the SQLite and legacy-storage cells; the PostgreSQL cells stayed green, as on the base);driver-memory11;driver-mongodb3;formula10;objectql3;service-analytics17;driver-turso12;rest1 (the SQLite cell). Restore: blob equalsHEAD,git diff HEADempty, 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/mainf4ce10c89dmerged): the whole-workspace typecheck of theType Check · workspacejob, 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:generatedall 15 up to date after regeneratingapi-surfaceandexport-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 (atf7a61d512eande197faa978) and the live PostgreSQL cells stand from round 1; round 2 changes the readers' test from=== UNBOUNDED_ABOVEtoisUnboundedAbove(…), whose body is that comparison.Acceptance notes
$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$eqon adatetimeis that day's midnight instant, and none of them readsnextUtcCalendarDayfor it. It is unchanged here and pinned as such ($eq '9999-12-31'answersopen, the midnight row, before and after).ltewiden without knowing the column type, as their bounded rule always has ($lte daybecomes "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'scheckand the draft preview, which see the value, admit an instant and compare anything else as written. The SQL drivers and the engine'shavingscope the rule todatetimecolumns and are exact.$null. The remote lowering assigns lowered keys into one operator map, so the new$null: falseshares a key with an author's own$nullbeside a last-day$lte, as its existing$lteto$ltand$betweento$gtelowerings already share theirs (the memory and mongo translators assemble such writes into$andbranches; the remote path does not). Read from the code, not reproduced; noted, not filed.having/ per-aggregationfilter,$betweenon a missing value. Anullaggregate value passes a$betweenthere, whatever its bounds (neitherordered(null, …)comparison is true). Unchanged by this PR; read from the code, not reproduced.mysqldin this container; theTemporal 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 inmongodb-filter.test.ts.nextUtcCalendarDay's answer as a string stops compiling (3 of the 16 in-repo sites did). Declaredyes (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.datetime.MemoryAnalyticsServiceconverts a comparand to the field's storage form before itslterow asksnextUtcCalendarDay, so on a DECLAREDdatetimethe helper sees an instant and declines, on every day. Measured on the built@objectstack/driver-memoryat this head, rows at2026-07-28T10:00Zand2026-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'); declareddatetime, only the 07-27 row (the echo compiles an inclusive bound at'2026-07-28T00:00:00.000Z'), whilefind()answers both. Not the last-day defect, so not fixed here. → filed by the seat as [finding] driver-memory analytics: a cubewhere$ltebare day on a declareddatetimefield drops the rest of that day —comparandsForconverts to storage form before the whole-day rule runs #20661.Generated by Claude Code