From 65a35831b2119b3ac9a1e3d2f7bb9f0ba65cd52c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:41:20 -0700 Subject: [PATCH 1/2] test(grants): pin the per-grant guard against a reject between read and write (TASK-333) Otto's gate at 6afc00a30 found a surviving mutant: narrowing the per-grant write's filter to { grantId } passed every test. The window is a refused mint's revocation landing after the close has read its candidates. Without the revokedAt:null guard, the close overwrites that revocation and names a grant it didn't revoke. The new arm runs the reject's write just before the close's write. It asserts that the grant keeps the reject's revokedAt and that no grant.revoked line is written. The { grantId } mutant fails it, and only it. Test-only. Co-Authored-By: Claude Opus 5.5 Co-authored-by: vera Agent-Model: claude/claude-opus-5-5/default --- .../unit/services/roomGrantRecord.test.js | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/backend/__tests__/unit/services/roomGrantRecord.test.js b/backend/__tests__/unit/services/roomGrantRecord.test.js index c0ad71d726..59ff473928 100644 --- a/backend/__tests__/unit/services/roomGrantRecord.test.js +++ b/backend/__tests__/unit/services/roomGrantRecord.test.js @@ -239,3 +239,23 @@ test('a close whose write fails for one grant still names the grants it did revo const revoked = (await chainLines()).filter((l) => l.kind === 'grant.revoked'); expect(revoked.map((l) => l.data.grantIds)).toEqual([revokedIds]); }); + +test('a reject that lands between the close\'s read and its write keeps its revocation and is not named', async () => { + const grant = await service.mintGrant(input()); + const model = RoomGrant(); + const realFindOneAndUpdate = model.findOneAndUpdate.bind(model); + const rejectedAt = new Date(Date.now() - 1000); + // The refused mint's write lands after the close read its candidates. + const spy = jest.spyOn(model, 'findOneAndUpdate').mockImplementationOnce(async (...args) => { + await model.updateOne( + { grantId: grant.grantId, revokedAt: null }, + { $set: { revokedAt: rejectedAt, revocationReason: 'task.done' } }, + ); + return realFindOneAndUpdate(...args); + }); + await close(); + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + expect((await model.findOne({ grantId: grant.grantId }).lean()).revokedAt).toEqual(rejectedAt); + expect((await chainLines()).filter((l) => l.kind === 'grant.revoked')).toEqual([]); +}); From 2674397313eba8c8998564abbcca4e92d5faad2e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:41:25 -0700 Subject: [PATCH 2/2] test(grants): the reject-race arm requires a clean close (TASK-333) onTaskClosed catches and logs every revokeTaskGrants error, so an empty grant.revoked record also follows a close that threw before emitting. The arm now asserts console.error stayed silent. A throw inserted just before emitRecord reddens this arm; it passed at 65a35831b. Co-Authored-By: Claude Opus 5.5 Co-authored-by: vera Agent-Model: claude/claude-opus-5-5/default --- backend/__tests__/unit/services/roomGrantRecord.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/__tests__/unit/services/roomGrantRecord.test.js b/backend/__tests__/unit/services/roomGrantRecord.test.js index 59ff473928..9db313dad3 100644 --- a/backend/__tests__/unit/services/roomGrantRecord.test.js +++ b/backend/__tests__/unit/services/roomGrantRecord.test.js @@ -253,9 +253,13 @@ test('a reject that lands between the close\'s read and its write keeps its revo ); return realFindOneAndUpdate(...args); }); + const logged = jest.spyOn(console, 'error').mockImplementation(() => {}); await close(); expect(spy).toHaveBeenCalledTimes(1); spy.mockRestore(); + // onTaskClosed only logs a failed close, so an empty record needs a clean close. + expect(logged).not.toHaveBeenCalled(); + logged.mockRestore(); expect((await model.findOne({ grantId: grant.grantId }).lean()).revokedAt).toEqual(rejectedAt); expect((await chainLines()).filter((l) => l.kind === 'grant.revoked')).toEqual([]); });