Skip to content

Commit 2354058

Browse files
feat(spec,driver-turso)!: retire turso config.encryptionKey, which never encrypted anything (#25153)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg6zwDuV2XvoDkqC7bXgqnpc)_ Fixes #25050 Clause-②: no (narrowing) Metadata-protocol: narrowed ## What changes **BREAKING.** Turso `config.encryptionKey` is removed (Route A, retire the key, ADR-0049 enforce-or-remove). It never encrypted anything, in any mode: - a local or embedded-replica file is read and written through better-sqlite3, which has no encryption codec. #25020 already refused the key there; - a remote database was never handed the key, because its storage encryption is configured on the server. After #25020 the key still parsed on a remote datasource, did nothing there, and was the last credential-shaped key a datasource could write in plain text. | Surface | Before | After | | --- | --- | --- | | `@objectstack/spec` `TursoConfigSchema.encryptionKey` | accepted beside a remote url; refused beside a local url (#25020) | `retiredKey()` tombstone: a `tsc` error, and a parse error at `config.encryptionKey` in every mode with any value (`''` included), carrying the prescription. It is the only issue reported on that config. `encryption_key` / `encryptionkey` get the same prescription | | `@objectstack/driver-turso` mirror `TursoConfigSchema` | same split | `z.never()` with the same prescription text, byte for byte | | `new TursoDriver()` / `createTursoDriver()` | refused local / replica / forced-remote-on-a-file only | `TursoDriverConfig.encryptionKey?: never`. A defined value is refused with `VALIDATION_ERROR` / 400 as the constructor's first check, in every mode | | `@objectstack/service-datasource` `TursoDriverConfigInput.encryptionKey` | `string` | `never`. The shared loader still forwards a declared value, so a legacy stored row reaches the constructor's refusal and does not boot in silence | | redaction (`datasource-credential-redaction.ts`) | `STILL_WRITABLE_CREDENTIAL_KEYS = { turso: ['encryptionKey'] }` | `RETIRED_CREDENTIAL_KEYS`, same content: a stored legacy value is still withheld on read and named in `redactedConfigKeys` | ### FROM → TO | You wrote | Write instead | | --- | --- | | `url: 'libsql://my-db.turso.io', encryptionKey: '…'` (also `https://`, `ws(s)://`) | delete `encryptionKey`. A remote database's storage encryption is configured on the Turso server | | `encryptionKey: ''` (or any unset value) | delete `encryptionKey` | | `url: 'file:./data/app.db', encryptionKey: '…'` (also `:memory:`, or a replica beside `syncUrl`) | delete `encryptionKey`, and put the file on an encrypted disk or volume if it must be encrypted at rest | **The one-line fix:** delete `encryptionKey` from every turso datasource. ### ADR-0087 disposition - **D2 `turso-config-encryption-key-removed`** (`retiredFromLoadPath: true`, `retiredAfter: '18.0.0'`) deletes the key when its value is unset (any non-string, or `''`) or when it sits beside a remote url (`libsql://`, `https://`, `http://`, `ws://`, `wss://`, in any case, url trimmed). It emits one notice per key. - A non-empty key beside a **local** url is left for the author. The row keeps the #25020 posture: it fails at driver construction with the prescription. - **D3 `turso-config-encryption-key-retired`**. `RETIRED_KEYS_BY_MAJOR[18]` gains `data/TursoConfig:encryptionKey`. The `STEP18_RATIONALE` fragment has order 107. - The changeset `.changeset/25050-retire-turso-encryption-key.md` carries the one marker, both declaration lines, BREAKING, FROM → TO and the one-line fix. Grades: `@objectstack/spec` minor, `@objectstack/driver-turso` minor, `@objectstack/service-datasource` minor. ## Decision: the partial conversion (four axes) The question was whether the D2 conversion deletes every stored `encryptionKey`, or only the ones it can prove were inert. - **Real business need:** an operator who wrote a key beside a local file believed the file was encrypted. A conversion that silently deletes that key lets the datasource boot in plaintext with no signal, which is the defect #25020 made loud. Beside a remote url, or when the value is unset, nothing was ever believed and nothing changes. - **Long-term soundness:** one rule ("a key the driver never read, with nothing believed behind it") is mechanical and lossless. The local case needs a human decision (an encrypted volume, or a remote database), so it stays a refusal that names the fix. - **Preventing AI metadata errors:** the tombstone, the guidance spellings, and the constructor refusal all carry the same prescription. An agent that writes the key in any spelling or mode is told to delete it. There is no accepted-but-inert state left to learn from. - **Startup no-sprawl:** one conversion, one D3 entry, one retired-key entry, one renamed internal constant. There are no new options or modes, and the loader keeps its single forwarding reader. ## Measured assumptions (one disproved) - **Disproved: "an existing path still redacts a stored `encryptionKey` once it is tombstoned."** It does not. The schema-derived credential list excludes `retiredKey()` tombstones by their `[REMOVED] ` prefix (#15680), and the derived turso list (`authToken`) is non-empty, so the canonical fallback never applies. - Without a fix, a legacy row's value would have been served on read. - Fixed with the retired-key reader `RETIRED_CREDENTIAL_KEYS` (the renamed `STILL_WRITABLE_CREDENTIAL_KEYS`). - Proven by `datasource-credential-redaction.test.ts` ("a RETIRED credential tombstone … is excluded from the derivation, yet still redacted") and by the service-datasource admin-door test on a local-url legacy row. - **Held: zero in-repo producers.** No example, template, scaffold, skill, host default or env var authors the key. A tree-scoped absence pin (`turso-encryption-key-retirement.test.ts`, radius `packages examples skills content scripts`) now holds it, with every exclusion named. - **Re-homed fixtures:** 15 stored-metadata redaction suites used `encryptionKey` as their sentinel credential. 14 now use turso `authToken`, which is refused inline and redacted. The runtime reader-contexts pin seeds through the production writer, which refuses both primary slots, so it now uses mongodb `options.proxyPassword`, which is writable and redacted. The metadata-protocol legacy-row redaction test keeps `encryptionKey` on purpose, as a real retired-key row. ## Reverse verification (once, no permanent test) Run with `scripts/ablation-replace.mjs` on the committed head. Each mutation landed (anchor 1 → 0, blob moved) and was restored (blob equals HEAD, `git diff HEAD` empty). - **Constructor refusal removed** (`refuseIgnoredSyncKey(ENCRYPTION_KEY_RETIRED_REFUSAL)`): driver-turso `turso-driver-encryption-key-retired.test.ts` and `turso-config-constructor-parity.test.ts` went red, 36 failed across both files. - **Retired-key reader emptied** (`turso: []`): spec `datasource-credential-redaction.test.ts` and `metadata-type-redaction.test.ts` went red, 7 failed. ## Verification - `@objectstack/spec`: build 0, `pnpm test` (after fixing the ledger total, which the first run caught) 0, typecheck 0. The repo-project absence pin passes, 10 tests. - `@objectstack/driver-turso`: build 0, `pnpm test` 0 (after measuring the `SYNC_KEY_REFUSALS` floor at 20), typecheck 0. - `@objectstack/service-datasource`: build 0, `pnpm test` 0, typecheck 0. - `@objectstack/cli`: typecheck 0. The three touched test files pass. One `storage-driver.test.ts` case timed out at 5 s under a shared-box batch, then passed in isolation (225 passed). - Touched suites in runtime, objectql, mcp, metadata-protocol, plugin-audit and service-automation: all pass. - dispatch-gates `--commands` on this diff (head `a15132cc4`): all 127 derived commands run, exit codes recorded before any pipe. `--ran` reports 127 run, 0 NOT-MEASURED, 0 UNRUN. - 5 of the 127 first refused on environment prerequisites, not findings: a shallow clone (`check-engine-split-ratio` exit 2, `check-plugin-teardown-shape --self-test` exit 3) and dist output that was not built (`check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n-coverage`, each exit 3). - Each passed (exit 0) after `git fetch --shallow-since` and a full `pnpm build`. - Dogfood `pnpm --filter @objectstack/dogfood test` on `a15132cc4`, lock-wrapped: lock VERDICT command-exit 0. Test Files 287 passed, 3 skipped (290). Tests 2395 passed, 18 skipped (2413). The contract-review-tier review belongs to the seat. This PR stays a draft. --- _Generated by [Claude Code](https://claude.ai/code/session_01B2EeooonSXrANpkpgUSfFZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0cf309f commit 2354058

55 files changed

Lines changed: 1385 additions & 997 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/driver-turso': minor
4+
'@objectstack/service-datasource': minor
5+
---
6+
7+
feat(spec,driver-turso)!: turso `config.encryptionKey` is removed — it never encrypted anything, in any mode, and is now refused everywhere it is written instead of accepted on a remote datasource
8+
9+
Clause-②: no (narrowing)
10+
11+
Metadata-protocol: narrowed
12+
13+
<!-- adr-0087: registered turso-config-encryption-key-removed, turso-config-encryption-key-retired -->
14+
15+
`encryptionKey` was declared as an AES-256 key for the database file. Nothing ever applied it. A local or embedded-replica file is read and written through better-sqlite3, which has no encryption codec, so #25020 already refused the key there. A remote database was never handed the key either: its storage encryption is configured on the server. That left a key that parsed on a remote datasource and did nothing. It was also the one credential-shaped key a datasource could still write in plain text (`STILL_WRITABLE_CREDENTIAL_KEYS`). Under ADR-0049 (enforce-or-remove) it is removed.
16+
17+
**BREAKING** accept-set narrowing on a published schema, a published constructor and an exported type. It ships as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`).
18+
19+
- **`@objectstack/spec`**: `TursoConfigSchema.encryptionKey` is a `retiredKey()` tombstone. Authoring it is a `tsc` error and a parse error at `config.encryptionKey`, in every mode and with any value, the empty string included. The error carries the removal prescription. It is the only issue reported on that config, whatever else is wrong. The misspellings `encryption_key` and `encryptionkey` get the same prescription. The #25020 local and replica refusal arms are gone, since the tombstone subsumes them.
20+
- **`@objectstack/driver-turso`**: the published mirror `TursoConfigSchema` refuses the key the same way, as `z.never()`. `TursoDriverConfig.encryptionKey` is typed `never`. `new TursoDriver()` and `createTursoDriver()` refuse a defined `encryptionKey` with `VALIDATION_ERROR` / 400 in every mode, before anything else is checked, in the schema's words.
21+
- **`@objectstack/service-datasource`**: the exported `TursoDriverConfigInput.encryptionKey` is typed `never`. The shared loader still forwards a declared value, so a legacy stored row reaches the constructor's refusal instead of being dropped in silence.
22+
23+
### Migration: FROM → TO
24+
25+
| You wrote | Write instead |
26+
| --- | --- |
27+
| `url: 'libsql://my-db.turso.io', encryptionKey: '…'` (also `https://`, `ws(s)://`) | delete `encryptionKey`. Storage encryption for a remote database is configured on the Turso server |
28+
| `encryptionKey: ''` (or any unset value) | delete `encryptionKey` |
29+
| `url: 'file:./data/app.db', encryptionKey: '…'` (also `:memory:`, or a replica beside `syncUrl`) | delete `encryptionKey` (already refused since #25020). Put the file on an encrypted disk or volume if it must be encrypted at rest |
30+
31+
The fix in one line: delete `encryptionKey` from every turso datasource.
32+
33+
### The ADR-0087 disposition
34+
35+
- **D2 `turso-config-encryption-key-removed`** deletes the key from a turso datasource's `config` when its value is unset (any non-string, or `''`) or when it sits beside a remote url (`libsql://`, `https://`, `http://`, `ws://`, `wss://`, in any case). It emits one notice per key. Nothing is lost: the driver never read the value.
36+
- A non-empty key beside a local url is **not** deleted. Removing it would let a datasource whose operator believed the file was encrypted boot in silence. It keeps the #25020 posture instead: the row fails at driver construction with the prescription, and the author deletes the key by hand.
37+
- The conversion is retired from the load path. A live author is refused at parse and taught. Stored rows (the datasource loaders), built artifacts inside their declared-floor window and `os migrate meta --from 17` replay it.
38+
- `RETIRED_KEYS_BY_MAJOR[18]` gains `data/TursoConfig:encryptionKey`. The D3 entry is `turso-config-encryption-key-retired`.
39+
40+
### Stored rows stay redacted
41+
42+
A tombstone is excluded from the schema-derived credential list, by its `[REMOVED]` prefix. Measured: after the retirement, no existing path would have withheld a stored `encryptionKey` on read. The derived turso list (`authToken`) is non-empty, so the canonical fallback never applies. `STILL_WRITABLE_CREDENTIAL_KEYS` is therefore renamed `RETIRED_CREDENTIAL_KEYS` and keeps `turso: ['encryptionKey']`. A legacy row's value is still withheld and still named in `redactedConfigKeys`, on the admin door and the metadata door alike.
43+
44+
### Blast radius
45+
46+
Measured on this tree: no example, template, CLI scaffold, published skill, host default or environment variable authors the key. A tree-scoped absence pin now holds that. Whether any out-of-repo deployment declares it is not measured, and is not claimed to be zero.

‎content/docs/data-modeling/drivers.mdx‎

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -195,15 +195,14 @@ ObjectStack — is what makes them secrets:
195195
| `mongodb` | `options.key` | TLS private key material (PEM) |
196196
| `mongodb` | `options.passphrase` | TLS key passphrase |
197197
| `mongodb` | `options.authMechanismProperties.AWS_SESSION_TOKEN` | AWS STS session token (`MONGODB-AWS` auth) |
198-
| `turso` \| `libsql` | `encryptionKey` | A key the driver never applies (see below) |
199198

200199
Unlike `password` / `authToken` (typed `never` in their schemas — the parse
201-
refuses them outright), these six keys are **writable**: the parse accepts
200+
refuses them outright), these five keys are **writable**: the parse accepts
202201
them, and they are stored **at rest in `sys_metadata` as plain text**, right
203202
alongside the rest of the datasource row. The protection that exists today is
204203
on the READ side only — every one of them is stripped before a datasource
205204
record is ever served back over the admin API or shown in the Setup UI
206-
(`PASSTHROUGH_SECRET_PATHS` / `STILL_WRITABLE_CREDENTIAL_KEYS` in
205+
(`PASSTHROUGH_SECRET_PATHS` in
207206
`datasource-credential-redaction.ts`, shipped in
208207
[#9040](https://github.com/objectstack-ai/objectstack/issues/9040)). That stops the value
209208
round-tripping through a read; it is not encryption at rest, and an operator
@@ -231,19 +230,17 @@ that needs an authenticated proxy or a passphrase-protected key converts
231230
that group into named binder-slot support — one mechanism covering those
232231
four keys — rather than the current per-key accept-and-document posture.
233232

234-
Turso's `encryptionKey` is no longer part of that trade-off either. It was
235-
declared as an AES-256 key for the local database file, but the driver reads
236-
and writes that file through better-sqlite3, which has no encryption codec, so
237-
the file was written in plaintext whatever the key said. A local or
238-
embedded-replica turso datasource that declares it is now refused at write
239-
time and when the driver is built: drop the key, and put the file on an
240-
encrypted disk or volume if it must be encrypted at rest. A remote turso
241-
datasource (a `libsql://`, `https://` or `ws(s)://` url) still parses with the
242-
key, which the driver does not forward (a remote database's storage encryption
243-
is the server's), while a `file:` url or `:memory:` under a forced
244-
`mode: 'remote'` is refused like a local one, because the libSQL client the
245-
driver builds for it is handed no key; a stored value is redacted on read like
246-
the rest of this table.
233+
Turso's `encryptionKey` is not part of that trade-off: it was **removed** in
234+
`@objectstack/spec` 18 ([#25050](https://github.com/objectstack-ai/objectstack/issues/25050), ADR-0049).
235+
It was declared as an AES-256 key for the local database file, but the driver
236+
reads and writes that file through better-sqlite3, which has no encryption
237+
codec, and it never handed the key to a remote database, whose storage
238+
encryption the server configures. So it encrypted nothing, in any mode. The
239+
parse and the driver constructor now refuse it in every mode: delete the key,
240+
and put a local file on an encrypted disk or volume if it must be encrypted at
241+
rest. `os migrate meta --from 17` deletes it for you where it was unset or
242+
beside a remote url; a stored legacy row that still carries it is redacted on
243+
read like the keys above.
247244
</Callout>
248245

249246
## Startup: a driver that cannot connect aborts the boot

‎content/docs/references/data/driver-turso.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ Turso / libSQL Connection Configuration
6868
| :--- | :--- | :--- | :--- |
6969
| **url** | `string` | ✅ | libSQL endpoint or local file: a remote libsql/https Turso URL, a local file written as a file: URL (never a bare path), or :memory: |
7070
| **authToken** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` |
71-
| **encryptionKey** | `string` | optional | Not honoured in any mode: refused for a local or replica datasource, whose SQLite engine cannot encrypt the file, 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, not set by this key |
71+
| **encryptionKey** | `never` | optional | [REMOVED] `turso config.encryptionKey` was removed in @objectstack/spec 18 and @objectstack/driver-turso 18 (ADR-0049) — it never encrypted anything, in any mode: a local or embedded-replica file is read and written through a SQLite engine that has no encryption codec, and the driver never handed the key to a remote database, whose storage encryption the server configures. Delete the key. A local file that must be encrypted at rest belongs on an encrypted disk or volume. Run `os migrate meta --from 17` to list the mechanical edits for the unset and remote-url cases; `--write` applies the ones it can prove, and you delete the key from a local or replica datasource by hand. |
7272
| **concurrency** | `integer` | optional | Maximum concurrent requests to the remote database |
7373
| **syncUrl** | `string` | optional | Remote sync URL for embedded-replica mode: a libsql or https Turso endpoint |
7474
| **sync** | `{ intervalSeconds?: integer; onConnect?: boolean }` | optional | Embedded-replica sync configuration (requires `syncUrl`) |

‎docs/design/driver-turso.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -516,14 +516,13 @@ package-published Spec / Studio mirror in `packages/drivers/driver-turso/src/spe
516516
Neither declares a key the driver does not read — the direction ADR-0049 (enforce-or-remove)
517517
governs. The converse does not hold, and the mirror is the shorter list: it does not declare
518518
`mode`, which the driver does read (`TursoDriverConfig.mode`). The mirror keeps its retired keys
519-
(`timeout` → `timeoutMs`; `localPath` and `wasm`, removed) as `z.never()` tombstones whose refusal
519+
(`timeout` → `timeoutMs`; `localPath`, `wasm` and — since #25050, because it encrypted nothing in any mode — `encryptionKey`, removed) as `z.never()` tombstones whose refusal
520520
carries the prescription. The live keys:
521521

522522
| Property | Type | Default | Description |
523523
|:---|:---|:---:|:---|
524524
| `url` | `string` | (required) | Database URL (`libsql://`, `https://`, `file:`, `:memory:`) — in replica mode, also the local file |
525525
| `authToken` | `string?` | — | JWT auth token for remote databases |
526-
| `encryptionKey` | `string?` | — | Encrypts nothing, in any mode: refused for a local or replica datasource (`VALIDATION_ERROR` / 400 at construction, a `custom` issue at authoring), because the local engine (better-sqlite3) has no encryption codec, and for a `file:` url or `:memory:` under a forced `mode: 'remote'`, whose libSQL client is handed no key; a remote database's storage encryption is the server's, not set by this key |
527526
| `concurrency` | `number` | `20` | Maximum concurrent requests |
528527
| `syncUrl` | `string?` | — | Remote sync URL for embedded replica mode |
529528
| `sync.intervalSeconds` | `number` | `60` | Periodic sync interval (0 = manual only) |

0 commit comments

Comments
 (0)