Skip to content

fix(driver-sql): stop the canonical temporal backfill writing SQLite julian-day misreadings to disk - #19688

Merged
huangyiirene merged 5 commits into
mainfrom
claude/issue-6009-backfill-julian-guard
Sep 22, 2026
Merged

huangyiirene merged 5 commits into
mainfrom
claude/issue-6009-backfill-julian-guard

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Part of #6009

Clause-②: no

The canonical temporal backfill no longer writes SQLite's julian-day reading of a bare-numeric cell over the bytes on disk. The read path is untouched, deliberately — see the fence below.

The defect

sqliteCanonicalDatetimeSql's else arm is coalesce(strftime('%Y-%m-%dT%H:%M:%fZ', col), col), and its documented contract is that a value SQLite cannot parse falls through the coalesce unchanged. SQLite's time-value grammar has one limb that defeats that reasoning: a bare number is a julian day (DDDD.DDDD, the last of its documented formats), and now is the wall clock. For those two shapes strftime does not return NULL — it returns a confident wrong answer — so coalesce never fires.

Reproduced first, before any code was written, on better-sqlite3 13.0.3 / SQLite 3.53.4 against a TEXT-affinity column. The card's own table, reproduced exactly, plus the two shapes that reach the same limb:

'12'         -> -4713-12-06T12:00:00.000Z      '2026'       -> -4707-06-11T12:00:00.000Z
'0'          -> -4713-11-24T12:00:00.000Z      '86400'      -> -4476-06-15T12:00:00.000Z
'1999'       -> -4707-05-15T12:00:00.000Z      '1e5'        -> -4439-09-09T12:00:00.000Z
'2440587.5'  ->  1970-01-01T00:00:00.000Z      'now'        ->  the wall clock, at boot
'2026-08-06' ->  2026-08-06T00:00:00.000Z  (correct — control)
'2026-08-06T00:00:00.000Z' -> itself       (fixed point — control)
'1753660800000.0' -> itself                (past the julian range, strftime NULL — control)

On a read that is a temporary misreading and the disk is untouched. backfillCanonicalDatetimes uses the same expression as its SET value, so on that side it is a write, and the original value is unrecoverable afterwards.

What landed

  • sqliteNonTemporalTextSql — the structural complement of the parseable spellings, not a heuristic. Every other format SQLite's date parser accepts goes through parseYyyyMmDd (which needs a -) or parseHhMmSs (which needs a :), so a cell the parser answers for while containing neither character reached it through the julian-day limb or the now limb — there is no third way in. The predicate never inspects the magnitude of the number and never decides what the cell means; it decides only that the migration must not rewrite it. It probes with julianday() rather than strftime() because the two agree on NULL-ness by construction, which makes one spelling serve both the datetime expression and the time one.
  • Rows it matches are withheld from the UPDATE in backfillCanonicalDatetimes and backfillCanonicalTimes, and left exactly as they are — which is what the backfill's own contract already promises for every other uninterpretable value.
  • A withheld row also blocks the canonical mark, and that half is what keeps every query answer identical. Skipping the row costs nothing while the read-side repair still applies to it: it keeps reading as the same instant it always did. Marking the column clean is what would move an answer — needsLegacyDatetimeRepair would drop the repair and the raw '2026' would be compared as TEXT. Unlike the coalesce fixpoints, these rows are not invariant under the repair, so they cannot ride through the mark the way genuinely unparseable junk does. The column stays un-marked, reads keep their (unindexed) repair, and one warn names the count, the reason and the remedy. That is the degradation posture this module already has for a failed UPDATE, reused rather than invented.
  • previewDatetimeConvergence / previewTimeConvergence inherit the same exclusion, so os migrate plan still promises exactly the rows apply rewrites — the pair's stated construction.
  • A patch changeset, measured rather than assumed: @objectstack/driver-sql is not private, its files[] ships dist, and the new symbol reaches it (10 hits in dist/index.js against a positive control of 12 for sqliteCanonicalDatetimeSql).

The fence, honoured

倾向不要在共享表达式里加启发式 —— 维护者 2026-08-03 在 cloud#1005 已就近似的提案裁过否决(公共契约 + 每次读都跑 + 会误读合法的数字字符串列)。

