Skip to content

Commit 600ef0d

Browse files
committed
Preserve review safety across item removal and identity mappings
1 parent 15dd571 commit 600ef0d

3 files changed

Lines changed: 27 additions & 3 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,7 @@ Declined the duplicate-source-mapping finding as a correctness issue: the rewrit
4747
## Review round 4
4848

4949
Reproduced a saved assignment throwing in `applyChoices` after its target item was removed by amendment. Revision commits now discard assignments whose targets no longer exist, in the same transaction as the revision and request invalidation. Unrelated assignments and standalone acceptances remain. Removed assignments cannot reactivate if an ID is later reintroduced. No finding declined this round. Final suite: 155 tests.
50+
51+
## Review round 5
52+
53+
Reproduced and fixed three cases: old approvals reviving after item removal/reintroduction; an unknown identity-mapped SHA remaining unrecorded and later claimable; and the linking engine rejecting historical owners absent from the selected plan revision. Revision commits now discard approvals for removed IDs. Missing identity-map sources receive immutable foreign/null-owner ledger entries. `ownership(identity, revision)` produces a conservative view for that revision, mapping absent owners to null without altering the historical ledger; pass the same revision as the plan supplied to the linking engine. `getLedger` remains the raw provenance record. No findings declined this round. Final suite: 157 tests.

‎runner/store.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ export class Store {
8080
this.#run('INSERT INTO revisions VALUES (?,?,?)', key, plan.revision, encode(plan));
8181
this.#run("UPDATE requests SET state='invalidated' WHERE key=? AND state IN ('pending','ready')", key);
8282
const items = new Set(plan.items.map(item => item.id));
83+
for (const row of this.#db.prepare('SELECT item FROM approvals WHERE key=?').all(key)) {
84+
if (!items.has(row.item as string)) this.#run('DELETE FROM approvals WHERE key=? AND item=?', key, row.item!);
85+
}
8386
for (const row of this.#db.prepare('SELECT choice_key,data FROM choices WHERE key=?').all(key)) {
8487
const choice = decode<SegmentChoice>(row.data);
8588
if (choice.action === 'assign' && !items.has(choice.item!))
@@ -193,8 +196,10 @@ export class Store {
193196
const key = identityKey(identity); this.#current(key);
194197
return this.#db.prepare('SELECT data FROM ledger WHERE key=? ORDER BY sha').all(key).map(row => decode<LedgerEntry>(row.data));
195198
}
196-
ownership(identity: PlanIdentity): ReadonlyMap<string, string | null> {
197-
return new Map(this.getLedger(identity).map(entry => [entry.sha, entry.owner]));
199+
/** Link a selected revision conservatively; the raw ledger retains historical owners. */
200+
ownership(identity: PlanIdentity, revision?: number): ReadonlyMap<string, string | null> {
201+
const items = new Set(this.getPlan(identity, revision).items.map(item => item.id));
202+
return new Map(this.getLedger(identity).map(entry => [entry.sha, entry.owner !== null && items.has(entry.owner) ? entry.owner : null]));
198203
}
199204
recordRebase(identity: PlanIdentity, expected: ReviewState, base: string, head: string, mappings: readonly { oldSha: string; newSha: string }[]): Snapshot {
200205
const key = identityKey(identity);
@@ -209,6 +214,7 @@ export class Store {
209214
destinations.add(newSha);
210215
const source = ledger.get(oldSha);
211216
if (oldSha !== newSha) this.#entry(key, { sha: newSha, owner: source?.owner ?? null, origin: source?.origin ?? 'foreign', sourceSha: oldSha });
217+
else if (!source) this.#entry(key, { sha: newSha, owner: null, origin: 'foreign', sourceSha: null });
212218
this.#run('INSERT INTO rewrites VALUES (?,?,?,?)', key, snapshot.id, oldSha, newSha);
213219
}
214220
return snapshot;

‎test/store.test.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ it('feeds persisted remapped ownership into the linking engine on real Git histo
100100
store.recordRebase(identity, state(store), base, head, [{ oldSha: oid(9), newSha: head }]);
101101
const segments = linkHistory(store.getPlan(identity), readHistory(dir, base, head), store.ownership(identity), p => p);
102102
expect(segments.length).toBeGreaterThan(0); expect(segments.every(s => s.row === 'P1')).toBe(true);
103+
const amended = plan(); amended.items[0]!.id = 'P2'; store.importRevision(JSON.stringify(amended), 'json', context, 1);
104+
const afterRemoval = linkHistory(store.getPlan(identity), readHistory(dir, base, head), store.ownership(identity), p => p);
105+
expect(afterRemoval.every(s => s.row === 'Unplanned')).toBe(true);
103106
});
104107
it('retains typed file-card approvals/choices while fingerprints detect later metadata and plan changes', () => {
105108
const { store, path } = fixture(); const current = store.getPlan(identity);
@@ -183,7 +186,7 @@ it('allows historical ledger retries after the owning item is removed, but rejec
183186
expect(() => store.recordHistory(identity, state(store), oid(1), oid(2), [entry])).not.toThrow();
184187
expect(() => store.recordHistory(identity, state(store), oid(1), oid(3), [{ ...entry, sha: oid(3) }])).toThrow(/Unknown ledger owner/);
185188
store.recordRebase(identity, state(store), oid(4), oid(5), [{ oldSha: oid(2), newSha: oid(5) }]);
186-
expect(store.ownership(identity).get(oid(5))).toBe('P1');
189+
expect(store.getLedger(identity).find(entry => entry.sha === oid(5))!.owner).toBe('P1');
187190
});
188191
it('allows a later amended revision to receive a fresh continuation approval', () => {
189192
const { store } = fixture(); const checkpoint = store.recordCheckpoint(identity, state(store), { item: 'P1', completedItems: ['P1'], outOfScopePaths: ['outside'], baseEntries: context.baseEntries });
@@ -210,3 +213,14 @@ it('expires assignments to removed plan items atomically with the amendment', ()
210213
store.importRevision(JSON.stringify(plan()), 'json', context, 2);
211214
expect(store.getReview(identity).choices).toEqual([]);
212215
});
216+
it('does not revive an old approval when a removed item ID is reintroduced', () => {
217+
const { store } = fixture(); store.saveReview(identity, state(store), [approveItem(store.getPlan(identity), [], 'P1', identity, true)], []);
218+
const next = plan(); next.items[0]!.id = 'P2'; store.importRevision(JSON.stringify(next), 'json', context, 1);
219+
store.importRevision(JSON.stringify(plan()), 'json', context, 2);
220+
expect(approvalStates(store.getPlan(identity), [], store.getReview(identity).approvals, identity).P1).toBe('unreviewed');
221+
});
222+
it('persists foreign ownership for an unknown SHA mapped to itself', () => {
223+
const { store } = fixture(); store.recordRebase(identity, state(store), oid(1), oid(2), [{ oldSha: oid(2), newSha: oid(2) }]);
224+
expect(store.getLedger(identity)).toEqual([{ sha: oid(2), owner: null, origin: 'foreign', sourceSha: null }]);
225+
expect(() => store.recordHistory(identity, state(store), oid(1), oid(2), [{ sha: oid(2), owner: 'P1', origin: 'owned', sourceSha: null }])).toThrow(/immutable/);
226+
});

0 commit comments

Comments
 (0)