Repository navigation
fix(spec,driver-turso)!: refuse encryptionKey on a local or replica turso datasource instead of writing the file in plaintext - #25045
Conversation
…urso datasource (wip) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
…sal (wip) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
…encryptionKey refusal (wip) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
…ribe Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
…rso-encryption-key-local
📓 Docs Drift CheckThis PR changes 3 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 8 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 147 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 9768300ae6e33c58cbeda2f8373964c66ff726d9 && git checkout 9768300ae6e33c58cbeda2f8373964c66ff726d9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2b1382a5b3887e4abd194411ef77a7cd368c95c2 df6803d15923a60a4dc32700d36b563f1b30a4e9 && git checkout -B drift-repro 2b1382a5b3887e4abd194411ef77a7cd368c95c2 && git merge --no-ff df6803d15923a60a4dc32700d36b563f1b30a4e9
node scripts/docs-audit/affected-docs.mjs --json 2b1382a5b3887e4abd194411ef77a7cd368c95c2
|
Contract reviewServed-tier: Inputs: card #25020 (body; comments 6306043862 triage, 6306742426 claim, 6307914195 dev report; no ruling comment exists), PR #25045 (body, 16-file list, net diff against the merge-base Check-runs on ① Derived judgments1. 2. 3. The mirror's byte-equality — RIGHT. Measured on the two head files: the 4. 5. The ADR-0087 semantic entry — RIGHT. 6. The redaction comment — RIGHT, and incomplete. 7. The five security answers.
② Semver levelRIGHT. The changeset grades ③ Boundary flagsFAIL item 1 (door 6, ① 7(a)). A The open question — remote mode after this PR. (i) Leaving the Dev deviations, each answered. 1. File surface widened to the producer ( Out-of-scope finding (remote mode accepts and drops the key; Further findings, non-blocking, fix in this PR with item 1. 7. Implemented-by: VERDICT: FAIL Generated by Claude Code |
席位核对Seat review ·
Generated by Claude Code |
…emory: under a forced remote mode too (wip) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
…y refusal (wip) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
…yptionKey describe Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
Contract reviewServed-tier: Inputs: card #25020 (body; comments 6306043862 triage, 6306742426 claim, 6307914195 round-0 report, 6310372472 patch-round report), PR #25045 (its round-1 body, the 17-file list, the net diff against the merge-base Check-runs on ① Derived judgments1. FAIL item 1 (door 6) is closed at every door — RIGHT.
2. The new texts — RIGHT. 3. The order, and nothing widened — RIGHT. Constructor: 4. The controls — RIGHT. 5. The mirror — RIGHT, and its remote arm is unreachable as claimed. The mirror's 6. Findings 7–9 — fixed. 7: 7. The prose the round changed — true against the code. The describe in both schemas and the regenerated reference row: "refused for a local or replica datasource … and for a file: url or :memory: under a forced remote mode, whose libSQL client is handed no key; a remote database's storage encryption is the server's" — true (judgments 1, 4). 8. What carries over from 6308116467 unchanged. ② Semver levelRIGHT. ③ Boundary flagsFAIL items: none. Door 6 of record 6308116467 is closed by the route the seat set (6308149967): the key is judged by where the database lives, at the spec, the mirror and the constructor, and no Dev deviations of the patch-round report 6310372472, each answered. 1. No PR body PATCH by the dev, the replacement left for the seat: the PR body read for this record is the round-1 body (it names patch round 1 and head The out-of-scope finding (a remote url accepts and drops the key) is #25050's, as the seat filed it; the accept controls pinned here for Further findings, non-blocking. 11. The remote-url lists in the prose (judgment 7) omit Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对Seat review ·
Generated by Claude Code |
…d-remote local-url shape Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
|
Regen-provenance: 6310634449 ·
Generated by Claude Code |
席位核对Seat review ·
Generated by Claude Code |
Fixes #25020
Clause-②: no (narrowing)
The
Clause-②line above is the claim's (comment 6306742426: "If the measured route is the refusal, it isno (narrowing)"), and the measured route is the refusal. The changeset carries the same value. Under the claim, this PR owes aCONTRACT_REVIEW_TIERreview before the queue.Dev run of the
domain:engineseat 2 PM dispatch (sessionsession_01QK6pdbxKZiHdFApL8RYVh9,mode:subagent), branchclaude/issue-25020-turso-encryption-key-local, BASE4d991588f, with one true merge oforigin/mainatd15981a07(it touched no file of this diff). Patch round 1 (contract review 6308116467, FAIL item 1 and findings 7–9; seat verdict 6308149967) landed as0833772a4,b0236c662and4a595ed43. Patch round 2 (seat order 6310662118) landed asdf6803d15: one changeset sentence,.changeset/**only. Every reading below was taken at head4a595ed43unless it says otherwise (the full dogfood battery, the derived gates and the check-runs were re-read atdf6803d15); the H1 table and the H2 grep were read at BASE and atd15981a07, and nothing since touched what they read. Only test values appear here (test-key-25020); no real key and no production path.The premise, measured first: it does not hold
The seat's premise P was: the key can be honoured by handing it to the engine
driver-tursoalready uses for the file's reads and writes, with no engine swap and no new dependency. Measured:PRAGMA compile_optionslists no codec option.PRAGMA key = 'test-key-25020',PRAGMA cipher,PRAGMA cipher_versionandPRAGMA rekeyall answer[](an unknown pragma is a silent no-op). A table written afterPRAGMA keyleaves a file whose header isSQLite format 3, and a second, key-less read-only open reads the row back.@libsql/client0.18.0 overlibsql0.5.29, already a dependency). A localfile:client with the key writes a file whose header is notSQLite format 3; better-sqlite3 opening it answersSQLITE_NOTADB; a libSQL client reopening it without the key answersSQLITE_NOTADB; with the key it reads the row. But that binding is not the engine this driver reads and writes through, so honouring the key means replacing the Knex / better-sqlite3 path with another engine. That is the ruling's "P does not hold" branch.So the route is the refusal. No engine swap, no partial swap, no warn-and-ignore.
What the driver did with the key, per mode (H1), before this change
Measured on
TursoDriverfrom source at BASE, with a test key:file:url + key)syncSchema+create+findround-trip; file headerSQLite format 3; plain better-sqlite3, read-only, no key, reads the rowfile:+syncUrl+client)file:+syncUrl, unreachable local sync endpoint)super.connect()writes the file in plaintext first (db.db,-shm,-wal, headerSQLite format 3); only then iscreateClienthanded the key, and its construction fails to connect (sync error: … tcp connect error)@libsql/client+ the key, over that plaintext fileSQLITE_NOTADB@libsql/clientencrypted with the keyDATABASE_ERROR(SQLITE_NOTADB)So both halves of H1 hold, depending on which side opens the file with the key: the engine that serves every read and write never encrypts it, and a file the sync client encrypted is one that engine cannot open. NOT MEASURED: a replica with the key against a reachable sync server. No libSQL sync server runs in this container, and the native binding's default (non-offline) sync path speaks the server's replication protocol, which nothing here can stand in for. The refusal does not depend on it: it fires at construction, before any client is built.
Who declares the key today (H2)
Measured on this tree at
d15981a07:git grepoverexamples/**,packages/create-objectstack,packages/cli/src(non-test) andskills/**finds zeroencryptionKeyauthors; no environment variable (TURSO_ENCRYPTION_KEY,OS_DATABASE_ENCRYPTION*) or host default sets it. The hits are the declarations themselves, docs describing them (content/docs/data-modeling/drivers.mdx,docs/design/driver-turso.md, the driver README), and test fixtures. Of the fixtures, the redaction suites author it on remotelibsql://urls (unaffected), and twometadata-protocolsuites carry it on afile:url in STORED rows that no parse reads (run green, below).What changes
A non-empty
encryptionKeyon a local datasource (afile:url or:memory:) or an embedded replica (afile:url besidesyncUrl), detected or forced withmode, is refused at all three places a turso config is judged, with one message per mode:new TursoDriver()(andcreateTursoDriver()) throwsVALIDATION_ERROR/ 400 beforesuper(), so no file is opened. It is the last of the local-engine refusals in the constructor, thrown through the existing envelope helper. The message comes fromlocalEncryptionKeyRefusal(mode)inpackages/drivers/driver-turso/src/turso-driver.ts.tursoTransportIssuesinpackages/spec/src/data/driver/turso.zod.tsgains a last arm in the local / replica branch: onecustomissue onencryptionKey. It reachesDatasourceSchema(asconfig.encryptionKey),validateDriverConfig,defineStack/os validate, and a save or test connection through the datasource admin service.packages/drivers/driver-turso/src/spec/turso.zod.ts) carries the same arm byte for byte.Patch round 1: the key is judged by where the database lives, not by the mode. A forced
mode: 'remote'answers first indetectMode, so{ url: 'file:…', mode: 'remote', encryptionKey }skipped every local-engine refusal, andconnect()handed the local url tocreateRemoteClient(), which buildscreateClient({ url, authToken, concurrency })with no key. Measured on this head with the new constructor arm ablated (leg D below): it constructed and connected inremotemode,syncSchema+create+findround-tripped, the file's header readSQLite format 3, and plain better-sqlite3, read-only, with no key, read the row. With the arm:VALIDATION_ERROR/ 400 at construction, and the file does not exist afterwards. Now refused at all three places:super(): a non-empty key beside a url thatnamesLocalDatabase()(afile:url in any letter case, or:memory:, the same scheme fold the driver uses everywhere). In a local or replica mode it throws the existing text (every url reaching that point is already afile:url or:memory:, becauselocalEngineDefectrefused the rest); under a forced remote mode it throwsremoteModeLocalEncryptionKeyRefusal(url). It runs after every forced-remote refusal, so asyncUrlor a lonesyncbeside it keeps its own message.tursoTransportIssuesgains a last arm in its remote branch: afile:url or:memory:beside a non-empty key raises onecustomissue onencryptionKey, after thetimeoutMsandsyncUrlissues.mode, so zod strips a forced one and the mirror judges{ url: 'file:…', mode: 'remote', encryptionKey }as the local database its url selects: it refuses it onencryptionKey, in the local arm's text. Its copy of the remote arm is unreachable through it and is kept for copy parity, like its other forced-mode branches. The parity table therefore judges the forced-remote rows by the constructor and the spec contract (verdict, key, and byte equality of the constructor's message with the spec's), and skips the mirror's verdict for them, as it does for every forced-mode row.Two texts, one per variant; both name the mode, echo no url and no key, and keep the envelope:
The key is not forwarded to
createClientin this shape: honouring it there would make a file's encryption depend on a transport override, a capability nothing pulls, and #25050 proposes retiring the key. A remote url (libsql://,https://,ws(s)://) with a key is still accepted; that mode is #25050's.The message (local; the replica variant says "an embedded replica" and adds the parenthesis "the sync client shares that one file, and a file it encrypted could not be opened by that engine"):
It names the mode and what does encrypt. It names the datasource the way its siblings do ("this datasource"): the driver is never handed the datasource's name, and the boot path prefixes every build failure with
datasource 'NAME': connect failed —(datasource-connection-service.ts), while the authoring issue sits at that datasource'sconfig.encryptionKey. It echoes no url and no key.Order. A url the engine cannot open, a lone
sync, a forced replica with nosyncUrl, a forced local mode beside asyncUrl, and under a forced remote mode asyncUrl, each keep their own refusal. A lonesyncor a forced-remotesyncUrlstays the spec's first issue, with the key's second. An emptyencryptionKeyis unset, as the loaders read it (buildTursoDriverConfigdrops it), and is still accepted. A remote url is not judged (the claim puts thelibsql://mode out of scope; see Acceptance notes).Text made true. The
encryptionKeydescribe in both schemas, its TSDoc,TursoDriverConfig.encryptionKey, the README,docs/design/driver-turso.md§10, thecontent/docs/data-modeling/drivers.mdxstill-writable trade-off (turso's key left it: it was never "a capability the client genuinely honours"), theSTILL_WRITABLE_CREDENTIAL_KEYScomment and (patch round 1) the module header ofdatasource-credential-redaction.ts, the TSDoc of@objectstack/service-datasource's exportedTursoDriverConfigInput.encryptionKey, and the QA checklist's turso expectation (write-accepted only on a remote datasource now). Patch round 1 also makes the describe in both schemas, the regenerated reference,drivers.mdx's remote sentence, the README and the design doc true for afile:url or:memory:under a forced remote mode. The replica arm ofconnect()stops forwardingencryptionKeytocreateClient: a non-empty one can no longer reach it. The generatedcontent/docs/references/data/driver-turso.mdxis regenerated (check:generated --fix, the one stale artifact; in patch round 1 again, thengen:schema,gen:docsandcheck:docsin that order, exit 0 at4a595ed43: "227 generated files in sync with packages/spec").Migration.
packages/spec/src/migrations/entries/semantic/18.turso-config-local-encryption-key-refused.ts(ADR-0087 semantic entry), registered in the changeset's ADR-0087 marker asregistered turso-config-local-encryption-key-refused. The changeset.changeset/25020-turso-encryption-key-local.mdisminorfor@objectstack/specand@objectstack/driver-turso, BREAKING, with the FROM / TO table (a forced-remote row added in patch round 1) and the one-line fix: deleteencryptionKeyfrom every turso datasource whoseurlis afile:url or:memory:.@objectstack/service-datasourceis gradedpatchfor its TSDoc:check-changeset-no-major.mjs --base origin/main --event(this body'sClause-②line) answered exit 0, "@objectstack/service-datasource: patch … NOT refused". A stored row in that shape now fails when its driver is built (failed-degraded, ADR-0062 D5). Patch round 1 dropped the connect-failure escape hatch from the entry'sreasonand from the changeset's ADR-0062 D5 sentence.Pins
packages/drivers/driver-turso/src/turso-driver-local-encryption-key-refusal.test.ts(new): local (file:,FILE:, forcedmode: 'local',:memory:,createTursoDriver) and replica (driver-built client, supplied client, forcedmode: 'replica') refusals ascode+status+ the mode's first sentence, each asserting no database file exists afterwards; the message names no url and no key. Controls: a local datasource with no key, one with an empty key, and a replica with no key each construct, connect, write and read back through the driver, and the file is a plain SQLite database with the row.packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts: 16 new rows (8 refused onencryptionKey, 6 order rows, 2 empty-key accept rows); the byte-equality set now includesencryptionKey; floors at the measured counts (8 key rows, 3 of them replica, 31 copied-message rows).packages/spec/src/data/driver/turso.test.ts: the authoring refusal per mode, no echo, order (including the two-issuesync+ key case), both authoring doors, controls, and the describe no longer promising AES-256.file:,FILE:andfile:+timeoutunder a forced remote mode (code,status, the first sentence, no file afterwards),:memory:andfile::memory:(the in-memory sentence),createTursoDriverwith no echo; controls: the same shape with no key, with an empty key,:memory:with no key, and alibsql://url with a key (auto-detected and forced remote) each construct in remote mode. Parity table: 5 rows refused onencryptionKeyunder a forced remote mode, 2 order rows (syncUrlfirst, a lonesyncfirst, each two issues at the spec), 4 accept controls (:memory:and an empty key under a forced remote mode,libsql://with a key auto-detected and forced); floors 13 key rows, 5 of them forced-remote, 38 copied-message rows. Spec test: the file and in-memory variants, no echo, order, both doors, controls, and the describe naming the forced remote mode.driver-turso/src/spec/turso.test.ts's all-fields replica fixture drops the key (it pinned the branch this removes);driver-placeholder-refusal.test.ts's tursoencryptionKeyrow moves to a remote url, the one mode where a literal still parses, with a comment that it pins the placeholder judgement and not a remote mode honouring the key.Reverse verification (ablation), direction predicted before it ran
Each leg went through
scripts/ablation-replace.mjson the committed head (anchor 1 to 0, blob changed, restore proven: blob equals HEAD andgit diff HEADempty). All three suites resolve the mutated file from source, so no rebuild was needed.encryptionKeyarm disabledspec/src/data/driver/turso.test.ts+ the placeholder familysync+ key row red; forced-mode rows skip the mirror by design4a595ed43)mode !== 'remote' && …)createTursoDriver) and 10 parity cases (5 rows times constructor verdict and byte equality) red; every control, every local / replica case and both forced-remote order rows green. The probe under the mutation reproduced door 6 (plaintext file, read with no key)Not ablated: the parity table's spec half through
packages/spec/dist(each leg would need a spec rebuild); leg B ablates the same arm through source. The new remote arm in the spec and the mirror is not ablated in patch round 1 (the dispatch asked for one leg, over the constructor arm); the mirror's copy is unreachable through the mirror, so no run could turn it red.Tests (head
4a595ed43)@objectstack/driver-turso:tsc --noEmitexit 0;vitest run: Test Files 93 passed (93), Tests 2538 passed, 47 skipped.@objectstack/spec:typecheckexit 0 (withcheck:scripts-typecheckandcheck:test-typecheck);vitest run --project local: Test Files 703 passed (703), Tests 21383 passed, 1 todo.check:generated --fix: the one stale artifact (check:docs) regenerated;check:docsexit 0.@objectstack/service-datasource(imports the changed TSDoc file):tsc --noEmitexit 0;vitest run: Test Files 51 passed (51), Tests 916 passed.d15981a07:metadata-protocolstored-row suites (2 files, 44 passed).test:repois CI's (Test Core).Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 121 families at4a595ed43(two more than round 0: thecheck-tenant-audit-censuspair). All 121 were run once after a fullturbo run build, each exit code captured to a file before any pipe: all 121 answered 0.--ranreconciliation: "121 derived, 121 run, 0 NOT-MEASURED, 0 UNRUN".check:driver-conformancereads "55 covered cell(s), 0 in the DEBT ledger, 0 exempt" at BASE, atd15981a07and at4a595ed43. The local-onlycheck-dev-prereqsscan: exit 0. The PR's check-runs on4a595ed43, read once: 36 success, 6 skipped.Full dogfood battery (the title carries
!and the line carries(narrowing)), once, on the final headdf6803d15, closure built per ① and run under the lock aspnpm --filter @objectstack/dogfood testwith no shard and no path:VERDICT command-exit 0, Test Files 284 passed, 3 skipped (287), Tests 2361 passed, 18 skipped. No failure. (At4a595ed43the same battery also readVERDICT command-exit 0, 284 passed / 3 skipped, 2361 passed / 18 skipped.) Atdf6803d15the 121 derived gate families all exit 0 (--ran: 121 derived, 121 run, 0 NOT-MEASURED, 0 UNRUN), and the check-runs read 36 success, 6 skipped.Acceptance notes
libsql://mode out of scope; filed as turso: after #25020 a remote datasource's encryptionKey is still parsed and silently dropped, so the key is honoured in no mode; retire it (ADR-0049 enforce-or-remove) #25050). Measured:new TursoDriver({ url: 'libsql://db.example.invalid', encryptionKey: 'test-key-25020' })constructs and connects inremotemode;createRemoteClientnever forwards the key;@libsql/client's own remote client refuses that key (ENCRYPTION_KEY_NOT_SUPPORTED: Encryption key is not supported by the remote client.). The spec contract parses it on a remote url. After this PR the key is honoured in no mode, which the describes now say. A local url under a forced remote mode is not this case: it is refused (patch round 1).encryptionKeykey stays declared (and redacted on read) because removing it is a key retirement this refusal does not make.Generated by Claude Code