Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/driver-memory-persistence-strict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
---
'@objectstack/spec': minor
---

Close the memory driver's `persistence` sub-shapes against unknown keys (#4001 batch B)

zod's default is `.strip`: a key a schema does not declare is silently
discarded and the parse still succeeds. `datasource.config` for a `memory`
driver has been parsed since #4410, but `.strict()` does not recurse — the
top-level `MemoryConfigSchema` was closed then, while the five variant shapes
nested under its `persistence` union stayed open, so a typo written *inside*
`persistence` (e.g. `{ type: 'file', filepath: '/data.json' }`) parsed clean
and the driver came up on its persistence defaults with no signal at all.

`PersistenceAdapterSchema`, `FilePersistenceConfigSchema`,
`LocalStoragePersistenceConfigSchema`, `CustomPersistenceConfigSchema` and
`AutoPersistenceConfigSchema` now raise a named, fixable error — the surface,
the offending key, and (where the schema declares one) an edit-distance "did
you mean" suggestion — instead of dropping the key.

No field was added or removed; every existing valid payload still parses the
same. Only a config that was already writing an unrecognised key under
`persistence` sees a new, loud rejection in place of the old silent no-op.
17 changes: 8 additions & 9 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,14 +22,14 @@ regenerate.
|---|---|
| Triaged directories | 5 |
| Object sites in them | 438 |
| Still-open (strip) sites | 180 |
| Files carrying at least one | 27 |
| Still-open (strip) sites | 175 |
| Files carrying at least one | 26 |

Remaining strip sites by class:

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 40 |
| authorable — the ruling's forced scope | 35 |
| unresolved — needs a per-schema verdict | 34 |
| wire / open — out of forced scope | 104 |
| no door — no carrier, ADR-0049 territory | 1 |
Expand All@@ -45,11 +45,11 @@ The `strict` column is the one the campaign schedules against; it counts both th
| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 161 | 119 | 5 | 0 | 37 |
| `data/` | 165 | 57 | 1 | 0 | 107 |
| `data/` | 165 | 62 | 1 | 0 | 102 |
| `automation/` | 65 | 42 | 0 | 0 | 23 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **438** | **252** | **6** | **0** | **180** |
| **total** | **438** | **257** | **6** | **0** | **175** |

## File-level triage — site counts

Expand DownExpand Up@@ -179,7 +179,7 @@ over it is here.

### `data/` — open

**107 strip of 165**, in 16 file(s).
**102 strip of 165**, in 15 file(s).

| File | Strip | Sites |
|---|---|---|
Expand All@@ -189,7 +189,6 @@ over it is here.
| `driver-nosql.zod.ts` | 10 | 10 |
| `driver-sql.zod.ts` | 2 | 2 |
| `driver.zod.ts` | 9 | 9 |
| `driver/memory.zod.ts` | 5 | 6 |
| `external-catalog.zod.ts` | 4 | 4 |
| `external-lookup.zod.ts` | 12 | 12 |
| `field-value.zod.ts` | 2 | 3 |
Expand All@@ -199,11 +198,11 @@ over it is here.
| `object.zod.ts` | 1 | 20 |
| `query.zod.ts` | 4 | 5 |
| `seed-loader.zod.ts` | 12 | 12 |
| **total** | **107** | **165** |
| **total** | **102** | **165** |

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 8 |
| authorable — the ruling's forced scope | 3 |
| unresolved — needs a per-schema verdict | 34 |
| wire / open — out of forced scope | 65 |
| no door — no carrier, ADR-0049 territory | 0 |
Expand Down
20 changes: 12 additions & 8 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -1182,7 +1182,6 @@ triage row record which one was taken.
| `driver.zod.ts` | wire | **out of scope** — driver capability contract |
| `analytics.zod.ts` | mixed (p) | `Metric` / `Dimension` / `Cube` / `AnalyticsQuery` — cube definitions are authored; needs a per-schema read |
| `document.zod.ts` | wire (p) | `DocumentTemplate` / `ESignatureConfig` read authorable on their face — the `(p)` is unresolved, verify before scheduling either way |
| `driver/memory.zod.ts` | authorable | The persistence-adapter union under `datasource.config`; `datasource.config` HAS been parsed against these since #4410, so strictness here now binds |
| `query.zod.ts` | open | ~~⚠️ classification conflict — see #4721~~ **RESOLVED (11:41Z ruling, closed by #4721).** The conflict was real and the answer was that per-FILE classification was the imprecise instrument: `SortNodeSchema` was carved out as `authorable` and closed (`strictObject` + `aliases: { direction: 'order' }`), the other 4 sites keep `open`. Those 4 are the dialect proper — `BaseQuerySchema`, `AggregationNodeSchema`, `FullTextSearchSchema`, `GroupByNodeSchema`'s object arm — and `BaseQuerySchema`'s own top-level strictness is #4001's to schedule, deliberately **not** taken by #4721 |
| `external-catalog.zod.ts` | wire (p) | **out of scope** |
| `hook.zod.ts` | wire | **out of scope** — `HookContextSchema` + `.session`/`.provenance`/`.user` are the runtime shape handed to a handler; verified in the data step |
Expand All@@ -1192,13 +1191,18 @@ triage row record which one was taken.