The read expression is byte-for-byte unchanged, and the suite pins that it still misreads, so a later "improvement" to it fails this suite rather than slipping through it. Everything added here runs once, as a migration, only on a column the metadata declares Field.datetime / Field.time, and it only ever declines to write. No new gate is added or recommended: this is a guard inside an existing migration path.

On '2440587.5'

The card notes SQLite's answer there is genuinely the julian epoch, and the dispatch read that as a legitimate case. The guard treats it like every other bare number, and the reason is the card's own criterion — 「合法的 canonical 拼写一定带 - / : / T / Z」 — so a cell without any of them is dirty data whichever number it holds. It costs the query nothing: the read path still answers 1970-01-01T00:00:00.000Z for it, before and after, which the suite asserts on a window that admits the row. Only the bytes survive that did not before. Discriminating by magnitude would be the guessing the sibling module's 「不可解残留如实记录」 posture exists to refuse.

Evidence

what result
Reproduction, before the fix the table above, on real SQLite (better-sqlite3 13.0.3 / SQLite 3.53.4)
pnpm --filter @objectstack/driver-sql test 179 files passed, 11 skipped; 2645 tests passed, 168 skipped, 0 failed
pnpm --filter @objectstack/driver-sql typecheck exit 0
New suite 18 cases, all passing
Ablation guard predicate neutralised via scripts/ablation-replace.mjs: anchor 1 to 0, blob bc27c909d4eb to 8d264f7cf4ed; the suite goes 6 red, including '12' written to disk as -4713-12-06T12:00:00.000Z and '2026' written as 12:00:00. Restored: blob back to bc27c909d4eb == HEAD, git diff HEAD empty, whole-tree git status --porcelain empty
Gate families derived with scripts/pm/dispatch-gates.mjs on the merged tree: 62 derived, 62 run, 0 NOT MEASURED, 0 UNRUN, every one recording its exit code (captured before any pipe)
pnpm lint the whole repo-wide eslint . --no-inline-config run, exit 0, at e9d0cf2bc — no narrowing claimed
origin/main merged e9d0cf2bc, clean, no os-regen deferral; pnpm install --frozen-lockfile, full workspace build and pnpm --filter @objectstack/spec check:generated all clean afterwards

