Skip to content

Commit a15132c

Browse files
committed
wip(25050): changeset, ledger total, parity floor, forwarded-key exception, runtime pin re-homed
Claude-Session: https://claude.ai/code/session_01B2EeooonSXrANpkpgUSfFZ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 01ff31c commit a15132c

5 files changed

Lines changed: 75 additions & 5 deletions

File tree

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.

‎packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,8 @@ describe('turso config: the constructor, the spec contract and this mirror agree
296296
// [#20586] The forced-local half of the `mode` rows, floored on its own so
297297
// it cannot shrink behind the forced-replica half.
298298
expect(ROWS.filter((r) => r.refusedOn === 'mode' && r.config.mode === 'local').length).toBeGreaterThanOrEqual(8);
299-
expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(25);
299+
// 20 measured at #25050, after the `encryptionKey` rows moved to RETIRED_KEY_ROWS.
300+
expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(20);
300301
// [#25050] The removed key, across every mode and beside other defects.
301302
expect(RETIRED_KEY_ROWS.length).toBeGreaterThanOrEqual(15);
302303
expect(RETIRED_KEY_ROWS.filter((r) => /^(libsql|https|wss):/i.test(r.config.url)).length).toBeGreaterThanOrEqual(5);

‎packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,13 @@ const API = '/api/v1';
8585
const ADMIN = { email: 'admin@objectos.ai', password: 'admin123' };
8686
const MEMBER = { email: 'pin-member@example.invalid', password: 'Member-Pass-123' };
8787

88-
/** The synthetic credential, stored in the datasource body's primary credential slot (turso's `authToken`). */
88+
/**
89+
* The synthetic credential, stored in a credential slot the production writer
90+
* ACCEPTS and the datasource redactor withholds on read: mongodb's
91+
* `options.proxyPassword`. It was turso's `encryptionKey` until #25050 retired
92+
* that key, and the primary slots (`password`, `authToken`) are refused inline
93+
* at this writer, so they cannot seed the row.
94+
*/
8995
const SENTINEL = 'reader-seam-sentinel-6c1e9b';
9096
const DS_NAME = 'pin_ds';
9197
/** Non-credential configuration of the same body: present in a projection of THIS row. */
@@ -433,8 +439,8 @@ beforeAll(async () => {
433439
const save = await as(adminToken, 'PUT', `/meta/datasource/${DS_NAME}`, {
434440
name: DS_NAME,
435441
label: 'Pin datasource',
436-
driver: 'turso',
437-
config: { url: `libsql://${DS_HOST}`, authToken: SENTINEL },
442+
driver: 'mongodb',
443+
config: { url: `mongodb://${DS_HOST}:27017/pin`, options: { proxyPassword: SENTINEL } },
438444
});
439445
if (save.status >= 300) throw new Error(`pin seed refused: ${save.status} ${JSON.stringify(await readJson(save))}`);
440446

‎packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,19 +325,36 @@ describe('#16023 — every reader consults the spelling the authoring contract a
325325
// arrives (`loadDatasourceRows` / `loadDatasourceRow`), so a `??` fallback
326326
// here would be a consumer-side dialect for a spelling both doors closed —
327327
// the sqlite `filename` and mongo `url` arms say exactly this.
328+
//
329+
// ONE named exception, and it is a refusal, not a tolerance: `encryptionKey`
330+
// (#25050) is a removal with no successor whose conversion deliberately
331+
// leaves a non-empty value beside a LOCAL url for its author. Its reader
332+
// forwards that value verbatim, under its own name, so the driver
333+
// constructor refuses it by name instead of the row booting in silence. It
334+
// is held to exactly that below: forwarded unchanged, never read into any
335+
// other key.
336+
const FORWARDED_TO_CONSTRUCTOR_REFUSAL = new Set(['encryptionKey']);
328337
it('reads NO retired spelling — a config authored the old way yields only `url`', () => {
329338
for (const { retired } of retirements()) {
330339
// Probed at every candidate type, not at the replacement's: a tolerance
331340
// arm could have been written with any type-test, and the tombstone
332341
// itself accepts nothing to sample from.
333342
for (const value of CANDIDATES) {
334343
const built = build({ url: 'libsql://x', [retired]: value });
344+
if (FORWARDED_TO_CONSTRUCTOR_REFUSAL.has(retired)) {
345+
expect(built, `\`${retired}\` is forwarded unchanged (probed with ${typeof value})`)
346+
.toEqual({ url: 'libsql://x', [retired]: value });
347+
continue;
348+
}
335349
expect(
336350
Object.keys(built),
337351
`a reader still consults the retired \`${retired}\` (probed with ${typeof value})`,
338352
).toEqual(['url']);
339353
}
340354
}
355+
// The exception list names only keys the contract really retired.
356+
const retiredNames = retirements().map((r) => r.retired);
357+
for (const key of FORWARDED_TO_CONSTRUCTOR_REFUSAL) expect(retiredNames).toContain(key);
341358
});
342359

343360
// The named, readable instance of the property above — the case #16023

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"measured": {
44
"zod": "4.4.3",
55
"publishedSchemasWithDroppedRefinements": 240,
6-
"droppedRefinementSites": 812,
6+
"droppedRefinementSites": 811,
77
"refinementSitesThatDidProject": 369,
88
"refinementSitesWithNoJsonFormToCompare": 0
99
},

0 commit comments

Comments
 (0)