Skip to content

Commit 48cdcd2

Browse files
fix(state): review follow-ups from #142/#149 — idempotency ordering, head verification, replay across migrations (#171)
Codex review follow-ups on the state kernel and drivers, re-verified against the Effect-native internals from #154: - Both drivers consult the idempotency key before running the reducer (#142/#149 P1): a committed key replays its stored result even when the reducer would now fail against the current head. Payload validation and canonicalization moved ahead of the key lookup; the reducer runs only on the append path. - Committed results replay across migrations (#142 P1): every journal record stores its post-commit state (event rows too, in the sqlite driver's existing nullable column), and migrations run the same chain over the stored results, so replay no longer depends on exact-revision history that migrations rebase. Legacy event rows without a stored state fall back to journal replay. - The sqlite driver verifies storage on open (#149 P1): journal continuity via expectConsistentJournal (a deleted intermediate row fails closed) and the materialized head against journal replay (a schema-valid hand-edited head fails closed); with a pending migration the head is checked against the last stored post-commit state instead. - sanitizedFileName hashes the complete definition id (sha-256) instead of hex-encoding its first six bytes (#149 P2), so ids sharing a sanitized prefix get isolated database files. - isJsonSafe rejects sparse arrays (#142 P2): holes no longer canonicalize like dense arrays under permissive schemas. The conformance suite pins the corrected semantics for both drivers: replay-before-reduce, replay across migrations, and prefix-colliding id isolation; sqlite corruption tests cover the two new fail-closed opens.
1 parent 9e98f4d commit 48cdcd2

10 files changed

Lines changed: 394 additions & 93 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@agent-bundle/runtime": patch
3+
---
4+
5+
State kernel review follow-ups from #142/#149. Both drivers now consult the
6+
idempotency key before running the reducer, so a committed key replays its
7+
stored result even when the reducer would fail against the current head; the
8+
committed result is stored per key (event journal rows now persist their
9+
post-commit state) and rides the migration chain, so replay survives schema
10+
migrations instead of failing `revision-unavailable`. The sqlite driver
11+
verifies storage on open — journal continuity (a hand-deleted intermediate
12+
row fails closed) and the materialized head against journal replay (a
13+
schema-valid but hand-edited head fails closed) — and derives database file
14+
names from a sha-256 hash of the complete definition id, so ids that share a
15+
sanitized prefix no longer collide onto one file. Sparse arrays are rejected
16+
at the JSON boundary instead of silently canonicalizing like dense ones. The
17+
shared conformance suite pins the corrected semantics for every driver.

