Repository navigation
Commit 28c9a2c
fix(spec,driver-turso)!: refuse encryptionKey on a local or replica turso datasource instead of writing the file in plaintext (#25045)
Fixes #25020
Clause-②: no (narrowing)
The `Clause-②` line above is the claim's (comment 6306742426: "If the
measured route is the refusal, it is `no (narrowing)`"), and the
measured route is the refusal. The changeset carries the same value.
Under the claim, this PR owes a `CONTRACT_REVIEW_TIER` review before the
queue.
Dev run of the `domain:engine` seat 2 PM dispatch (session
`session_01QK6pdbxKZiHdFApL8RYVh9`, `mode:subagent`), branch
`claude/issue-25020-turso-encryption-key-local`, BASE `4d991588f`, with
one true merge of `origin/main` at `d15981a07` (it touched no file of
this diff). **Patch round 1** (contract review 6308116467, FAIL item 1
and findings 7–9; seat verdict 6308149967) landed as `0833772a4`,
`b0236c662` and `4a595ed43`. **Patch round 2** (seat order 6310662118)
landed as `df6803d15`: one changeset sentence, `.changeset/**` only.
**Every reading below was taken at head `4a595ed43`** unless it says
otherwise (the full dogfood battery, the derived gates and the
check-runs were re-read at `df6803d15`); the H1 table and the H2 grep
were read at BASE and at `d15981a07`, 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-turso` already uses for the file's reads and writes, with
no engine swap and no new dependency. Measured:
- **The engine is better-sqlite3 13.0.3 (SQLite 3.53.4), and it has no
encryption codec.** `PRAGMA compile_options` lists no codec option.
`PRAGMA key = 'test-key-25020'`, `PRAGMA cipher`, `PRAGMA
cipher_version` and `PRAGMA rekey` all answer `[]` (an unknown pragma is
a silent no-op). A table written after `PRAGMA key` leaves a file whose
header is `SQLite format 3`, and a second, key-less read-only open reads
the row back.
- **The native libSQL binding does encrypt** (`@libsql/client` 0.18.0
over `libsql` 0.5.29, already a dependency). A local `file:` client with
the key writes a file whose header is not `SQLite format 3`;
better-sqlite3 opening it answers `SQLITE_NOTADB`; a libSQL client
reopening it without the key answers `SQLITE_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 `TursoDriver` from source at BASE, with a test key:
| Mode | What happened |
| --- | --- |
| local (`file:` url + key) | constructs, connects, `syncSchema` +
`create` + `find` round-trip; file header `SQLite format 3`; plain
better-sqlite3, read-only, no key, reads the row |
| replica, supplied client (`file:` + `syncUrl` + `client`) | the same:
the key reaches nothing |
| replica, driver-built sync client (`file:` + `syncUrl`, unreachable
local sync endpoint) | `super.connect()` writes the file in plaintext
first (`db.db`, `-shm`, `-wal`, header `SQLite format 3`); only then is
`createClient` handed the key, and its construction fails to connect
(`sync error: … tcp connect error`) |
| `@libsql/client` + the key, over that plaintext file | `SQLITE_NOTADB`
|
| this driver's replica face over a file `@libsql/client` encrypted with
the key | `DATABASE_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 grep` over `examples/**`,
`packages/create-objectstack`, `packages/cli/src` (non-test) and
`skills/**` finds **zero** `encryptionKey` authors; 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 remote `libsql://` urls
(unaffected), and two `metadata-protocol` suites carry it on a `file:`
url in STORED rows that no parse reads (run green, below).
## What changes
A non-empty `encryptionKey` on a local datasource (a `file:` url or
`:memory:`) or an embedded replica (a `file:` url beside `syncUrl`),
detected or forced with `mode`, is refused at all three places a turso
config is judged, with one message per mode:
- **Construction.** `new TursoDriver()` (and `createTursoDriver()`)
throws `VALIDATION_ERROR` / 400 before `super()`, 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 from
`localEncryptionKeyRefusal(mode)` in
`packages/drivers/driver-turso/src/turso-driver.ts`.
- **Authoring.** `tursoTransportIssues` in
`packages/spec/src/data/driver/turso.zod.ts` gains a last arm in the
local / replica branch: one `custom` issue on `encryptionKey`. It
reaches `DatasourceSchema` (as `config.encryptionKey`),
`validateDriverConfig`, `defineStack` / `os validate`, and a save or
test connection through the datasource admin service.
- **The driver mirror**
(`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 in `detectMode`, so
`{ url: 'file:…', mode: 'remote', encryptionKey }` skipped every
local-engine refusal, and `connect()` handed the local url to
`createRemoteClient()`, which builds `createClient({ url, authToken,
concurrency })` with no key. Measured on this head with the new
constructor arm ablated (leg D below): it constructed and connected in
`remote` mode, `syncSchema` + `create` + `find` round-tripped, the
file's header read `SQLite 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:
- **Construction.** One mode-independent predicate before `super()`: a
non-empty key beside a url that `namesLocalDatabase()` (a `file:` 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 a `file:` url or `:memory:`,
because `localEngineDefect` refused the rest); under a forced remote
mode it throws `remoteModeLocalEncryptionKeyRefusal(url)`. It runs after
every forced-remote refusal, so a `syncUrl` or a lone `sync` beside it
keeps its own message.
- **Authoring.** `tursoTransportIssues` gains a last arm in its remote
branch: a `file:` url or `:memory:` beside a non-empty key raises one
`custom` issue on `encryptionKey`, after the `timeoutMs` and `syncUrl`
issues.
- **The mirror** carries the arm byte for byte. It declares no `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 on `encryptionKey`, 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:
> `encryptionKey` asks for this datasource's database file to be
encrypted, but `url` is a local `file:` url, and `mode: 'remote'` runs
every read and write of that file through the libSQL client this driver
builds, which is handed no key: nothing would encrypt the file, so it
would be written in plaintext that any SQLite reader opens without the
key — the turso driver refuses this configuration when it starts.
Nothing in this driver encrypts a local file. Drop `encryptionKey`, and
encrypt the disk or volume the file lives on instead, or point `url` at
a remote Turso database, whose storage encryption is the server's to
configure, not this key's.
> `encryptionKey` asks for this datasource's database to be encrypted,
but `url` names an in-memory database, and `mode: 'remote'` runs every
read and write of it through the libSQL client this driver builds, which
is handed no key: there is no file for the key to encrypt, so it would
change nothing — the turso driver refuses this configuration when it
starts. Drop `encryptionKey`.
The key is not forwarded to `createClient` in 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"):
> `encryptionKey` asks for this datasource's database file to be
encrypted, but this datasource is a local database, which runs every
read and write through a local SQLite engine that has no encryption
codec: nothing would encrypt the file, so it would be written in
plaintext that any SQLite reader opens without the key — the turso
driver refuses this configuration when it starts. Nothing in this driver
encrypts a local or replica file. Drop `encryptionKey`, and encrypt the
disk or volume the file lives on instead, or keep the data in a remote
Turso database, whose storage encryption is the server's to configure,
not this key's.
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's `config.encryptionKey`. It echoes no url and no key.
**Order.** A url the engine cannot open, a lone `sync`, a forced replica
with no `syncUrl`, a forced local mode beside a `syncUrl`, and under a
forced remote mode a `syncUrl`, each keep their own refusal. A lone
`sync` or a forced-remote `syncUrl` stays the spec's first issue, with
the key's second. An empty `encryptionKey` is unset, as the loaders read
it (`buildTursoDriverConfig` drops it), and is still accepted. A remote
url is not judged (the claim puts the `libsql://` mode out of scope; see
Acceptance notes).
**Text made true.** The `encryptionKey` describe in both schemas, its
TSDoc, `TursoDriverConfig.encryptionKey`, the README,
`docs/design/driver-turso.md` §10, the
`content/docs/data-modeling/drivers.mdx` still-writable trade-off
(turso's key left it: it was never "a capability the client genuinely
honours"), the `STILL_WRITABLE_CREDENTIAL_KEYS` comment and (patch round
1) the module header of `datasource-credential-redaction.ts`, the TSDoc
of `@objectstack/service-datasource`'s exported
`TursoDriverConfigInput.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 a `file:` url or `:memory:` under a forced remote mode. The
replica arm of `connect()` stops forwarding `encryptionKey` to
`createClient`: a non-empty one can no longer reach it. The generated
`content/docs/references/data/driver-turso.mdx` is regenerated
(`check:generated --fix`, the one stale artifact; in patch round 1
again, then `gen:schema`, `gen:docs` and `check:docs` in that order,
exit 0 at `4a595ed43`: "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
as `registered turso-config-local-encryption-key-refused`. The changeset
`.changeset/25020-turso-encryption-key-local.md` is `minor` for
`@objectstack/spec` and `@objectstack/driver-turso`, BREAKING, with the
FROM / TO table (a forced-remote row added in patch round 1) and the
one-line fix: delete `encryptionKey` from every turso datasource whose
`url` is a `file:` url or `:memory:`. `@objectstack/service-datasource`
is graded `patch` for its TSDoc: `check-changeset-no-major.mjs --base
origin/main --event` (this body's `Clause-②` 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's `reason` and 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:`, forced `mode: 'local'`, `:memory:`,
`createTursoDriver`) and replica (driver-built client, supplied client,
forced `mode: 'replica'`) refusals as `code` + `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 on `encryptionKey`, 6 order rows, 2 empty-key
accept rows); the byte-equality set now includes `encryptionKey`; 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-issue `sync` + key case),
both authoring doors, controls, and the describe no longer promising
AES-256.
- **Patch round 1.** Refusal file: `file:`, `FILE:` and `file:` +
`timeout` under a forced remote mode (`code`, `status`, the first
sentence, no file afterwards), `:memory:` and `file::memory:` (the
in-memory sentence), `createTursoDriver` with no echo; controls: the
same shape with no key, with an empty key, `:memory:` with no key, and a
`libsql://` url with a key (auto-detected and forced remote) each
construct in remote mode. Parity table: 5 rows refused on
`encryptionKey` under a forced remote mode, 2 order rows (`syncUrl`
first, a lone `sync` first, 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.
- Fixture triage: `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 turso `encryptionKey` row 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.mjs` on the committed
head (anchor 1 to 0, blob changed, restore proven: blob equals HEAD and
`git diff HEAD` empty). All three suites resolve the mutated file from
source, so no rebuild was needed.
| Leg | Mutation | Run | Result |
| --- | --- | --- | --- |
| A | constructor: the refusal call replaced by a no-op | the new
refusal file + the parity table | **25 failed** / 249 passed / 37
skipped: all 9 refusal cases and 16 parity cases (8 key rows times
constructor verdict and byte equality) red; the 3 controls and every
order row green |
| B | spec: the `encryptionKey` arm disabled |
`spec/src/data/driver/turso.test.ts` + the placeholder family | **5
failed** / 123 passed: the 5 refusal cases red; controls, the describe
pin and the placeholder family green |
| C | mirror: the same arm disabled | the parity table + the mirror's
own test | **7 failed** / 287 passed / 37 skipped: the mirror verdict on
the 6 unforced key rows and the `sync` + key row red; forced-mode rows
skip the mirror by design |
| D (patch round 1, at `4a595ed43`) | constructor: the new predicate
narrowed back to the old guard (`mode !== 'remote' && …`) | the refusal
file + the parity table, plus the door-6 probe | predicted: the
forced-remote refusal cases red, everything else green. **16 failed** /
299 passed / 47 skipped: the 6 forced-remote refusal cases (3 file, 2
in-memory, `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 --noEmit` exit 0; `vitest run`: Test
Files 93 passed (93), Tests 2538 passed, 47 skipped.
- `@objectstack/spec`: `typecheck` exit 0 (with
`check:scripts-typecheck` and `check: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:docs` exit 0.
- `@objectstack/service-datasource` (imports the changed TSDoc file):
`tsc --noEmit` exit 0; `vitest run`: Test Files 51 passed (51), Tests
916 passed.
- At `d15981a07`: `metadata-protocol` stored-row suites (2 files, 44
passed). `test:repo` is CI's (Test Core).
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 121 families at `4a595ed43` (two
more than round 0: the `check-tenant-audit-census` pair). All 121 were
run once after a full `turbo run build`, each exit code captured to a
file before any pipe: all 121 answered 0. `--ran` reconciliation: "121
derived, 121 run, 0 NOT-MEASURED, 0 UNRUN". `check:driver-conformance`
reads "55 covered cell(s), 0 in the DEBT ledger, 0 exempt" at BASE, at
`d15981a07` and at `4a595ed43`. The local-only `check-dev-prereqs` scan:
exit 0. The PR's check-runs on `4a595ed43`, read once: 36 success, 6
skipped.
**Full dogfood battery** (the title carries `!` and the line carries
`(narrowing)`), once, on the final head `df6803d15`, closure built per ①
and run under the lock as `pnpm --filter @objectstack/dogfood test` with
no shard and no path: `VERDICT command-exit 0`, Test Files 284 passed, 3
skipped (287), Tests 2361 passed, 18 skipped. No failure. (At
`4a595ed43` the same battery also read `VERDICT command-exit 0`, 284
passed / 3 skipped, 2361 passed / 18 skipped.) At `df6803d15` the 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
- **A remote url still accepts the key and drops it (not this card's;
the claim puts the `libsql://` mode out of scope; filed as #25050).**
Measured: `new TursoDriver({ url: 'libsql://db.example.invalid',
encryptionKey: 'test-key-25020' })` constructs and connects in `remote`
mode; `createRemoteClient` never 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).
- The `encryptionKey` key stays declared (and redacted on read) because
removing it is a key retirement this refusal does not make.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 4feccc2 commit 28c9a2c
17 files changed
Lines changed: 996 additions & 51 deletions
File tree
- .changeset
- content/docs
- data-modeling
- references/data
- docs
- design
- qa/platform-checklist/areas
- packages
- drivers/driver-turso
- src
- spec
- services/service-datasource/src
- spec/src
- data
- driver
- migrations/entries/semantic
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
195 | 195 | | |
196 | 196 | | |
197 | 197 | | |
198 | | - | |
| 198 | + | |
199 | 199 | | |
200 | 200 | | |
201 | 201 | | |
| |||
222 | 222 | | |
223 | 223 | | |
224 | 224 | | |
225 | | - | |
226 | | - | |
227 | | - | |
228 | | - | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
229 | 228 | | |
230 | 229 | | |
231 | 230 | | |
232 | 231 | | |
233 | | - | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
234 | 247 | | |
235 | 248 | | |
236 | 249 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
68 | 68 | | |
69 | 69 | | |
70 | 70 | | |
71 | | - | |
| 71 | + | |
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
523 | 523 | | |
524 | 524 | | |
525 | 525 | | |
526 | | - | |
| 526 | + | |
527 | 527 | | |
528 | 528 | | |
529 | 529 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1716 | 1716 | | |
1717 | 1717 | | |
1718 | 1718 | | |
1719 | | - | |
| 1719 | + | |
1720 | 1720 | | |
1721 | 1721 | | |
1722 | 1722 | | |
| |||
1730 | 1730 | | |
1731 | 1731 | | |
1732 | 1732 | | |
1733 | | - | |
| 1733 | + | |
1734 | 1734 | | |
1735 | 1735 | | |
1736 | 1736 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
250 | 250 | | |
251 | 251 | | |
252 | 252 | | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
253 | 264 | | |
254 | 265 | | |
255 | 266 | | |
| |||
327 | 338 | | |
328 | 339 | | |
329 | 340 | | |
330 | | - | |
331 | | - | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
332 | 344 | | |
333 | 345 | | |
334 | 346 | | |
| |||
0 commit comments