check:doc-authoring caught the first draft: the warn string carried (#6009). Per the recorded ruling the id moved to the adjacent // comment, and the test now keys on the message's own words.

Why the fixture rebuilds the column as TEXT

A column knex creates for Field.datetime is declared datetime, which is NUMERIC affinity, so '2026' is converted to INTEGER 2026 on the way in and takes the expression's integer/real branch instead — the local write path mostly dodges this, exactly as the card's analysis says. The bare-numeric TEXT shape needs a TEXT-affinity column, which is what RemoteTransport.mapFieldTypeToSQL declares for every temporal column and what an externally-provisioned SQLite file can carry: initObjects adds missing columns to a table that already exists and never retypes one. The fixture therefore declares at / tod TEXT and keeps a second NUMERIC-affinity datetime column ep as the integer/real control — one table, both storage classes, which is the situation the repair expression exists for.

What is NOT addressed here, and is why this is Part of rather than a close

The remote transport's backfill still writes the misreading. packages/drivers/driver-turso/src/remote-canonical-backfill.ts builds its own statements and receives only canonicalSqlFor, so it does not inherit this guard. Its convergeSql is

update TABLE set COL = CANONICAL where rowid in (select rowid from TABLE where NOT_CANONICAL limit ?)

with CANONICAL being this same shared expression, over columns RemoteTransport.mapFieldTypeToSQL declares TEXT — which the card names as the one reasonably reachable side. That module is outside this dispatch's declared file surface (packages/drivers/driver-sql/src/), so it is reported rather than edited. The shape a follow-up needs is already available: sqliteNonTemporalTextSql is protected on SqlDriver exactly like sqliteCanonicalDatetimeSql, so it can be handed across the transport boundary the same way.

Related, and measured while reading that module: its unresolvedEpochTextRows contract states that the rows it counts 「are fixpoints of the shared repair: reading them through it and reading them without it give the identical answer」, and therefore do not block the canonical mark. That holds for the rows above the recovery band (past the julian range, where strftime returns NULL) and not for the ones below it: '2026' and '86400' are digits-only, fall outside 1e12 ≤ v, and are read as BC dates by the shared repair, so they are not fixpoints and dropping the repair does change what they match.

Answering the dispatch's question directly: unresolvedEpochTextRows is not the right place to count these. Two reasons, either sufficient. It lives in driver-turso, outside the file surface. And its documented meaning is "recorded rather than guessed at, and harmless to the mark" — filing julian-misread rows under a name that asserts they are harmless would make a false statement true-looking. The local counterpart added here is deliberately a different thing: it is not a report, it is a blocker on the canonical mark, because that is what the rows require.

Acceptance notes

Observed while measuring, not filed and not fixed:

  • '12:30' in a Field.datetime column reads as 2000-01-01T12:30:00.000Z and is converged to it. That is a genuine SQLite date format (parseHhMmSs), not the julian limb, so it is outside this card's fence and the guard deliberately leaves it alone. Noted, not filed: it is the read path's documented answer and changing it is a contract decision, not a defect fix. Taker: none identified.
  • SQLite's now keyword shares the julian limb's shape and is caught by the same predicate for the same reason — a stored literal now would otherwise be converged to whatever the clock said at boot. Recorded in the helper's docblock so it is read rather than rediscovered; no separate treatment.

Generated by Claude Code

…day misreading to disk

`sqliteCanonicalDatetimeSql`'s `else` arm leans on `coalesce(strftime(...), col)`
to preserve what it cannot parse. SQLite's time-value grammar accepts a bare
number as a JULIAN DAY and the keyword `now`, and for those `strftime` returns a
confident wrong answer instead of NULL, so `coalesce` never fires: a stored
'2026' reads as -4707-06-11T12:00:00.000Z. On a read that is temporary. On the
SET side of `backfillCanonicalDatetimes` it is written over the original bytes.

Adds `sqliteNonTemporalTextSql`, the structural complement of the parseable
spellings (every other format SQLite's parser accepts carries a '-' or a ':'),
and withholds the rows it matches from both temporal backfills. Because such a
row is NOT a fixpoint of the read-side repair, it also blocks the canonical
mark — which is what keeps every query answer identical: the column keeps its
repair, and the migration changes no read.

The read path is untouched (cloud#1005, 2026-08-03). The convergence previews
inherit the same exclusion so the plan still promises exactly what apply does.

Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE
Co-authored-by: Claude <noreply@anthropic.com>
…s measured table

18 cases over a TEXT-affinity temporal column — the shape
`RemoteTransport.mapFieldTypeToSQL` declares and an externally provisioned
SQLite file can carry. A knex-created `datetime` column is NUMERIC affinity, so
'2026' is stored as INTEGER 2026 and takes the epoch branch instead; the fixture
rebuilds the column as TEXT and keeps a second NUMERIC column as the
integer/real control.

Pins, in order: the read path STILL misreads (the cloud#1005 refusal, asserted
as unimplemented); no julian-derived value reaches disk; every real date
spelling still converges; the epoch branch and the coalesce fixpoints are
byte-identical; the column stays unmarked while a withheld row remains; and a
discriminating window filter answers identically before and after.

Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE
Co-authored-by: Claude <noreply@anthropic.com>
…ckfill guard

Measured rather than assumed: the package is not private, its files[] ships
`dist`, and the new symbol lands there (10 hits in dist/index.js against a
positive control of 12 for sqliteCanonicalDatetimeSql), so the changed bytes
reach consumers and skip-changeset would be wrong.

Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE
Co-authored-by: Claude <noreply@anthropic.com>
…ring

`check:doc-authoring` flagged both sites: an operator reading a runtime log has
no tracker, no git log and no ADRs, so the id belongs in the adjacent comment
and the anchor stays in git history either way. The test now keys on the
message's own words instead of the id.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 8 documentable anchor(s).

8 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))
  • content/docs/data-modeling/index.mdx (via SqlDriver (symbol, a top-level class))
  • 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))
  • 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))

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

  • content/docs/releases/v17/17-0.mdx (via SqlDriver (symbol, a top-level class))

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
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 11 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 1f53b0b685bc5477f2eb702c7938e94b7009e59d → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 1f53b0b685bc5477f2eb702c7938e94b7009e59d