**Authorable strip in `data/`:**
[the counts file](./2026-07-unknown-key-strictness-ledger.counts.md#data--open) splits this
directory three ways, and the middle bucket is the one to read: `object`, `driver/memory`
and `field` are **firm** authorable; `external-lookup`, `seed-loader`, `analytics` and
`field-value` are **unresolved** — they still carry `mixed (p)`, so the ledger is saying
"nobody has done the per-schema read" rather than "these are ready". The rest is wire/open
and out of the ruling's forced scope; that count fell by one when #4721 closed
`query.zod.ts`'s `SortNodeSchema`, the one row in this directory where the per-schema read
moved a site OUT of `open` rather than confirming it.
directory three ways, and the middle bucket is the one to read: `object` and `field` are
**firm** authorable; `external-lookup`, `seed-loader`, `analytics` and `field-value` are
**unresolved** — they still carry `mixed (p)`, so the ledger is saying "nobody has done the
per-schema read" rather than "these are ready". The rest is wire/open and out of the
ruling's forced scope; that count fell by one when #4721 closed `query.zod.ts`'s
`SortNodeSchema`, the one row in this directory where the per-schema read moved a site OUT
of `open` rather than confirming it, and by one more when **#4001 batch B** closed
`driver/memory.zod.ts`'s remaining 5 sites (the persistence-adapter union under
`datasource.config` — `PersistenceAdapterSchema`, `FilePersistenceConfigSchema`,
`LocalStoragePersistenceConfigSchema`, `CustomPersistenceConfigSchema`,
`AutoPersistenceConfigSchema`), dropping its row from the remaining-strip map entirely: the
file's 6th site, `MemoryConfigSchema`, was already `strictObject` since #4410.

**批 20 closed 13 of `object.zod.ts`'s 14 and parked the row at 1**, which makes it
the fourth row in this ledger to shrink without disappearing — after `flow` (批 11),
Expand Down
97 changes: 97 additions & 0 deletions packages/spec/src/data/driver/memory.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -295,6 +295,23 @@ describe('FilePersistenceConfigSchema', () => {
expect(config.type).toBe('file');
expect(config.path).toBeUndefined();
});

// #4001 batch B: this shape was a bare `z.object` — an unrecognised key was
// silently stripped and the file adapter came up on its defaults with no
// signal at all. `.strict()` makes that loud.
it('rejects an unrecognised key instead of silently stripping it', () => {
const result = FilePersistenceConfigSchema.safeParse({
type: 'file',
path: '/data/store.json',
filepath: '/data/other.json', // typo'd key, not a real field
});

expect(result.success).toBe(false);
expect(result.error!.issues[0]!.message).toContain(
"this memory datasource's file persistence config",
);
expect(result.error!.issues[0]!.message).toContain('filepath');
});
});

describe('LocalStoragePersistenceConfigSchema', () => {
Expand All@@ -316,6 +333,20 @@ describe('LocalStoragePersistenceConfigSchema', () => {
expect(config.type).toBe('local');
expect(config.key).toBeUndefined();
});

// #4001 batch B — see the FilePersistenceConfigSchema case above.
it('rejects an unrecognised key instead of silently stripping it', () => {
const result = LocalStoragePersistenceConfigSchema.safeParse({
type: 'local',
storageKey: 'myapp:db', // typo'd key, not a real field
});

expect(result.success).toBe(false);
expect(result.error!.issues[0]!.message).toContain(
"this memory datasource's localStorage persistence config",
);
expect(result.error!.issues[0]!.message).toContain('storageKey');
});
});

describe('CustomPersistenceConfigSchema', () => {
Expand All@@ -332,6 +363,58 @@ describe('CustomPersistenceConfigSchema', () => {
expect(typeof config.adapter.save).toBe('function');
expect(typeof config.adapter.flush).toBe('function');
});

// #4001 batch B — see the FilePersistenceConfigSchema case above.
it('rejects an unrecognised key instead of silently stripping it', () => {
const result = CustomPersistenceConfigSchema.safeParse({
adapter: {
load: async () => null,
save: async () => {},
flush: async () => {},
},
options: { retries: 3 }, // not a real field on this shape
});

expect(result.success).toBe(false);
expect(result.error!.issues[0]!.message).toContain(
"this memory datasource's custom-adapter persistence config",
);
expect(result.error!.issues[0]!.message).toContain('options');
});
});

describe('PersistenceAdapterSchema', () => {
it('should accept a valid adapter (load/save/flush)', () => {
const config = PersistenceAdapterSchema.parse({
load: async () => null,
save: async () => {},
flush: async () => {},
});

expect(typeof config.load).toBe('function');
expect(typeof config.save).toBe('function');
expect(typeof config.flush).toBe('function');
});

// #4001 batch B: this shape was a bare `z.object` — an unrecognised key
// (e.g. a typo'd lifecycle method) was silently stripped instead of being
// reported, so a custom adapter missing `flush` because the author wrote
// `close` instead got a clean parse and a driver that never persisted on
// shutdown.
it('rejects an unrecognised key instead of silently stripping it', () => {
const result = PersistenceAdapterSchema.safeParse({
load: async () => null,
save: async () => {},
flush: async () => {},
close: async () => {}, // not a real field on this shape
});

expect(result.success).toBe(false);
expect(result.error!.issues[0]!.message).toContain(
"this memory datasource's custom persistence adapter",
);
expect(result.error!.issues[0]!.message).toContain('close');
});
});

describe('AutoPersistenceConfigSchema', () => {
Expand DownExpand Up@@ -366,6 +449,20 @@ describe('AutoPersistenceConfigSchema', () => {
autoSaveInterval: 50, // Below minimum of 100
})).toThrow();
});

// #4001 batch B — see the FilePersistenceConfigSchema case above.
it('rejects an unrecognised key instead of silently stripping it', () => {
const result = AutoPersistenceConfigSchema.safeParse({
type: 'auto',
interval: 5000, // meant `autoSaveInterval`, not a real field
});

expect(result.success).toBe(false);
expect(result.error!.issues[0]!.message).toContain(
"this memory datasource's auto-detect persistence config",
);
expect(result.error!.issues[0]!.message).toContain('interval');
});
});

describe('MemoryPersistenceConfigSchema', () => {
Expand Down
Loading
Loading