Skip to content

Commit b339d97

Browse files
committed
Fix ledger retry equality and checkpoint prefix validation
1 parent 55f2558 commit b339d97

3 files changed

Lines changed: 20 additions & 2 deletions

File tree

‎docs/implementation/persistent-review-store.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,7 @@ Baseline: 136 tests. Final counts and CI evidence are recorded in the PR.
2929
The store does not read Git or execute agents. The runner remains responsible for obtaining immutable typed tree entries and actual filesystem identity; keeping metadata stable; auditing paths, links, and occupancy; reconciling execution prefixes; and phase/container enforcement (#6). Checkpoint persistence is not an implemented execution state machine. This slice adds neither UI nor an HTTP endpoint. No migration against a shared environment is performed.
3030

3131
Build the review screen under #3 next. The real-issue assignment, experiment protocol, planted-change script, and paired go/no-go experiment remain prerequisites to proceeding beyond that screen.
32+
33+
## Review round 1
34+
35+
Copilot's summary identified two findings (no inline threads). Both were reproduced with failing regressions, then fixed: immutable ledger comparisons now compare the typed fields rather than JSON property order, and a checkpoint's item must be the last item in its completed prefix. No findings were declined. Final suite: 151 tests.

‎runner/store.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,9 @@ export class Store {
162162
if ((entry.origin === 'foreign' && entry.owner !== null) || (entry.origin === 'owned' && !entry.owner) || !['foreign', 'owned'].includes(entry.origin)) throw new Error('Invalid ledger ownership.');
163163
const existing = this.#get('SELECT data FROM ledger WHERE key=? AND sha=?', key, entry.sha);
164164
if (existing) {
165-
if (existing.data !== encode(entry)) throw new Error('Cannot overwrite immutable ledger ownership.');
165+
const prior = decode<LedgerEntry>(existing.data);
166+
if (prior.sha !== entry.sha || prior.owner !== entry.owner || prior.origin !== entry.origin || prior.sourceSha !== entry.sourceSha)
167+
throw new Error('Cannot overwrite immutable ledger ownership.');
166168
return;
167169
}
168170
this.#run('INSERT INTO ledger VALUES (?,?,?)', key, entry.sha, encode(entry));
@@ -237,7 +239,7 @@ export class Store {
237239
return this.#transaction(() => {
238240
this.#expect(key, expected);
239241
const plan = this.getPlan(identity), ids = plan.items.map(item => item.id);
240-
if (!ids.includes(evidence.item) || !evidence.completedItems.includes(evidence.item) || new Set(evidence.completedItems).size !== evidence.completedItems.length || evidence.completedItems.some((item, i) => item !== ids[i])) throw new Error('Checkpoint must describe the executed plan prefix.');
242+
if (!ids.includes(evidence.item) || evidence.completedItems.at(-1) !== evidence.item || new Set(evidence.completedItems).size !== evidence.completedItems.length || evidence.completedItems.some((item, i) => item !== ids[i])) throw new Error('Checkpoint must describe the executed plan prefix.');
241243
const checkpoint = { ...evidence, ...expected, id: randomUUID() };
242244
this.#run('INSERT INTO checkpoints VALUES (?,?,?)', key, checkpoint.id, encode(checkpoint)); return checkpoint;
243245
});

‎test/store.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,15 @@ it('keeps identical commit SHAs and local item IDs isolated across plan identiti
164164
expect(store.ownership(other).has(oid(2))).toBe(false);
165165
expect(() => store.getSnapshot(other, store.getSnapshot(identity).id)).toThrow(/Unknown/);
166166
});
167+
it('accepts idempotent ledger retries regardless of object property order', () => {
168+
const { store } = fixture();
169+
store.recordHistory(identity, state(store), oid(1), oid(2), [{ sha: oid(2), owner: 'P1', origin: 'owned', sourceSha: null }]);
170+
expect(() => store.recordHistory(identity, state(store), oid(1), oid(2), [{ sourceSha: null, origin: 'owned', owner: 'P1', sha: oid(2) }])).not.toThrow();
171+
expect(store.getLedger(identity)).toHaveLength(1);
172+
});
173+
it('requires the checkpoint item to be the last item in the completed prefix', () => {
174+
const { store } = fixture(); const next = plan(); next.items.push({ ...structuredClone(next.items[0]!), id: 'P2' });
175+
store.importRevision(JSON.stringify(next), 'json', context, 1);
176+
expect(() => store.recordCheckpoint(identity, state(store), { item: 'P1', completedItems: ['P1', 'P2'], outOfScopePaths: ['outside'], baseEntries: context.baseEntries })).toThrow(/prefix/);
177+
expect(store.recordCheckpoint(identity, state(store), { item: 'P2', completedItems: ['P1', 'P2'], outOfScopePaths: ['outside'], baseEntries: context.baseEntries }).item).toBe('P2');
178+
});

0 commit comments

Comments
 (0)