⚠️ 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 1f53b0b685bc5477f2eb702c7938e94b7009e59d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@huangyiirene
huangyiirene marked this pull request as ready for review September 22, 2026 08:10
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 22, 2026
Merged via the queue into main with commit 40626bd Sep 22, 2026
36 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-6009-backfill-julian-guard branch September 22, 2026 08:40
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…n misreading over a stored value (objectstack-ai#19719)

Fixes objectstack-ai#6009

**Clause-②: no**

The sequel to PR objectstack-ai#19688, which fixed the `driver-sql` (Knex) half of
this card at squash `40626bdca6`. The REMOTE path is a separate module
that builds its own statements and did not inherit the guard.

## The defect

`RemoteTransport.mapFieldTypeToSQL` declares every temporal column
`TEXT`, so a `Field.datetime` / `Field.time` column can hold a
digits-only string such as `'2026'`, `'86400'`, `'12'` — or the keyword
`'now'`. SQLite's time-value grammar accepts a bare number as a JULIAN
DAY and `now` as the wall clock, so for those two shapes `strftime`
answers confidently rather than returning NULL, and the
`coalesce(strftime(...), col)` that is supposed to preserve unparseable
values never fires. The convergence `UPDATE` in
`backfillRemoteCanonicalColumn` wrote that answer **over the stored
bytes**, and no later run can get them back.

Measured here on better-sqlite3 13.0.3 / SQLite 3.53.4, column declared
`TEXT`:

```text
'2026'  -> -4707-06-11T12:00:00.000Z    'now' -> the wall clock, now
'86400' -> -4476-06-15T12:00:00.000Z    '12'  -> -4713-12-06T12:00:00.000Z
```

In a `Field.time` column the same `'2026'` becomes `'12:00:00'` — a wall
clock nobody wrote. Both are pinned.

## What changed

All of it inside `packages/drivers/driver-turso/src/`. **No
`packages/drivers/driver-sql/` file is touched**, and neither
`sqliteCanonicalDatetimeSql` nor `sqliteCanonicalTimeSql` moves — the
2026-08-03 cloud#1005 ruling that refused a heuristic in the shared read
expression is untouched, and objectstack-ai#19688's pins that those expressions still
misread a bare number stay green.

- **`TursoDriver` hands its own `sqliteNonTemporalTextSql` across the
module boundary**, beside the canonical expression it already hands
over, as `{ canonical, nonTemporalText }`. The predicate is `protected`
on `SqlDriver` and stays that way; nothing is copied, so remote and
local cannot drift about which bytes are safe to overwrite.
- **The convergence `UPDATE` gains `and not (guard)`** — the local
twin's exclusion, same construction.
- **Withheld rows block the canonical mark.** This is the half that
keeps every query answer identical, and here it costs no second gate: a
withheld row is by construction `col IS NOT canonical`, so it is already
inside the probe's `residual`, and the mark already requires `residual
=== 0`. Withholding alone would not do it — a marked column drops the
read-side repair, and the raw `'2026'` would then compare as TEXT
instead of as the instant the repair reads it as.
- **The probe promises exactly what an apply rewrites.** `buildProbeSql`
gains a fourth count, `non_temporal_text` — the migration's own `WHERE`
with the guard ANDed on instead of negated — and `residual -
nonTemporalText` is what decides whether the convergence phase runs. The
remote side has no other preview surface:
`SqlDriver.previewDeferredSchemaWork` reads `deferredSchemaObjects`,
which only the Knex sync path fills, so in remote mode it answers `[]`
and this probe is the whole plan. `residual` itself is deliberately
**not** netted down, because that is what holds the mark.
- **A new per-column report field, `nonTemporalTextRowsWithheld`**, plus
an operator warning naming the consequence and the remedy. It is `null`
— NOT MEASURED — when no guard was supplied, never `0`.
- **No new gate**, no new `check:*` script, and no recommendation for
one.

### The corrected sentence

The module's contract said rows outside the epoch-recovery band "do not
block the canonical mark, because they are fixpoints of the shared
repair". That is true only **above** SQLite's julian-day ceiling, where
`strftime` returns NULL and `coalesce` hands the value back. **Below
it** — `'12'`, `'2026'`, `'86400'` — the repair answers, the row is not
a fixpoint, and dropping the repair changes what it matches. Both halves
are now written with the measurement behind them, and the field's own
contract says which of the two a given row is in. The count was **not**
filed under `unresolvedEpochTextRows`, whose documented meaning is
*recorded and harmless to the mark*; these rows are the opposite, so
they got their own name.

### Why the entry points are not breaking

The third position of `probeRemoteCanonicalColumns`,
`backfillRemoteCanonicalColumn` and `backfillRemoteCanonicalColumns` now
accepts `RemoteBackfillSqlRules` **or** the pre-existing bare
`CanonicalSqlFor`. The bare arm is **fail-closed, not a fallback**: with
no guard to withhold by, the convergence phase is refused, the column
reports `error` and stays unmarked, and reads stay correct on the
repair. So an external caller compiled against 17.4.0 keeps compiling
and still cannot write a misreading. A required parameter was considered
and rejected: this repo forbids a `major` changeset during the launch
window, and a `minor` carrying a BREAKING banner would owe an ADR-0087
disposition for which no honest category exists here
(`runtime-interface-only` needs zero in-code references,
`type-surface-only` needs an erased base-side type,
`no-migration-prescription` is closed by the FROM/TO the changeset
owes). The union keeps the fix out of that dead end.

## The three questions

**1. Does the julian misread reach the epoch-text `UPDATE`?** No, and
for two independent reasons, either sufficient.

*Structural:* its SET expression is `canonicalSqlFor(kind, 'cast(col as
real)')`. `typeof(cast(X as real))` is always `'real'`, so the canonical
expression takes its integer/real limb — `strftime('%Y-%m-%dT%H:%M:%fZ',
v/1000.0, 'unixepoch')`. The julian limb lives only in the `else` arm's
`strftime(..., col)`, which that statement can never reach, whatever the
row holds.

*Set-theoretic:* its `WHERE` selects digits-only TEXT in `1e12 <= v <
4.1024448e12`, and a bare number is read as a julian day only below
`5373484.5`. Disjoint (see Q3). ANDing the guard on there would withhold
nothing and would falsely imply the two sets meet, so the statement is
left alone and the reading is recorded beside it, with a pin.

**2. After this change, can a column still be marked canonical while
holding a row the shared repair reads as a BC date?** No — and the path
is named rather than assumed absent. `canonical` is `after.residual ===
0 && after.epochInBand === 0`. A julian-readable digits-only cell is not
equal to what the repair yields for it (`'2026'` vs
`'-4707-06-11T12:00:00.000Z'`), so `col IS NOT canonical` holds and the
row is counted in `residual`; it is withheld from the `UPDATE`, so it
stays counted; therefore `residual >= 1` and the mark cannot be granted.
The early fast-path return is gated on the same `probe.residual === 0`,
so it is not a second door.

*Control:* the same instrument reports `residual = 0` for a digits-only
value **above** the julian ceiling (`'1753660800'`, `'999999999999'`,
`REMOTE_BACKFILL_EPOCH_MS_MAX + 1`) — so the non-zero residual is about
the value, not a stuck counter. The `'huge'` test asserts that column is
still marked, and asserts `julianday()` is NULL for it, taken from
SQLite rather than claimed.

**3. Where does the epoch band sit relative to the julian range?** There
is a **gap of nearly six orders of magnitude**; the bands neither
overlap nor abut.

| | lower | upper |
|---|---|---|
| julian-day reading of a bare number | `0` | `5373484.5` (exclusive) |
| `REMOTE_BACKFILL_EPOCH_MS_MIN` .. `_MAX` | `1000000000000` |
`4102444800000` (exclusive) |

Measured, not derived: `julianday('5373484.4')` answers `5373484.4`,
`julianday('5373484.5')` is NULL, and `julianday()` is NULL at both ends
of the epoch band. The gap `5373484.5 <= v < 1e12` is digits-only text
that neither limb touches — genuine `coalesce` fixpoints, and that is
where `'1753660800'` (epoch **seconds**) lands. Consequence: the guard
cannot withhold a single row the 后果 B recovery would convert, which is
pinned end to end by a column holding both an in-band epoch-text row and
a withheld julian row.

## Verification

Run on the merged tree (`origin/main` merged, then the branch squashed
onto it with the byte-identical tree; final head as pushed).

- `pnpm --filter @objectstack/driver-turso test` — **55 files, 1291
tests, 0 failed** (1289 before; the 9 new tests, minus the rewritten
one, account for it).
- `pnpm --filter @objectstack/driver-turso typecheck` — exit 0.
- `pnpm --filter '...@objectstack/driver-turso' typecheck` — downstream
consumers, **scope 21 of 82 workspace projects**, exit 0. No in-repo
consumer of the three entry points exists outside this package
(grepped); the sweep proves the widened third position breaks none of
them.
- `pnpm exec turbo run build --filter='./packages/*'
--filter='./packages/*/*'` — 72/72, exit 0.
- `pnpm lint` (repo-wide `eslint . --no-inline-config`) — exit 0. Not
narrowed; the whole scan ran.
- **Gate families**: `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` derived **61** commands. All 61 run
on the merged, built tree, each exit code captured before any pipe; **61
run, 0 NOT-MEASURED, 0 UNRUN**, reconciled with `--ran` carrying every
exit code (a derived zero, not a claimed one). Three families first
answered **exit 3 PREREQUISITE NOT MET** (`check:dual-build-cjs-loads`,
`check:lean-entry-closure`, `check:type-check-debt`) and were discharged
by building the closure they named, never by declaring them
inapplicable.
- `check:doc-authoring` caught a real finding on the first pass — the
refusal string carried a tracker id an operator cannot resolve. The id
moved to an adjacent comment and the gate is green.

### Ablation

One anchor, `resolveBackfillSql`'s `nonTemporalText:
sql.nonTemporalText`, replaced with a predicate that matches nothing —
the single funnel both the probe count and the `UPDATE`'s `WHERE` read,
so the mutated module behaves exactly as the pre-fix one. Driven through
`scripts/ablation-replace.mjs`, which proves the write against the disk:

```text
anchor   "nonTemporalText: sql.nonTemporalText"  x1 -> x0
replace  "nonTemporalText: () => '(0)'"          x0 -> x1
blob     dcbb875 -> 1dd0ca3cfab510d38ace81d4de30bea0975e1995
```

Result: **5 failed | 24 passed (29)**. The subject is resolved from
source by a relative in-package import, so no build sits between the
mutation and the run.

Red, each for the right reason: the datetime row became
`-4707-06-11T12:00:00.000Z`; the time row became `12:00:00`; `'now'`
became the run's own wall clock; the plan's `residual` read 3 instead of
4; and the julian row in the mixed column was rewritten.

Deliberate positive controls that stayed **green**, so the suite cannot
pass vacuously: the above-the-ceiling fixpoint row is still marked
canonical; the not-over-broad case (naive, offset, canonical and junk
rows) still converges and marks; the band-disjointness measurement is
unmoved; both fail-closed cases are unmoved (they take the bare arm,
which the mutation does not reach); and every objectstack-ai#5770 batching,
resumability, failure and identifier-safety test is unmoved.

Restore proven, not assumed: blob back to `dcbb875a6bfb`, `git diff
HEAD` empty, and whole-tree `git status --porcelain` empty.

## Changeset

`.changeset/6009-turso-remote-julian-guard.md`,
`@objectstack/driver-turso: minor`. Measured rather than assumed: the
package is not private, its `files[]` ships `dist`, and the changed
bytes reach it — `nonTemporalTextRowsWithheld` appears 7x in
`dist/index.js`, 7x in `dist/index.mjs` and 2x in `dist/index.d.ts`,
`RemoteBackfillSqlRules` 4x in the `.d.ts`, and the refusal string once
in `dist/index.js`, against a negative control that matches nothing.
`minor` rather than `patch` because new exported types and a new report
field land; no BREAKING banner, because nothing breaks.

## Acceptance notes

- `unresolvedEpochTextRows` and `nonTemporalTextRowsWithheld`
deliberately **overlap** and neither is a subset of the other: the guard
also matches shapes the digits-only count never sees (a leading `+` or
space, `'2026e0'`, a digits-only BLOB — all measured). Subtracting one
from the other to make the older field's sentence uniformly true would
have produced a count that can go negative; the fields' contracts say
which rows are in which half instead.
- The convergence phase still issues one statement that matches zero
rows when every residual row is withheld, exactly as the local twin
does. Noted, not filed: one wasted statement on a column that cannot
converge, on a path that already costs a probe.

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

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants