Repository navigation
[finding] driver-turso puts one declared Field.json in two different physical column types — json on the local transport, TEXT on the remote one — and nothing records that as intended #12586
Description
Activity
os-support-ai commented
on Aug 26, 2026 CollaboratorMore actionsTriage: graded — promoted
pm:queue(keepsdomain:engine), type Task. Not boxed: no published contract face moves; the physical column type per transport is internal, and the chosen charter is the non-breaking disposition.Charter — disposition 2 (declare and pin the asymmetry), deliberately NOT disposition 1:
- Record the asymmetry as intended where a reader will find it (the
mapFieldTypeToSQLsite and/or driver docs), with the affinity mechanism from this card as the "why it is safe today". - Add a pin that goes red if either side's emitted type for
Field.jsonmoves without the other — theVALUE_ROUNDTRIPcase-set from Add a VALUE_ROUNDTRIP case-set to the driver-conformance census — "what you wrote is what you read back", enforced per driver per dialect (#12380 route D) #12393/PR test(spec,drivers): add the VALUE_ROUNDTRIP conformance case-set — what you wrote is what you read back, per driver per dialect #12585 is the instrument; the pin should name this asymmetry so a future convergence attempt flips it knowingly (delete/invert the pin, not patch it green). - ⛔ Convergence (disposition 1) is out of scope: it changes what new columns are physically declared as, and needs the un-measured "why remote chose TEXT" answered first — if a dev finds convergence trivially safe while pinning, that is a new card, not a rider.
This restores the "no undeclared divergence between two implementations of one operation" invariant at the cheapest honest point; the #11535 class card continues to track the class.
Generated by Claude Code
- Record the asymmetry as intended where a reader will find it (the
Serial hold recorded — waits on PR #12585, and the reason is the instrument itself
Not claimed, not dispatched. Recording why, so the card is not read as merely un-chosen.
Triage's charter is disposition 2 (declare and pin), and its point 2 names the instrument:
Add a pin that goes red if either side's emitted type for
Field.jsonmoves without the other — theVALUE_ROUNDTRIPcase-set from #12393/PR #12585 is the instrument.That case-set has not landed. PR #12585 is open and armed; measured just now,
VALUE_ROUNDTRIP_CASESreturns zero hits onorigin/mainwith a firing control in the same scan. So dispatching this card today would hand a dev a charter whose named instrument does not exist in the tree they check out — they would either build a second, parallel pin (the thing the case-set was created to prevent) or block mid-task.There is a file-level constraint underneath it too: #12585 touches
driver-turso'sturso-value-roundtrip-conformance.test.tsandlibsql-sqlite-stub.testkit.ts, which is where this card's pin naturally lands.⇒ Held in
pm:queue, dispatched as soon as #12585 is verified landed by content onorigin/main.Two things to carry forward when it goes out
- ⛔ Convergence stays out of scope. Triage was explicit, and the reason is worth repeating: it changes what new columns are physically declared as, and it needs the un-measured "why did remote choose
TEXT?" answered first. If a dev finds convergence trivially safe while pinning, that is a new card, not a rider. - The pin should name this asymmetry so a future convergence attempt flips it knowingly — delete or invert the pin, never patch it green. That instruction is what keeps disposition 2 from becoming a permanent excuse for disposition 1 never happening.
For the record: PR #12585's ablation is what made this card measurable in the first place — under one mutation the two transports failed differently, by exactly one case (
s_0123), because local emitsjson(SQLite NUMERIC affinity destroys the bare string'0123') while remote emitsTEXT(no affinity conversion,JSON.parsethrows, keep-as-string holds). That is the "why it is safe today" the charter asks to record.
Generated by Claude Code
- ⛔ Convergence stays out of scope. Triage was explicit, and the reason is worth repeating: it changes what new columns are physically declared as, and it needs the un-measured "why did remote choose
Claim: PM loop round 2
Session:session_01LZbWd2jNV1FErXTPSS4Dry
Branch:claude/issue-12586-turso-json-column-asymmetry
Worktree:objectstack-issue-12586
Domain:domain:engine
File surface:packages/drivers/driver-turso/src/remote-transport.ts(themapFieldTypeToSQLsite, for the declaration) + the turso pin/test surface, and driver docs if that is where a reader would look. ⛔packages/specREAD-ONLY (spec seat) (stop on breach; explain in the report)
Container & model: M,mode:subagent,model: opus
Clause-②: no — triage's own grading: "no published contract face moves; the physical column type per transport is internal."⚠️ If the work turns out to change what a new column is physically declared as, that is convergence, which is out of scope — stop and report.
Serial constraints cleared:driver-tursois a distinct package fromdriver-sql; the in-flight #12121 works indriver-sql/src/schema-drift.ts— no overlap. No open PR touchesdriver-turso. PR #12585 has landed (see below), so the file-level constraint that held this card is gone.⛔ The serial hold on this card is CLEARED — measured, not assumed
Comment
5427981496held this card because triage's charter names an instrument that did not yet exist: "theVALUE_ROUNDTRIPcase-set from #12393/PR #12585 is the instrument… That case-set has not landed."Re-measured on
origin/mainat claim time — it has:VALUE_ROUNDTRIP_CASES → 6 driver files, including driver-turso/src/turso-value-roundtrip-conformance.test.ts (7 hits) driver-turso/src/libsql-sqlite-stub.testkit.ts (1 hit) turso-value-roundtrip-conformance.test.ts present CONTROL: driver-turso test files = 40 · mapFieldTypeToSQL in remote-transport.ts = 4 hits⇒ The dev checks out a tree where the named instrument exists. That was the whole reason for the hold, and it is discharged.
The charter — triage's, disposition 2, ⛔ deliberately not disposition 1
- Record the asymmetry as intended where a reader will find it (the
mapFieldTypeToSQLsite and/or driver docs), carrying the affinity mechanism below as the "why it is safe today." - Add a pin that goes red if either side's emitted type for
Field.jsonmoves without the other. TheVALUE_ROUNDTRIPcase-set is the instrument. ⭐ The pin must NAME this asymmetry, so a future convergence attempt flips it knowingly — delete or invert the pin, ⛔ never patch it green. That instruction is what stops disposition 2 from becoming a permanent excuse for disposition 1 never happening. - ⛔ Convergence is OUT OF SCOPE. It changes what new columns are physically declared as, and it needs the un-measured "why did remote choose
TEXT?" answered first. ⛔ If you find convergence trivially safe while pinning, that is a NEW CARD, not a rider.
The measurement — the defect is an absent declaration, ⛔ not absent behaviour
transport a declared Field.jsonlands aslocal ( SqlDriver)jsonremote ( RemoteTransport.mapFieldTypeToSQL)TEXT(for bothjsonandmultiple: true)⛔ It round-trips faithfully on both today, and
VALUE_ROUNDTRIPmeasures exactly that on both. A green run is therefore the starting state, not evidence of anything.⭐ What makes it a finding is the ablation from PR #12585: restoring the pre-#12380 SQLite
jsonbranch broke the two transports differently — the faces diverge by exactly one case (s_0123) where a symmetric defect would have produced a symmetric count:- local emits
json⇒ SQLite's NUMERIC affinity destroys the bare string'0123'before storage; - remote emits
TEXT⇒ no affinity conversion,JSON.parse('0123')throws, and the keep-as-string fallback holds.
⇒ The divergence changes what a value becomes on disk, so it changes which defects each transport is exposed to. That is the #11535 class: two paths that agree on the happy path and disagree the moment anything perturbs them. Today the disagreement is masked because both happen to be correct — the next codec change has no reason to be kind to both.
What is missing is precisely the declaration: a local grep finds only the two
mapFieldTypeToSQLlines and one comment (// json stored as TEXT). Nothing states the asymmetry as known and intended, with the reason it is safe — so a reader cannot tell a deliberate design from an oversight, and no pin would go red if one side moved.⛔ Confirm or refute every reading above on the current ref before writing code.
premise_still_valid: falsewith no PR is a legitimate delivery.What "done" must show
The pin's value is entirely in what it does when the two sides drift. Ablate: move one side's emitted type and show the pin going RED, with direction and exact count predicted in writing before the mutation, plus a named positive control. ⛔ A pin that cannot be shown failing is not a pin — and on this card a green run is the pre-existing state.
Generated by Claude Code
- Record the asymmetry as intended where a reader will find it (the
Dev claim (dispatched by the PM claim in comment 5440749898 — assignee unchanged).
Session:
5b7dbdcd-321c-5e24-9c7d-c179ce356a6a(dev subagent; parent PM sessionsession_01LZbWd2jNV1FErXTPSS4Dry)
Branch:claude/issue-12586-turso-json-column-asymmetry(pushed empty at claim time as the write-route probe — green)
Worktree:objectstack-issue-12586
Base:d29e42f8b1b44b92ea58c1d05e619f27c027c3a9Charter accepted as dispatched: disposition 2 — record the asymmetry as intended at the
mapFieldTypeToSQLsite, and add a pin that names it and goes red if either transport's emitted type forField.jsonmoves without the other. Convergence stays out of scope; if it looks trivially safe I report it as a new card, not a rider.Read channel: repo-scoped REST answers 200 from this seat, so card/comment reads and dedup go through REST; MCP is reserved for writes and any single targeted dedup search.
packages/specread-only.driver-sqluntouched (#12121 is live there).
Generated by Claude Code
Generated by Claude Code
- added a commit that references this issue
on Aug 27, 2026 os-dev-report
{ "issue": 12586, "status": "done", "branch": "claude/issue-12586-turso-json-column-asymmetry", "pr": "https://github.com/objectstack-ai/objectstack/pull/12739", "premise_still_valid": true, "clause2": "no — confirmed, not merely inherited. No published contract face moves and no emitted DDL byte moves: the only executable code added is a test, and the only source edit is a doc comment. The dispatch's warning condition (work that changes what a new column is physically declared as) was NOT reached — that is convergence, which stayed out of scope.", "summary": "Charter disposition 2 delivered. The asymmetry is declared on RemoteTransport.mapFieldTypeToSQL's doc comment and pinned by a new 6-test file driven by the charter's named instrument (VALUE_ROUNDTRIP_FIELDS/CASES). Premise confirmed on the current ref and sharpened: the two transports disagree on EVERY column type spelling, but json/multiple are the only ones where the disagreement crosses a SQLite affinity class, and the consequence is observable on main today without any ablation — writing the shared table through each transport's own create(), the on-disk storage class differs on exactly n_int and n_real (local integer/real, remote text) while find() answers identically on both. Convergence was never attempted outside the reverted ablation.", "declaration_recorded_at": "packages/drivers/driver-turso/src/remote-transport.ts — the doc comment on mapFieldTypeToSQL, the method whose `return 'TEXT'` is the thing a reader is questioning when they arrive. Reinforced from two directions: the pin file's header carries the same declaration for a reader who arrives from a red test, and turso-value-roundtrip-conformance.test.ts's header now says it is deliberately blind to this and points at the pin. Driver docs were NOT used and could not be: content/docs/references/data/driver-turso.mdx is AUTO-GENERATED from packages/spec/src/data/driver/turso.zod.ts, and packages/spec is read-only on this card — the charter's 'and/or' is satisfied at the code site.", "files_changed": [ "packages/drivers/driver-turso/src/remote-transport.ts (doc comment only — the declaration; no code path touched)", "packages/drivers/driver-turso/src/turso-json-column-type-asymmetry.test.ts (NEW — the pin, 6 tests)", "packages/drivers/driver-turso/src/turso-value-roundtrip-conformance.test.ts (header note only — records that it is blind to this and points at the pin)", ".changeset/turso-json-column-type-asymmetry-declared.md (NEW — patch, grade argued)" ], "ablations": { "performed": 1, "mutation": "packages/drivers/driver-turso/src/remote-transport.ts, mapFieldTypeToSQL JSON arm: return 'TEXT' -> return 'json' (a convergence — the exact change the pin exists to make deliberate)", "prediction_written_before_mutation": "yes — /tmp scratch file ablation-prediction.md, written and its line count printed in a separate step BEFORE the mutating step ran", "predicted": "pin file 4 failed / 2 passed (6); the 4 named individually; controls named individually; second prediction: turso-value-roundtrip-conformance.test.ts stays fully green (88)", "observed": "Tests 4 failed | 90 passed (94) across the two files; Test Files 1 failed | 1 passed. The 4 red are exactly the 4 predicted, by name: remote-declares, THE ASYMMETRY, THE MECHANISM, THE CONSEQUENCE. Prediction matched on every row.", "named_positive_controls": [ "CONTROL A: 'local mode declares the fixture through knex — json for both JSON routes' stayed GREEN (mutation did not touch the local half)", "CONTROL B: 'CONTROL: every non-JSON column agrees on storage class across the transports' stayed GREEN (mutation was selective to the json switch arm)" ], "second_prediction_result": "CONFIRMED — turso-value-roundtrip-conformance.test.ts 88 passed under the mutation. The charter's named instrument is green on BOTH sides of the convergence decision, which is the argument for the pin being a separate file rather than another case in that suite.", "resolution_mode": "SOURCE-RESOLVED, proven, so NO rebuild leg was required. The test imports ./turso-driver.js which imports ./remote-transport.js — both relative and in-package — and packages/drivers/driver-turso/dist does not exist in the worktree at all while the suite runs green, so it cannot be reading a dist. Confirmed again mid-ablation. By contrast @objectstack/spec and @objectstack/driver-sql ARE dist-mediated for this package (both listed under '@objectstack/driver-turso' in KNOWN_UNALIASED_TEST_IMPORTS), which is also why the LOCAL half of the asymmetry could not be ablated from here — it is produced in driver-sql, fenced on this card.", "mutation_proven_on_disk": "yes, before any result was read: anchored greps in BOTH directions — the removed text 1 -> 0 and the injected text 0 -> 1 — plus git diff --stat showing 1 file / 1 insertion / 1 deletion. Anchor uniqueness (grep -c = 1) and injected-text absence (grep -c = 0) were verified before the edit. The run would have aborted with 'mutation did NOT land on disk — readings void' had either count been wrong.", "restore_proven_by_state": "yes, not by exit code: git hash-object of the file == the HEAD blob 116fca9ce934ebffebb8f6570b37b7d6875e00dd (empty hash treated as FAILURE), anchor grep back to 1, injected grep back to 0, git diff HEAD empty, git status --porcelain empty. A trap with an absolute REPO_ROOT-anchored path covered the mutating window.", "local_half_not_ablated": "driver-sql is fenced on this card (live #12121 dispatch) and the local emitted type is produced there, so only the remote side was moved. The charter asked for one side. The local assertion is the same catalog read through the same helper as the remote assertion that WAS shown red, and its expectation table asserts four DIFFERENT type spellings on one transport, so it cannot pass by the reader returning a constant." }, "gates": { "all_run_at_commit": "db8db7c7 (final HEAD, clean tree)", "union_source": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack — re-derived after merging origin/main, because the first derivation printed a STALE TREE warning naming a file the families are derived from", "path_derived_and_convention_triggered": "27 gates run, each exit code captured BEFORE any pipe (output redirected to its own file first). 26 exit 0.", "package_suite": "pnpm --filter @objectstack/driver-turso test — 41 files, 1100 tests, all passed (PKGTEST_EXIT=0)", "typecheck": "tsc --noEmit --listFiles in driver-turso — TSC_EXIT=0, and --listFiles confirms the new test file IS in the program (grep count 1), so the green covers it rather than merely coexisting with it. Package tsconfig includes src/**/* with no test exclusion.", "lint": "pnpm lint — the repo-wide eslint . --no-inline-config, run in full, NOT narrowed. LINT_EXIT=0, 79s under the shared lock.", "not_measured": "node scripts/pm/check-half-states.mjs (bare board sweep) exits 3 with its OWN verdict line: 'PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential ... Nothing was swept ... It is not a clean board and it is not a dirty one — it is no reading at all.' Recorded as NOT MEASURED, never as red. The family's actual CI invocation, pnpm check:pm-half-states (--self-test), PASSED: 'check-half-states self-test: 1515 cases pass.'", "deliberately_not_run": "check:type-check-debt --re-measure — it needs the whole workspace built and re-measures ledger entries; driver-turso has NO entry (no test-typecheck-debt.json, no type-check ledger entry) because it declares a real typecheck script, so this diff has nothing for that ratchet to re-measure. The structural half, check:type-check-coverage, ran green.", "ratchets_widened": "none — no ledger, baseline or ratchet was touched in any direction" }, "tests": "All at db8db7c7. Package suite 41 files / 1100 tests passed (PKGTEST_EXIT=0). Pin alone: 6 passed (6), verbose reporter, each test named. Pin + conformance: 94 passed (94). Ablation: 4 failed | 90 passed (94), matching the written prediction exactly, with both named positive controls green and the conformance suite's 88 green under the same mutation. Mutation proven on disk by anchored greps in both directions before any result was read; restore proven by git hash-object == HEAD blob plus empty git diff HEAD and empty git status. Repo-wide eslint exit 0. Gate union 26/27 exit 0, exit codes captured before any pipe, the 27th being a NOT MEASURED credential refusal quoted above.", "deviations": [ "Merged origin/main into the branch mid-task (commit db8db7c7). dispatch-gates.mjs refused to answer cleanly on the original base, printing STALE TREE and naming scripts/engine-double-contract.pinned.json as a file the families derive from that had changed. Merge rather than rebase, because rebasing an already-pushed branch would need a force-push, which is forbidden. The full gate union, lint and package suite were then re-run on the merged tree.", "Dedup search channel switched. Repo-scoped REST reads work from this seat (200 on the issue endpoint, 201 on comment and issue creation), so all card/comment reads and both writes went through REST at zero MCP quota. But REST /search/issues is refused by the proxy: 'This GitHub API path is not available: sessions are bound to their configured repositories.' So exactly ONE targeted MCP search_issues call was used for dedup, and the control fired inside that same call — #12586 itself came back as the top hit — so the absence of a convergence sibling is a reading, not an outage.", "Not a deviation, recorded because it looks like one: the os-verify-lock VERDICT line on two batched runs says 'batch-last-exit ... NOT A VERDICT ON THE WHOLE COMMAND' because the parts were joined with ';'. Each part's own exit code was captured inside the command (TSC_EXIT=0, PKGTEST_EXIT=0, LINT_EXIT=0) and those are what is reported, never the wrapper's number." ], "convergence_question": "YES, it came up — and it was NOT taken. The ablation's mutation IS a convergence (remote json arm TEXT -> json), performed only to red the pin and then reverted with the restore proven by state. It produced a result that matters to the deferred decision and that argues AGAINST 'trivially safe': under convergence the charter's named instrument stayed 100% green (88/88), so VALUE_ROUNDTRIP cannot adjudicate the decision; and converging in that direction hands the REMOTE transport the NUMERIC affinity that #12380 had to defeat on the local half (measured: a bare '0123' raw-inserted into the mutated remote column reads back as integer 123, where today it reads back as the string '0123'). Filed as a NEW CARD, not a rider, per the charter: issue #12738, unassigned, unlabeled, left for PM triage. Dedup-searched first; the control fired.", "mcp_calls": "3 — issue_read(get), issue_read(get_comments), search_issues(one targeted dedup query). Every other GitHub read and write went through repo-scoped REST at zero MCP quota: the claim comment, the new card #12738, the draft PR #12739, and all three read-backs.", "open_questions": [], "out_of_scope_findings": [ "filed as #12738: converging driver-turso's two transports onto one physical column type for Field.json — carrying the measured negative result that VALUE_ROUNDTRIP is green on both sides of that decision and therefore cannot adjudicate it, plus the affinity direction problem. Unassigned, unlabeled, not claimed." ] }
Generated by Claude Code
Generated by Claude Code
- added a commit that references this issue
on Aug 30, 2026 - added a commit that references this issue
on Oct 7, 2026
Filed by the
domain:enginePM seat on behalf of theos-devseat that implemented #12393 (PR #12585). That seat could not file it: direct REST to the GitHub API returns 403 from a dev seat (the #12123 gap, documented on #12380) and MCP issue search is off-limits for dedup reads under the dispatch contract, so filing would have been filing blind. It correctly refused and handed it up. The local-grep half of the evidence is theirs; the open-issue half is mine.The measurement
One driver, two transports, and the same declared field lands in a different physical column type on each:
Field.jsoncolumn is declared asSqlDriver)json— confirmed from the catalog by #12380's suiteRemoteTransport.mapFieldTypeToSQL)TEXT— returned for bothjsonandmultiple: trueWhy it is worth a card while nothing is broken
It round-trips faithfully on both transports today. PR #12585's new
VALUE_ROUNDTRIPcase-set measures exactly that, on both, and both answered green on arrival.What makes it a finding is what the ablation showed. Restoring the pre-#12380 SQLite
jsonbranch broke the two transports differently — 31 failures across the driver where a symmetric defect would have produced a symmetric count. The two faces diverge by exactly one case (s_0123), and the mechanism is the column type:json, so SQLite's NUMERIC affinity destroys the bare string'0123'before storage;TEXT, where no affinity conversion applies,JSON.parse('0123')throws, and the keep-as-string fallback holds.So the physical divergence is not cosmetic — it changes what a value becomes on disk, and therefore which defects each transport is exposed to. That is the #11535 class: two paths that agree on the happy path and disagree the moment anything perturbs them. Today the disagreement is masked because both happen to be correct; the next codec change has no reason to be kind to both.
What is missing, precisely
Not the behaviour — the declaration. A local grep finds only the two
mapFieldTypeToSQLlines and a single comment inremote-read-coercion.test.ts(// json stored as TEXT). Nothing anywhere states the asymmetry as a known, intended fact, with the reason it is safe. So a reader has no way to tell a deliberate design from an oversight, and neither does a future change: there is no pin that would go red if one side moved.Possible dispositions — none chosen here
TEXTin the first place.Whichever way it goes, the
VALUE_ROUNDTRIPcase-set from #12393 now covers both transports, so a convergence attempt has an instrument to check itself against — which it did not have before today.Dedup
mapFieldTypeToSQLlines and the oneremote-read-coercion.test.tscomment; no card, doc, changeset or ADR states the asymmetry.Filed unassigned, recording only — not claimed.