Skip to content

Commit 4bfe1a5

Browse files
os-zhuangclaude
andauthored
feat(spec): refuse ${…} placeholder syntax in memory persistence.path / persistence.key at publish (#8495) (#8666)
* feat(spec): refuse ${…} placeholders in memory persistence.path/persistence.key (#8495) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5tUwGM3LQoqErTfkvRW7W * feat(spec): register the #8495 refusal as an ADR-0087 semantic entry under protocol 18, with changeset Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5tUwGM3LQoqErTfkvRW7W --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 247c55a commit 4bfe1a5

5 files changed

Lines changed: 273 additions & 8 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): refuse `${…}` placeholder syntax in memory `persistence.path` / `persistence.key` at publish (#8495)
6+
7+
**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
8+
launch-window convention ships it as `minor`; the migration prescription is
9+
registered under protocol major 18, where `os migrate meta` users will look).
10+
11+
The #8336 defect one surface over: a `${…}` placeholder written in the memory
12+
driver's persistence config (e.g. `persistence: { type: 'file', path:
13+
'${DATA_DIR}/mem.json' }`) is resolved by **nothing** — the driver would create
14+
and write a literal `./${DATA_DIR}/…` path, or write under the literal
15+
placeholder-bearing localStorage key, with no error naming the unresolved
16+
placeholder. #8336's ruling (refuse loudly at authoring time — the value was
17+
authored under a false belief) applies to these two keys with its reason
18+
intact: they are config-material like the connection keys, not record data.
19+
20+
**What is refused:** a complete `${…}` span in memory `persistence.path` (file
21+
persistence and the `auto` override) or `persistence.key` (localStorage and the
22+
`auto` override) — the same shared judgment (`placeholderFree`) the
23+
connection-material keys use, so the policy cannot drift per key.
24+
25+
**What stays accepted:** every literal path/key byte-identically, including
26+
placeholder-looking near-misses (`$VAR`, `{name}`, an unclosed `${`) — and the
27+
memory driver's `initialData` stays deliberately **unjudged**: it carries
28+
arbitrary record values, where a literal `${…}` may be legitimate data (the
29+
mother ruling's deliberate memory-driver exclusion, which reached exactly as
30+
far as its reason did).
31+
32+
## FROM → TO
33+
34+
```ts
35+
// before — parsed green; the driver created a literal `./${DATA_DIR}/…` path
36+
defineDatasource({
37+
name: 'scratch', driver: 'memory',
38+
config: { persistence: { type: 'file', path: '${DATA_DIR}/scratch.json' } },
39+
})
40+
41+
// after — write the literal path (or leave it unset: the shared datasource
42+
// factory scopes the default destination per datasource)
43+
defineDatasource({
44+
name: 'scratch', driver: 'memory',
45+
config: { persistence: { type: 'file', path: './data/scratch.json' } },
46+
})
47+
```
48+
49+
There is deliberately **no automatic rewrite**: the placeholder names a value
50+
that exists only in the author's intended deployment environment, which a
51+
source-file transform cannot know. `os migrate meta` surfaces the change as a
52+
structured TODO (semantic entry `memory-persistence-placeholder-refused`,
53+
protocol major 18 — this refusal is not part of the v17.0.0 cut).
54+
55+
<!-- adr-0087: registered memory-persistence-placeholder-refused -->

‎packages/spec/src/data/driver/driver-placeholder-refusal.test.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { describe, expect, it } from 'vitest';
3434

3535
import { DatasourceSchema } from '../datasource.zod';
3636
import { containsUnresolvedPlaceholder } from './common.zod';
37+
import { MemoryConfigSchema } from './memory.zod';
3738
import { MongoConfigSchema } from './mongo.zod';
3839
import { MysqlConfigSchema } from './mysql.zod';
3940
import { PostgresConfigSchema } from './postgres.zod';
@@ -170,6 +171,101 @@ describe('mongo `options` passthrough — the deep judgement (#8336)', () => {
170171
});
171172
});
172173

174+
/**
175+
* #8495 — the #8336 shape one surface over: memory `persistence.path` (file
176+
* persistence and the `auto` override) and `persistence.key` (localStorage)
177+
* are config-material, not record data. A `${DATA_DIR}` written there is
178+
* resolved by nothing — the driver would create and write a literal
179+
* `./${DATA_DIR}/…` path (or a literal localStorage key), the same
180+
* authored-under-a-false-belief defect. Inherited parent adjudication from
181+
* #8336; `initialData` stays deliberately UNJUDGED (record values, where a
182+
* literal `${…}` may be legitimate data) and is pinned so below.
183+
*/
184+
const MEMORY_PERSISTENCE_FAMILY = [
185+
{ name: 'memory file persistence.path', key: 'persistence.path',
186+
valid: (v: string) => ({ persistence: { type: 'file', path: v } }),
187+
literal: './data/memory-driver.json', placeholder: '${DATA_DIR}/memory-driver.json' },
188+
{ name: 'memory localStorage persistence.key', key: 'persistence.key',
189+
valid: (v: string) => ({ persistence: { type: 'local', key: v } }),
190+
literal: 'myapp:db', placeholder: 'myapp:${TENANT}:db' },
191+
{ name: 'memory auto persistence.path override', key: 'persistence.path',
192+
valid: (v: string) => ({ persistence: { type: 'auto', path: v } }),
193+
literal: '/var/data/memory.json', placeholder: '${DATA_DIR}/memory.json' },
194+
{ name: 'memory auto persistence.key override', key: 'persistence.key',
195+
valid: (v: string) => ({ persistence: { type: 'auto', key: v } }),
196+
literal: 'objectstack:memory-db', placeholder: '${STORAGE_KEY}' },
197+
] as const;
198+
199+
describe.each(MEMORY_PERSISTENCE_FAMILY)('$name — unresolved placeholder refusal (#8495)', (f) => {
200+
it('refuses a `${…}` placeholder, pathed under `persistence`, naming the key and the defect', () => {
201+
const result = MemoryConfigSchema.safeParse(f.valid(f.placeholder));
202+
expect(result.success).toBe(false);
203+
const issue = result.error!.issues.find((i) => i.path.join('.') === f.key);
204+
expect(issue, `refusal must be pathed at \`${f.key}\``).toBeDefined();
205+
expect(issue!.code).toBe('custom');
206+
expect(issue!.message).toContain(`\`${f.key}\``);
207+
// The ruling's guidance, verbatim: the non-capability is explicit.
208+
expect(issue!.message).toContain('placeholders are not resolved here');
209+
// The measured defect, in the family's shared phrasing (#8078).
210+
expect(issue!.message).toContain('resolved by nothing');
211+
// The prescription: the literal value.
212+
expect(issue!.message).toContain('Write the literal value instead');
213+
});
214+
215+
it('accepts the literal spelling byte-identically (pin)', () => {
216+
const config = f.valid(f.literal);
217+
const before = MemoryConfigSchema.safeParse(config);
218+
expect(before.success, JSON.stringify(before.error?.issues)).toBe(true);
219+
expect(MemoryConfigSchema.parse(config)).toEqual(before.data);
220+
});
221+
222+
it('placeholder-LOOKING literals stay accepted: `$VAR`, `{name}`, unclosed `${` are not the measured convention', () => {
223+
for (const nearMiss of [
224+
f.literal + '$SUFFIX',
225+
f.literal + '{curly}',
226+
f.literal + '-${unclosed',
227+
]) {
228+
const result = MemoryConfigSchema.safeParse(f.valid(nearMiss));
229+
expect(result.success, `\`${nearMiss}\` must stay accepted: ${JSON.stringify(result.error?.issues)}`).toBe(true);
230+
}
231+
});
232+
});
233+
234+
describe('memory `initialData` stays UNJUDGED — the deliberate #8336 exclusion holds (#8495)', () => {
235+
it('a literal `${…}` in a record value is legitimate DATA and keeps parsing', () => {
236+
// The mother ruling's memory-driver exclusion was argued from exactly this:
237+
// `initialData` carries arbitrary record values, where `${…}` may be the
238+
// real payload (a template string a downstream renderer consumes). The
239+
// #8495 refusal covers `persistence.path`/`persistence.key` ONLY.
240+
const config = {
241+
initialData: {
242+
templates: [{ id: '1', body: 'Hello ${name}, your order ${order_id} shipped.' }],
243+
users: [{ id: '${weird-but-legal}', name: 'Alice' }],
244+
},
245+
persistence: { type: 'file', path: './data/memory.json' },
246+
};
247+
const result = MemoryConfigSchema.safeParse(config);
248+
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
249+
// Byte-identical: the values are data, and data is never rewritten.
250+
expect(result.data!.initialData).toEqual(config.initialData);
251+
});
252+
});
253+
254+
describe('DatasourceSchema — the memory refusal reaches the authored artefact (#8495)', () => {
255+
it('re-paths the refusal under `config.persistence.path` for the author', () => {
256+
const result = DatasourceSchema.safeParse({
257+
name: 'scratch',
258+
driver: 'memory',
259+
config: { persistence: { type: 'file', path: '${DATA_DIR}/scratch.json' } },
260+
});
261+
expect(result.success).toBe(false);
262+
const issue = result.error!.issues.find((i) => i.path.join('.') === 'config.persistence.path');
263+
expect(issue, 'issue must be re-pathed under config.persistence.path').toBeDefined();
264+
expect(issue!.code).toBe('custom');
265+
expect(issue!.message).toContain('placeholders are not resolved here');
266+
});
267+
});
268+
173269
describe('DatasourceSchema — the refusal reaches the authored artefact (#8336)', () => {
174270
it('re-paths the refusal under `config.<key>` for the author', () => {
175271
const result = DatasourceSchema.safeParse({

‎packages/spec/src/data/driver/memory.zod.ts‎

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { strictObject } from '../../shared/strict-object';
66
import type { DriverDefinition } from '../datasource.zod';
77
import {
88
driverConfigJsonSchema,
9+
placeholderFree,
910
READ_ONLY_BELONGS_ON_DATASOURCE,
1011
SCHEMA_MODE_BELONGS_ON_DATASOURCE,
1112
} from './common.zod';
@@ -96,8 +97,17 @@ export const FilePersistenceConfigSchema = lazySchema(() => strictObject(
9697
},
9798
{
9899
type: z.literal('file'),
99-
/** File path to persist data (JSON format). Defaults to `.objectstack/data/memory-driver.json`. */
100-
path: z.string().optional().describe('File path to persist data'),
100+
/**
101+
* File path to persist data (JSON format). Defaults to `.objectstack/data/memory-driver.json`.
102+
*
103+
* `${…}` placeholder syntax is refused (#8495, the #8336 shape one surface
104+
* over): nothing resolves it, so the driver would create and write a
105+
* literal `./${DATA_DIR}/…` path — authored under a false belief. The
106+
* memory driver's `initialData` stays deliberately unjudged (record
107+
* values, where a literal `${…}` may be legitimate data); this key is
108+
* config-material, not data.
109+
*/
110+
path: placeholderFree(z.string(), 'persistence.path').optional().describe('File path to persist data'),
101111
/** Auto-save interval in milliseconds. Default: 2000ms. */
102112
autoSaveInterval: z.number().min(100).default(2000).describe('Auto-save interval in ms'),
103113
},
@@ -118,8 +128,13 @@ export const LocalStoragePersistenceConfigSchema = lazySchema(() => strictObject
118128
},
119129
{
120130
type: z.literal('local'),
121-
/** localStorage key. Defaults to `objectstack:memory-db`. */
122-
key: z.string().optional().describe('localStorage key for persisted data'),
131+
/**
132+
* localStorage key. Defaults to `objectstack:memory-db`.
133+
*
134+
* `${…}` placeholder syntax is refused (#8495): nothing resolves it, so
135+
* the driver would write under the literal placeholder-bearing key.
136+
*/
137+
key: placeholderFree(z.string(), 'persistence.key').optional().describe('localStorage key for persisted data'),
123138
},
124139
).describe('localStorage persistence configuration'));
125140

@@ -164,12 +179,20 @@ export const AutoPersistenceConfigSchema = lazySchema(() => strictObject(
164179
},
165180
{
166181
type: z.literal('auto'),
167-
/** File path override when running in Node.js. */
168-
path: z.string().optional().describe('File path override for Node.js environments'),
182+
/**
183+
* File path override when running in Node.js.
184+
* `${…}` placeholder syntax is refused (#8495) — same judgment as the
185+
* `file` branch's `path`; the auto-detected file adapter resolves nothing.
186+
*/
187+
path: placeholderFree(z.string(), 'persistence.path').optional().describe('File path override for Node.js environments'),
169188
/** Auto-save interval override when running in Node.js. */
170189
autoSaveInterval: z.number().min(100).optional().describe('Auto-save interval override for Node.js environments'),
171-
/** localStorage key override when running in a browser. */
172-
key: z.string().optional().describe('localStorage key override for browser environments'),
190+
/**
191+
* localStorage key override when running in a browser.
192+
* `${…}` placeholder syntax is refused (#8495) — same judgment as the
193+
* `local` branch's `key`.
194+
*/
195+
key: placeholderFree(z.string(), 'persistence.key').optional().describe('localStorage key override for browser environments'),
173196
},
174197
).describe('Auto-detect persistence configuration'));
175198

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'memory-persistence-placeholder-refused',
7+
surface: 'memory driver config `persistence.path` (file persistence and the `auto` ' +
8+
'override) and `persistence.key` (localStorage and the `auto` override) — values ' +
9+
'containing `${…}` placeholder syntax',
10+
replacement: 'the literal path or key. For environment-specific destinations, leave the ' +
11+
'key unset and let the shared datasource factory scope the default per datasource, or ' +
12+
'compute the config value in code before it enters `defineStack`',
13+
reason:
14+
'The #8336 defect one surface over: a `${…}` placeholder in memory persistence config ' +
15+
'is resolved by NOTHING — the driver would create and write a literal `./${DATA_DIR}/…` ' +
16+
'path, or write under the literal placeholder-bearing localStorage key, so the dump ' +
17+
'lands in a wrongly-named location with no error naming the unresolved placeholder ' +
18+
'(#8495; authored under the same false belief the #8336 ruling closes). These two keys ' +
19+
'are config-material like the connection keys, so the parent adjudication applies with ' +
20+
'its reason intact; the memory driver\'s `initialData` stays deliberately UNJUDGED — it ' +
21+
'carries arbitrary record values, where a literal `${…}` may be legitimate data. There ' +
22+
'is no mechanical rewrite: the placeholder names a value that exists only in the ' +
23+
'author\'s intended deployment environment, which a source-file transform cannot know.',
24+
acceptanceCriteria:
25+
'Every memory datasource parses with no `${…}` span in `persistence.path` or ' +
26+
'`persistence.key`; `initialData` record values containing literal `${…}` keep parsing ' +
27+
'byte-identically.',
28+
};

‎packages/spec/src/migrations/registry.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4723,6 +4723,68 @@ const step17: MigrationStep = {
47234723
],
47244724
};
47254725

4726+
/**
4727+
* Protocol 18 step — accumulating, uncut.
4728+
*
4729+
* v17.0.0 was cut before these narrowings landed, so their migration
4730+
* prescriptions belong to the NEXT major: `composeMigrationChain` filters
4731+
* `m <= toMajor` (default `PROTOCOL_MAJOR`), so this step is inert for every
4732+
* default caller until the protocol major reaches 18. The enforcement itself
4733+
* ships earlier on the 17.x line (launch-window convention: accept-set
4734+
* narrowings ride minor releases); this step is where `migrate meta` users
4735+
* are told, at the major boundary where they look.
4736+
*
4737+
* Mechanical: none yet. Semantic: the memory-driver persistence placeholder
4738+
* refusal (#8495) — the #8336 parent adjudication applied to the two
4739+
* config-material memory keys its deliberate `initialData` exclusion never
4740+
* covered.
4741+
*/
4742+
const step18: MigrationStep = {
4743+
toMajor: 18,
4744+
rationale:
4745+
'Protocol 18 extends the #8336 unresolved-placeholder refusal to the memory ' +
4746+
'driver\'s config-material persistence keys: `persistence.path` (file persistence ' +
4747+
'and the `auto` override) and `persistence.key` (localStorage and the `auto` ' +
4748+
'override) refuse `${…}` placeholder syntax at publish (#8495). Nothing resolves a ' +
4749+
'placeholder there — the driver would create a literal `./${DATA_DIR}/…` path or ' +
4750+
'write under the literal localStorage key — the same authored-under-a-false-belief ' +
4751+
'shape, one surface over. The memory driver\'s `initialData` stays deliberately ' +
4752+
'unjudged: it carries arbitrary record values, where a literal `${…}` may be ' +
4753+
'legitimate data.',
4754+
conversionIds: [],
4755+
semantic: [
4756+
// One file per entry under `entries/semantic/`, concatenated here sorted by
4757+
// entry id by `gen:migration-registry` (#7297). Add an entry by adding a
4758+
// FILE — never by editing between the markers, which is generated.
4759+
// <os-generated semantic:18>
4760+
{
4761+
id: 'memory-persistence-placeholder-refused',
4762+
surface: 'memory driver config `persistence.path` (file persistence and the `auto` ' +
4763+
'override) and `persistence.key` (localStorage and the `auto` override) — values ' +
4764+
'containing `${…}` placeholder syntax',
4765+
replacement: 'the literal path or key. For environment-specific destinations, leave the ' +
4766+
'key unset and let the shared datasource factory scope the default per datasource, or ' +
4767+
'compute the config value in code before it enters `defineStack`',
4768+
reason:
4769+
'The #8336 defect one surface over: a `${…}` placeholder in memory persistence config ' +
4770+
'is resolved by NOTHING — the driver would create and write a literal `./${DATA_DIR}/…` ' +
4771+
'path, or write under the literal placeholder-bearing localStorage key, so the dump ' +
4772+
'lands in a wrongly-named location with no error naming the unresolved placeholder ' +
4773+
'(#8495; authored under the same false belief the #8336 ruling closes). These two keys ' +
4774+
'are config-material like the connection keys, so the parent adjudication applies with ' +
4775+
'its reason intact; the memory driver\'s `initialData` stays deliberately UNJUDGED — it ' +
4776+
'carries arbitrary record values, where a literal `${…}` may be legitimate data. There ' +
4777+
'is no mechanical rewrite: the placeholder names a value that exists only in the ' +
4778+
'author\'s intended deployment environment, which a source-file transform cannot know.',
4779+
acceptanceCriteria:
4780+
'Every memory datasource parses with no `${…}` span in `persistence.path` or ' +
4781+
'`persistence.key`; `initialData` record values containing literal `${…}` keep parsing ' +
4782+
'byte-identically.',
4783+
},
4784+
// </os-generated semantic:18>
4785+
],
4786+
};
4787+
47264788
/** All migration steps, keyed by the major they migrate into. */
47274789
export const MIGRATIONS_BY_MAJOR: Readonly<Record<number, MigrationStep>> = {
47284790
11: step11,
@@ -4732,6 +4794,7 @@ export const MIGRATIONS_BY_MAJOR: Readonly<Record<number, MigrationStep>> = {
47324794
15: step15,
47334795
16: step16,
47344796
17: step17,
4797+
18: step18,
47354798
};
47364799

47374800
/** The majors that have a step, ascending. */

0 commit comments

Comments
 (0)