test(spec,drivers): add the VALUE_ROUNDTRIP conformance case-set — what you wrote is what you read back, per driver per dialect - #12585
Conversation
) The driver-conformance census was green at 9 of 9 dialect-scored cells and none of its nine case-sets was about value storage — every one asks which rows come back, none asks what is in them. VALUE_ROUNDTRIP closes that as a class: 41 cases over json / multiple / string / number / boolean, asserting type as well as value, plus the injectivity pairs a per-value check cannot see. Enrolled through the census's own machinery, so CLASSIFIED obliges the fixture to be classified, CONSUMED obliges every driver to run it, and MATRIXED obliges driver-sql's cell to be answered on every dialect it speaks. 50 covered cells, 10 of 10 dialect-scored cells matrix-routed, 0 DEBT, 0 exempt. Also fixes a test-double fidelity gap the case-set surfaced: driver-turso's libsql stub did not model @libsql/client's client-side boolean -> 1/0 conversion, so a declared boolean written through the remote transport could not be bound at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
#12393) The generated export-origins artifact records every runtime export of each entry point, and `./data` gained seven. Caught by the package's own compiler-free freshness guard (scripts/export-origins.test.ts), regenerated with the command that guard's failure text names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
📓 Docs Drift Check60 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 129 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 8b8a0a9ec0302255d7a1f413705098b985aab1bb && git checkout 8b8a0a9ec0302255d7a1f413705098b985aab1bb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f93df4dbe314d5133f4a8c395b255ba0e2aeeaff c1d0ba950317028bfc94fd56b7ff911521eb46e0 && git checkout -B drift-repro f93df4dbe314d5133f4a8c395b255ba0e2aeeaff && git merge --no-ff c1d0ba950317028bfc94fd56b7ff911521eb46e0
node scripts/docs-audit/affected-docs.mjs --json f93df4dbe314d5133f4a8c395b255ba0e2aeeaff |
PM review — accepted, flipped ready, auto-merge armed. And one correction that is mine, not the PR's.⛔ My dispatch brief carried a stale premise. The dev checked it; I had not.I told this dispatch, in bold: "#5499 froze investment in Verified now, on #5499's own comments:
The freeze has been fully lifted for fifteen days. The dev read the anchor, found the rulings, enrolled both drivers on the same terms as everyone else, and flagged the stale premise instead of quietly working around it — which is why this cost nothing except the reading. Had they scoped the case-set to "drivers not under the freeze" as my brief invited, the column would have shipped with two backends silently excluded and a false reason recorded in the file. This is the same discipline I have been enforcing on every dispatch this shift — re-verify the premise on current state, never inherit it — applied to a card's standing constraints rather than its citations, which is where I stopped looking. The card body, my brief, and #12380's landing comment all still describe the freeze as live; #5499 is the authority and it says otherwise. The case-setSize re-measured, not inherited: 41 cases, 5 new cells (45 → 50), 7 backends executed, 0 driver-side divergences. Zero ledger rows, both Green on arrival proves nothing, and the PR knows it. The ablation is the card's thesis measured rather than asserted: restoring the pre-#12380 SQLite Two self-corrections, both reported rather than tidied away
The one non-test change is not what it looks likeA Follow-ups I am taking, so they do not evaporate
CI is the remaining gate — including Generated by Claude Code |
…RIP exports CI's `Type Check · consumer gates` was red on `check:api-surface`, which named the seven additions and the exact remedy. Regenerated with the repo's tooling; the diff is those seven lines and nothing else, and `check:api-surface` now reports the surface unchanged. The generator refuses without a built package and says so rather than emitting a wrong snapshot — the build was run first, and `OS_SKIP_DTS=1` was NOT used, since that flag skips exactly the declarations this reads (AGENTS.md 9). Why it reached CI: `check:api-surface` is one of the nine families in #12514 whose declared path population matches nothing in the tree, so the local dispatch-gates derivation could not name it for this change set. The sibling `export-origins` snapshot WAS regenerated, because that one was caught by the package's own suite.
CI was red on
|
Fixes #12393
Route D of #12380, ruled in by the maintainer on 2026-08-26 alongside route A and
sequenced behind it. Route A landed as
4045b954d3, so the reason for the hold hasexpired.
What this adds
VALUE_ROUNDTRIP_CASES— a tenth case-set in the driver-conformance census, asking everydriver on every dialect the one question none of the other nine asks: what you wrote is
what you read back.
The census was green at 9 of 9 dialect-scored cells after #12136 promoted
MATRIXED, andall nine of its case-sets are about which rows come back — filters, temporal form,
pagination, aggregation. None is about what is in them. That green was therefore not
weak evidence about a round-trip defect; it was no evidence at all, and it would have
stayed green forever with the defect in place. Hence the drip of one-instance cards:
#12380, #11535, #11782, #10995.
Enrolled through the census's existing machinery rather than as a bespoke suite —
that reuse is the whole argument for this route.
CLASSIFIEDobliges the fixture to beclassified,
CONSUMEDobliges every driver to run it, andMATRIXEDobligesdriver-sql's cell to be answered on every dialect it speaks rather than on SQLitealone, which is precisely the coverage shape that let #12380 survive.
The size, re-measured rather than inherited
The card refused to size itself and asked for a measurement. Measured:
json/multiple: true/string/number/booleanLEDGERandDIALECT_LEDGERboth stay emptyEvery value in the table is one some driver was measured to change (#12380's boundary
set, #11535's array, #11782's boolean) or a control that stayed faithful in the same
measurement. Nothing was invented to round out a table.
The three things the card said would decide acceptance
1. #5499 — the freeze is already fully lifted, so no freeze-scoping was needed
The brief said to read the ruling before scoping. Reading it changed the answer. #5499 is
still open as a historical anchor, but both halves were lifted by the maintainer on
2026-08-11, recorded on the card itself:
The second comment states it outright: "this card's freeze is fully dissolved … it no
longer gates anything." So
driver-memoryanddriver-mongodbwere enrolled on the sameterms as everyone else, and both answered the case-set green on arrival. Nothing was
quietly fixed in a frozen driver, and nothing needed to be — no driver source changed at
all. The freeze did not have to become an excuse, because there is no freeze.
dispatch and #12380's landing comment all still describe the freeze as live.
2. A gate that ships red vs. one that ships honest — it ships green, and non-vacuously
Zero red cells, zero ledger rows, and no new escape hatch — so the #12136 sharp edge
(a
DIALECTrow deliberately does not clearMATRIXED) is not re-opened, because nothinghere asks a ledger for anything. That was reachable only because route D was sequenced
behind route A; landing it first would have shipped red on SQLite by construction.
⛔ Green on arrival is worth nothing unless the column can go red, so it was ablated —
see below.
One value class is scoped out by name rather than ledgered: a declared field written
as an explicit
undefined, which the JS-backed drivers return as an own key holdingundefined. That is #9276 — open, on hold, tracked — not something this table discovered.Including it would have shipped a case-set red on
driver-memoryby construction. A ledgerrow would have been the wrong instrument: the case-set simply does not ask that question
yet, and the case-set file says so, by name, with the condition under which it should.
3. Clause ② — no, confirmed against the actual diff
Six test files, one conformance fixture, one
CASE_SETSrow, one changeset, oneregenerated artifact, and one test-double fix. No Zod schema, no runtime, no driver
source, no API, no config. The two
packages/spec/srcfiles are the fixture and itsexport * fromline — the package'sdataexport gains seven names that only conformancesuites consume, and nothing an existing consumer resolves changes shape. No accept-set is
narrowed or widened anywhere in the diff.
Ablation — the column can go red, and only where it should
Direction and exact failure counts were written down before the mutation was applied;
the mutation was proven on disk by anchored
grep -cFcounts before any result was read;restore ran under
trap … EXIT INT TERMand was verified with an emptygit diff.Mutation: restore the pre-#12380 SQLite branch in
SqlDriver.formatInput— on SQLiteonly, store a non-object
Field.jsonvalue as-is instead ofJSON.stringify-ing it.sql-driver-12380-json-roundtrip(instance pin)⇒ The new column goes red on three drivers while all nine existing columns stay green.
That is the argument the card was filed on, measured rather than asserted.
Mutation confirmed on disk before any result was read: fix marker
ONE encoding, every dialect (#12380)1 → 0, mutant markerABLATION-12393-MUTANT0 → 2,git diff --numstat=4 2. Restore verified: markers back to 1 / 0,git statusempty.Two things the run corrected, reported rather than tidied away
(a) A false green that read exactly like "the case-set does not bite there." The first
pass mutated driver-sql's source only.
driver-sqlite-wasmanddriver-tursoimportSqlDriverfrom@objectstack/driver-sql— the package exports, i.e.dist/— so theirfirst readings (
44 passed,88 passed) were measured against the unmutated artifactand are void.
ablation-dist-preflightthen refused the first rebuilt attempt too, becausethe marker was only a comment and tsup strips comments from executable output — it
survived in the sourcemap alone, and the tool correctly called that not a reading. Only
with an executable marker did the preflight pass (
✓ marker present in 2 built files) andthe real numbers appear. Both legs rebuilt; the restore leg rebuilt as well and proved the
marker absent from all 6 built files, so no later measurement runs against a poisoned
dist.driver-sql's own suites were not exposed to this, and that is measured rather than
assumed: all eight resolve
SqlDriverthrough relative source (./sql-driver.js,./index.js,../src/index.js) and zero driver-sql test files import@objectstack/driver-sql— with a positive control, since the same grep foundfrom './sql-driver.js'in four of them.(b) A mechanism claim of mine was wrong. I predicted turso's remote transport would
stay green because it "does not reach
formatInputat all". It went red on 15 of 44. Thesource says why:
TursoDriver.toRemoteWriteFormsisformatInput, deliberately, "sothe two transports cannot disagree about what a written value becomes." I had reasoned
from
RemoteTransport.serializeValuewithout reading its caller.The two transports diverge by exactly one case under the mutant (
s_0123), and the reasonis informative: local emits a column declared
json(NUMERIC affinity destroys the barestring
'0123'before storage), whileRemoteTransport.mapFieldTypeToSQLemitsTEXT,where no affinity conversion applies and
JSON.parse('0123')throws so the keep-as-stringfallback holds. Both are green under the fixed code, so this is not a defect today — but a
declared-column-type asymmetry between two faces of one driver is the #11535 class, and it
is reported below rather than left in a scratch file.
The one non-test change, and its bounding scan
driver-turso'smakeLibsqlSqliteStubdid not model@libsql/client's client-sideboolean → 1/0conversion, so a declaredbooleanwritten through the REMOTE transportcould not be bound at all — better-sqlite3 rejected it outright. Surfaced by this
case-set, since it is the first thing in the package to store a declared boolean through
that transport.
⭐ Which side was wrong was settled by reading the dependency, not the comment.
RemoteTransport.serializeValuesays "booleans are kept as-is (libsql handles them)" —and
@libsql/client's sqlite3 backend does exactly that, converting a bound boolean to1/0under the defaultintMode(lib-esm/sqlite3.js, thetypeof value === "boolean"arm). So the transport is correct and unchanged; the stub was the half that diverged.
The asymmetry that hid it: the LOCAL path never reaches the question, because
SqlDriver.formatInputcarries its own bind safety net for these value classes.Bounding scan before touching a testkit 24 files share: zero tests anywhere in the repo
pin the stub's boolean-bind throw (
grepfor the error text finds 10 hits, none indriver-turso— all are prose indriver-sql/spec/service-analyticsaboutbetter-sqlite3's real behaviour). Since every such bind throws today, no existing green
test can depend on the old behaviour. Positive control: 20 turso test files reference the
stub, so the grep reaches them.
driver-turso's full suite: 40 files / 1094 tests, allpassing. Deliberately not modelled, and said so in the code: the client's
DateandArrayBufferconversions, becauseserializeValueconverts aDateto ISO before theclient sees one, and an unreachable conversion would be a claim nothing checks.
Changeset grade —
patch, defended@objectstack/spec: patch. The published surface gains one conformance fixture whoseseven exports only conformance suites consume; no schema, no runtime, no behaviour, and
nothing an existing consumer resolves changes shape. Not
minor, because aminorsignals a capability consumers can adopt and there is none. Not
none, because the exportsurface genuinely grows and
packages/spec/export-origins/data.jsonrecords it. Thedriver packages get no changeset: their diffs are test files plus one test-only testkit.
Verification
Gate union derived, not recalled, at the final head
6d96e0a73f:node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack→ 36 path-derivedfamilies, re-derived after the last commit and byte-identical to the earlier derivation.
All 36 run, plus the 7 convention-triggered families the derivation names (test-file kind
and gate-script kind) and
check:type-check-debt --re-measure. Every exit code capturedbefore any pipe into a per-command results file — the lock's own
VERDICTline reportsthe batch's exit and is not a verdict on any one command.
All green. Verdict lines quoted from the gates themselves, not from
$?:check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.+10 of 10 dialect-scored cell(s) have a matrix-routed suite.check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 245.3s, 1843 raw tsc error(s) total, none above its recorded number.check-nul-bytes: OK (scanned 6932 text file(s) … no raw ASCII control bytes).where-matcher conformance holds: 303 matcher(s) discovered, 303 answer the combinator battery correctly or refuse it loudlyOK: 20 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.check-engine-double-contract: 398 (file, verb) row(s) held by the RETAINED ledgerpass:
check-dev-prereqs.mjsexited 1 with "The workspace is not built — 1 unmetprecondition, not a list of problems" (34 of 67 packages missing a
dist/entry point).The workspace closure was then built (
turbo run build, 70/70 successful) and it wasre-run:
✓ 67 package build artifacts present.check:type-check-debthas the sameprecondition and was run only after that build.
Suites (all foreground, serialized through
scripts/pm/os-verify-lock.sh):@objectstack/spec@objectstack/driver-sql@objectstack/driver-turso@objectstack/driver-memory@objectstack/driver-sqlite-wasm@objectstack/driver-mongodbTypecheck: all six touched packages, each script name echoed in the output so a
zero-match
--filtercannot read as a pass.@objectstack/specrun came back1 failed | 11463 passed— the package's own compiler-free freshness guard, becausepackages/spec/export-origins/data.jsonis generated and my seven new exports made itstale. Regenerated with the command the failure text names; the diff is exactly those
seven lines and nothing else. Worth noting which instrument caught it: the derivation
lists
check:export-originsamong the 9 families "where the layout moved under a gatethat still spells the old path" — so the path derivation could never have named it, and
only running the package's full suite did.
OS_TEST_POSTGRES_URLand
OS_TEST_MYSQL_URLare unset, so the pg and mysql cells skip rather than provegreen. CI's
Temporal Conformance (live PG + MySQL)job runs the wholedriver-sqlsuitewith both servers and
OS_EXPECT_LIVE_DIALECT_MATRIX=1, which turns those skips into namedreds — and it picks this file up automatically, since it runs the package rather than a
path list. The asymmetry half of the ablation therefore rests here on (a) the mutation
being
this.isSqlite-guarded by construction and (b) driver-memory / driver-mongodbmeasuring green under it, which is a real cross-driver control this container can run.
The live PG/MySQL half was measured on #12380's own ablation.
Out-of-scope findings — handed up, not filed
⛔ Neither is filed as a card, and the reason is a channel fact rather than a judgement:
direct REST to the GitHub API returns 403 from this dev seat (the #12123 gap #12380
documented), and MCP issue search is off-limits for dedup reads under the dispatch
contract. Filing without a dedup search would be filing blind, so both are handed up with
the local-grep half of the evidence done. Same call the #12172 dev made.
[drivers] a driver returning a declared field as an own key holding
undefinedis a storage-contract defect — Option C from #8489 #9276's restart condition fired nine days before it was written. That card sitspm:on-holdwithRestart-when: … OR driver-memory / driver-mongodb come out of the #5499 investment freeze. The line was added 2026-08-20; the freeze was fully lifted2026-08-11. So it is held by a condition that was already satisfied at the moment of
writing, and no mechanism will ever wake it — which is the exact failure that card's own
note says the restart line was added to prevent.
driver-tursodeclares aField.jsoncolumn asjsonlocally andTEXTremotely.SqlDriveremitsjson(confirmed from the catalog by the [finding] driver-sql's SQLite json codec is not round-trip faithful for a string whose content is valid JSON — '123' reads back as a number, and Postgres does not agree #12380 suite);RemoteTransport.mapFieldTypeToSQLreturnsTEXTfor bothjsonandmultiple: true.Benign today — both transports round-trip faithfully, which this PR's suite measures —
but the two faces of one driver put the same declared field in different physical column
types, and the ablation showed they consequently fail differently under the same
defect. That is the driver-sql: single→multi-value field change never migrates the existing column type (varchar/text kept), arrays silently stored as stringified literals — data corruption on Postgres #11535 class. Local grep finds the two
mapFieldTypeToSQLlines andone comment in
remote-read-coercion.test.ts(// json stored as TEXT); nothing statesthe asymmetry as a known, intended fact.
Generated by Claude Code