‎packages/rsc-runtime/src/state/conformance.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,22 @@ export const stateDriverConformanceCases: readonly StateConformanceCase[] = Obje
223223
assert.equal((await store.read()).revision, 2);
224224
},
225225
},
226+
{
227+
name: 'a committed key replays without re-running the reducer',
228+
run: async (context) => {
229+
const store = await context.open(taskDefinition(context.lifetime));
230+
await addTask(store, 'a');
231+
const removed = await store.dispatch('taskRemoved', { id: 'a' }, { idempotencyKey: 'remove:a' });
232+
assert.equal(removed.revision, 2);
233+
// Task 'a' is gone, so the reducer would now throw; the key must be
234+
// consulted before the reducer runs for the retry to replay.
235+
const replayed = await store.dispatch('taskRemoved', { id: 'a' }, { idempotencyKey: 'remove:a' });
236+
assert.equal(replayed.replayed, true);
237+
assert.equal(replayed.revision, removed.revision);
238+
assert.deepEqual(replayed.state, removed.state);
239+
assert.equal((await store.read()).revision, 2);
240+
},
241+
},
226242
{
227243
name: 'reusing an idempotency key with a different payload is an idempotency-conflict',
228244
run: async (context) => {
@@ -433,6 +449,36 @@ export const stateDriverConformanceCases: readonly StateConformanceCase[] = Obje
433449
await assert.rejects(context.reopen(taskDefinition(context.lifetime)), rejectsWith('migration-missing'));
434450
},
435451
},
452+
{
453+
name: 'replaying a key committed before a migration returns its committed result',
454+
run: async (context) => {
455+
const storeV1 = await context.open(taskDefinition(context.lifetime));
456+
const original = await addTask(storeV1, 'a');
457+
await addTask(storeV1, 'b');
458+
const storeV2 = await context.reopen(taskDefinitionV2(context.lifetime));
459+
assert.equal((await storeV2.read()).revision, 3);
460+
// The migration rebases exact-revision history, but a pre-deployment
461+
// retry must still replay: the committed result rides the migration
462+
// chain instead of depending on exact-revision replay.
463+
const replayed = await storeV2.dispatch('taskAdded', { id: 'a', title: 'Task a' }, { idempotencyKey: 'add:a' });
464+
assert.equal(replayed.replayed, true);
465+
assert.equal(replayed.revision, original.revision);
466+
assert.deepEqual(replayed.state, { labels: [], tasks: [{ id: 'a', title: 'Task a' }], total: 1 });
467+
assert.equal((await storeV2.read()).revision, 3);
468+
},
469+
},
470+
{
471+
name: 'definition ids sharing a sanitized prefix stay isolated',
472+
run: async (context) => {
473+
// These two ids sanitize identically and share their leading bytes, so
474+
// storage naming must derive from the complete id, never a truncation.
475+
const first = await context.open(taskDefinition(context.lifetime, 'abcdef/a'));
476+
const second = await context.open(taskDefinition(context.lifetime, 'abcdef-a'));
477+
await addTask(first, 'a');
478+
assert.equal((await second.read()).revision, 0);
479+
assert.deepEqual((await second.read()).state, { tasks: [], total: 0 });
480+
},
481+
},
436482
{
437483
name: 'closed stores fail typed',
438484
run: async (context) => {

‎packages/rsc-runtime/src/state/contract.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,12 @@ export type AgentStateEvent<TEvents extends AgentStateEventSchemas> = {
105105
* persisted at definition version `n - 1` to version `n`; a definition of
106106
* version `v > 1` must supply every step from `2` through `v`. Steps receive
107107
* the raw persisted value and their final output must satisfy the current
108-
* schema. Migrating rebases history: exact-revision reads below the recorded
109-
* migration become `revision-unavailable`.
108+
* schema; steps must accept any valid version `n - 1` state, because
109+
* committed results stored for idempotent replay migrate through the same
110+
* chain. Migrating rebases history: exact-revision reads below the recorded
111+
* migration become `revision-unavailable`, but replaying a committed
112+
* idempotency key still returns its committed result (migrated to the
113+
* current version).
110114
*/
111115
export type AgentStateMigrations = Readonly<Record<number, (persisted: unknown) => unknown>>;
112116

‎packages/rsc-runtime/src/state/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export {
4646
changeFromJournalRecord,
4747
expectConsistentJournal,
4848
migrationIdempotencyKey,
49+
parseEventPayload,
50+
reduceStateEvent,
4951
replayJournal,
5052
resolveResetState,
5153
runStateMigrations,

‎packages/rsc-runtime/src/state/journal.ts‎

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,16 +66,16 @@ export const migrationIdempotencyKey = (toVersion: number): string =>
6666
`${AGENT_STATE_RESERVED_KEY_PREFIX}migrate:${String(toVersion)}`;
6767

6868
/**
69-
* Validates one event payload, runs the reducer, and validates its output.
70-
* Throws typed `invalid-event`, `reducer-failure`, or `invalid-state`
71-
* errors; never exposes payload or state contents in messages.
69+
* Validates one event payload against its declared schema without running
70+
* the reducer. Idempotency-key replay must be decided from the validated
71+
* payload alone: a committed key retried after the state changed replays
72+
* the committed result, so the reducer must not run first.
7273
*/
73-
export const applyStateEvent = <TState, TEvents extends AgentStateEventSchemas>(
74+
export const parseEventPayload = <TState, TEvents extends AgentStateEventSchemas>(
7475
definition: AgentStateDefinition<TState, TEvents>,
75-
state: TState,
7676
name: string,
7777
payload: unknown,
78-
): { readonly payload: unknown; readonly state: TState } => {
78+
): unknown => {
7979
const schema = definition.events[name];
8080
if (schema === undefined) {
8181
throw new AgentStateError('invalid-event', `State '${definition.id}' has no event '${name}'`);
@@ -90,9 +90,23 @@ export const applyStateEvent = <TState, TEvents extends AgentStateEventSchemas>(
9090
if (!isJsonSafe(parsed.data)) {
9191
throw new AgentStateError('invalid-event', `State '${definition.id}' event '${name}' payload must be JSON-safe`);
9292
}
93+
return deepFreezeJson(parsed.data);
94+
};
95+
96+
/**
97+
* Runs the reducer over an already-validated payload (see
98+
* {@link parseEventPayload}) and validates its output. Throws typed
99+
* `reducer-failure` or `invalid-state`; never exposes state contents.
100+
*/
101+
export const reduceStateEvent = <TState, TEvents extends AgentStateEventSchemas>(
102+
definition: AgentStateDefinition<TState, TEvents>,
103+
state: TState,
104+
name: string,
105+
payload: unknown,
106+
): TState => {
93107
let next: TState;
94108
try {
95-
next = definition.reduce(state, { name, payload: parsed.data } as AgentStateEvent<TEvents>);
109+
next = definition.reduce(state, { name, payload } as AgentStateEvent<TEvents>);
96110
} catch (error) {
97111
throw new AgentStateError(
98112
'reducer-failure',
@@ -110,7 +124,22 @@ export const applyStateEvent = <TState, TEvents extends AgentStateEventSchemas>(
110124
if (!isJsonSafe(validated.data)) {
111125
throw new AgentStateError('invalid-state', `State '${definition.id}' reducer output for event '${name}' must be JSON-safe`);
112126
}
113-
return { payload: deepFreezeJson(parsed.data), state: deepFreezeJson(validated.data) };
127+
return deepFreezeJson(validated.data);
128+
};
129+
130+
/**
131+
* Validates one event payload, runs the reducer, and validates its output.
132+
* Throws typed `invalid-event`, `reducer-failure`, or `invalid-state`
133+
* errors; never exposes payload or state contents in messages.
134+
*/
135+
export const applyStateEvent = <TState, TEvents extends AgentStateEventSchemas>(
136+
definition: AgentStateDefinition<TState, TEvents>,
137+
state: TState,
138+
name: string,
139+
payload: unknown,
140+
): { readonly payload: unknown; readonly state: TState } => {
141+
const parsed = parseEventPayload(definition, name, payload);
142+
return { payload: parsed, state: reduceStateEvent(definition, state, name, parsed) };
114143
};
115144

116145
/** Validates a reset seed (or resolves the initial state) against the schema. */

‎packages/rsc-runtime/src/state/json.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,15 @@ const isPlainObject = (value: unknown): value is Readonly<Record<string, unknown
1616
export const isJsonSafe = (value: unknown): boolean => {
1717
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true;
1818
if (typeof value === 'number') return Number.isFinite(value);
19-
if (Array.isArray(value)) return value.every(isJsonSafe);
19+
if (Array.isArray(value)) {
20+
// Index-by-index so holes fail closed: `every` skips holes, which would
21+
// let a sparse array canonicalize to the same text as a denser one and
22+
// break both round-tripping and idempotency-key comparison.
23+
for (let index = 0; index < value.length; index += 1) {
24+
if (!(index in value) || !isJsonSafe(value[index])) return false;
25+
}
26+
return true;
27+
}
2028
return isPlainObject(value) && Object.values(value).every(isJsonSafe);
2129
};
2230

‎packages/rsc-runtime/src/state/memory-driver.ts‎

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ import type {
2121
import { AgentStateError, expectIdempotencyKey } from './contract.js';
2222
import type { AgentStateJournalRecord } from './journal.js';
2323
import {
24-
applyStateEvent,
2524
canonicalCommitInput,
2625
changeFromJournalRecord,
2726
migrationIdempotencyKey,
27+
parseEventPayload,
28+
reduceStateEvent,
2829
replayJournal,
2930
resolveResetState,
3031
runStateMigrations,
@@ -70,12 +71,18 @@ const expectVolatileLifetime = (lifetime: AgentStateLifetime): MemoryLifetime =>
7071
}
7172
};
7273

74+
interface CommittedResult<TState> {
75+
readonly record: AgentStateJournalRecord;
76+
/** Post-commit state, kept per key so replay survives history rebases. */
77+
readonly state: TState;
78+
}
79+
7380
interface MemoryStoreInternals<TState, TEvents extends AgentStateEventSchemas> {
7481
closed: boolean;
7582
definition: AgentStateDefinition<TState, TEvents>;
7683
head: AgentStateSnapshot<TState>;
7784
readonly journal: AgentStateJournalRecord[];
78-
readonly keys: Map<string, AgentStateJournalRecord>;
85+
readonly keys: Map<string, CommittedResult<TState>>;
7986
}
8087

8188
interface MemoryStoreEntry<TState, TEvents extends AgentStateEventSchemas> {
@@ -133,59 +140,63 @@ const createMemoryStore = <TState, TEvents extends AgentStateEventSchemas>(
133140

134141
/**
135142
* Commits share one shape: validate inputs, then honor a committed
136-
* idempotency key (replay/conflict), then compare-and-swap, then append.
137-
* The interior is fully synchronous, so a commit is atomic per store
138-
* within this process.
143+
* idempotency key (replay/conflict), then compare-and-swap, then run the
144+
* reducer, then append. The key check precedes the reducer because a
145+
* committed key must replay its stored result even when the state that
146+
* produced it has since changed — and that stored result is kept per key,
147+
* so replay never depends on exact-revision history (which migrations
148+
* rebase). The interior is fully synchronous, so a commit is atomic per
149+
* store within this process.
139150
*/
140151
const commit = (
141-
record: Readonly<{ canonicalInput: string; key: string }> &
152+
input: Readonly<{ canonicalInput: string; key: string }> &
142153
(
143-
| { readonly kind: 'event'; readonly name: string; readonly payload: unknown; readonly state: TState }
154+
| { readonly kind: 'event'; readonly name: string; readonly payload: unknown }
144155
| { readonly kind: 'reset'; readonly state: TState }
145156
),
146157
expectedRevision: number | undefined,
147158
): AgentStateCommitResult<TState> => {
148-
const committed = internals.keys.get(record.key);
159+
const committed = internals.keys.get(input.key);
149160
if (committed !== undefined) {
150-
if (canonicalCommitInput(committed) !== record.canonicalInput) {
161+
if (canonicalCommitInput(committed.record) !== input.canonicalInput) {
151162
throw new AgentStateError(
152163
'idempotency-conflict',
153164
`State '${internals.definition.id}' idempotency key was reused with a conflicting input`,
154165
);
155166
}
156-
return Object.freeze({
157-
replayed: true,
158-
revision: committed.revision,
159-
state: replayJournal(internals.definition, internals.journal, committed.revision),
160-
});
167+
return Object.freeze({ replayed: true, revision: committed.record.revision, state: committed.state });
161168
}
162169
if (expectedRevision !== undefined && expectedRevision !== internals.head.revision) {
163170
throw new AgentStateError(
164171
'revision-conflict',
165172
`State '${internals.definition.id}' expected revision ${String(expectedRevision)} but the head is ${String(internals.head.revision)}`,
166173
);
167174
}
175+
const state =
176+
input.kind === 'event'
177+
? reduceStateEvent(internals.definition, internals.head.state, input.name, input.payload)
178+
: input.state;
168179
const journalRecord: AgentStateJournalRecord =
169-
record.kind === 'event'
180+
input.kind === 'event'
170181
? {
171182
committedAt: now().toISOString(),
172-
idempotencyKey: record.key,
183+
idempotencyKey: input.key,
173184
kind: 'event',
174-
name: record.name,
175-
payload: record.payload,
185+
name: input.name,
186+
payload: input.payload,
176187
revision: internals.head.revision + 1,
177188
}
178189
: {
179190
committedAt: now().toISOString(),
180-
idempotencyKey: record.key,
191+
idempotencyKey: input.key,
181192
kind: 'reset',
182193
revision: internals.head.revision + 1,
183-
state: record.state,
194+
state: input.state,
184195
};
185196
internals.journal.push(journalRecord);
186-
internals.keys.set(journalRecord.idempotencyKey, journalRecord);
187-
internals.head = Object.freeze({ revision: journalRecord.revision, state: record.state });
188-
return Object.freeze({ replayed: false, revision: journalRecord.revision, state: record.state });
197+
internals.keys.set(journalRecord.idempotencyKey, { record: journalRecord, state });
198+
internals.head = Object.freeze({ revision: journalRecord.revision, state });
199+
return Object.freeze({ replayed: false, revision: journalRecord.revision, state });
189200
};
190201

191202
const store: AgentStateStore<TState, TEvents> = {
@@ -227,19 +238,18 @@ const createMemoryStore = <TState, TEvents extends AgentStateEventSchemas>(
227238
expectOperable(internals.closed, internals.definition.id, options.signal);
228239
const key = expectIdempotencyKey(options.idempotencyKey);
229240
expectRevisionShape(options.expectedRevision, `State '${internals.definition.id}' expectedRevision`);
230-
const applied = applyStateEvent(internals.definition, internals.head.state, name, payload);
241+
// Payload validation only — the reducer runs inside `commit`, after
242+
// the idempotency key has been consulted.
243+
const parsed = parseEventPayload(internals.definition, name, payload);
231244
const canonicalInput = canonicalCommitInput({
232245
committedAt: '',
233246
idempotencyKey: key,
234247
kind: 'event',
235248
name,
236-
payload: applied.payload,
249+
payload: parsed,
237250
revision: 0,
238251
});
239-
return commit(
240-
{ canonicalInput, key, kind: 'event', name, payload: applied.payload, state: applied.state },
241-
options.expectedRevision,
242-
);
252+
return commit({ canonicalInput, key, kind: 'event', name, payload: parsed }, options.expectedRevision);
243253
}),
244254
);
245255
},
@@ -298,7 +308,8 @@ const migrateOpenStore = <TState, TEvents extends AgentStateEventSchemas>(
298308
definition: AgentStateDefinition<TState, TEvents>,
299309
now: () => Date,
300310
): void => {
301-
const migrated = runStateMigrations(definition, internals.definition.version, internals.head.state);
311+
const fromVersion = internals.definition.version;
312+
const migrated = runStateMigrations(definition, fromVersion, internals.head.state);
302313
const record: AgentStateJournalRecord = {
303314
committedAt: now().toISOString(),
304315
idempotencyKey: migrationIdempotencyKey(definition.version),
@@ -308,7 +319,16 @@ const migrateOpenStore = <TState, TEvents extends AgentStateEventSchemas>(
308319
toVersion: definition.version,
309320
};
310321
internals.journal.push(record);
311-
internals.keys.set(record.idempotencyKey, record);
322+
// Committed results replay across migrations: every stored result sits at
323+
// `fromVersion` (this loop maintains that inductively), so each one rides
324+
// the same migration chain as the head.
325+
for (const [key, entry] of internals.keys) {
326+
internals.keys.set(key, {
327+
record: entry.record,
328+
state: runStateMigrations(definition, fromVersion, entry.state),
329+
});
330+
}
331+
internals.keys.set(record.idempotencyKey, { record, state: migrated });
312332
internals.head = Object.freeze({ revision: record.revision, state: migrated });
313333
internals.definition = definition;
314334
};

0 commit comments

Comments
 (0)