From 0fa42163079e0be4aeccf8a116826d9490515834 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:59:21 -0700 Subject: [PATCH] fix(purge): a skipped seat refuses the whole synthetic account TASK-260 residual, CTO ruling 2026-10-16. The first apply (02:47Z) deleted an account whose seat it had skipped for lacking the journey prefix. That left the seat's ephemeral registry row pointing at a deleted publisher. Install refuses an ephemeral row to anyone but its publisher (routes/registry/install.ts), so the name is dead for everyone, permanently. Every skip reason (no prefix, another user's install, a bot older than the account) orphans the row the same way. The purge now refuses an account that has any skipped seat, naming each seat and its reason, in the same shape as the foreign-human pod refusal. A leftover test account is harmless; a dead name is not. The three seat-skip arms now assert refusal, with the person, Scout, the pod and the seat's registry row, bot and memory all untouched. A new arm shows a refused account stays refused on the next run while an ordinary one beside it is still purged. Dropping the refusal fails all four arms. 14/14 pass across the purge and scheduler suites. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MCgCsf3UJRCPRbnNKxsECe --- .../syntheticAccountPurgeService.test.js | 66 +++++++++++++------ .../services/syntheticAccountPurgeService.ts | 13 +++- 2 files changed, 57 insertions(+), 22 deletions(-) diff --git a/backend/__tests__/unit/services/syntheticAccountPurgeService.test.js b/backend/__tests__/unit/services/syntheticAccountPurgeService.test.js index 713efe13c1..e48c2851ca 100644 --- a/backend/__tests__/unit/services/syntheticAccountPurgeService.test.js +++ b/backend/__tests__/unit/services/syntheticAccountPurgeService.test.js @@ -63,6 +63,15 @@ const syntheticAccount = async (username = 'eng-smoke-journey-1015124709', { sea return { human, scout, seatBot, pod, seat }; }; +// A refused account is left exactly as it was: the person, Scout, the pod and +// the seat's registry row, bot and memory. +const expectUntouched = async ({ human, scout, pod, seatBot, seat }) => { + expect(await User.countDocuments({ _id: { $in: [human._id, scout._id, seatBot._id] } })).toBe(3); + expect(await Pod.countDocuments({ _id: pod._id })).toBe(1); + expect(await AgentRegistry.countDocuments({ agentName: seat })).toBe(1); + expect(await AgentMemory.countDocuments({ agentName: seat })).toBe(1); +}; + describe('syntheticAccountPurgeService', () => { beforeAll(setupMongoDb); afterAll(closeMongoDb); @@ -189,42 +198,59 @@ describe('syntheticAccountPurgeService', () => { expect(await Message.countDocuments({ podId: pod._id })).toBe(1); }); - it('skips a seat name another user also holds, keeping their install and memory', async () => { - const { seat } = await syntheticAccount(); + // A skipped seat refuses the whole account. Install refuses an ephemeral + // registry row to anyone but its publisher, so deleting the account would + // leave the seat's name dead for everyone. + it('refuses an account with a seat another user also holds, and deletes none of it', async () => { + const account = await syntheticAccount(); const other = await insertUser({ username: 'dana', email: 'dana@example.com' }); - await AgentInstallation.collection.insertOne({ agentName: seat, podId: id(), installedBy: other._id }); + await AgentInstallation.collection.insertOne({ agentName: account.seat, podId: id(), installedBy: other._id }); const report = await purgeSyntheticAccounts({ apply: true, now: NOW, pool: fakePool() }); - expect(report.accounts[0].skippedSeats).toEqual([{ name: seat, reason: 'installed by another user' }]); - expect(await AgentInstallation.countDocuments({ agentName: seat, installedBy: other._id })).toBe(1); - expect(await AgentMemory.countDocuments({ agentName: seat })).toBe(1); - expect(await AgentRegistry.countDocuments({ agentName: seat })).toBe(1); + expect(report.accounts[0].skippedSeats).toEqual([{ name: account.seat, reason: 'installed by another user' }]); + expect(report.accounts[0].refused).toMatch(/orphan its skipped seat\(s\): .*installed by another user/); + expect(await AgentInstallation.countDocuments({ agentName: account.seat, installedBy: other._id })).toBe(1); + await expectUntouched(account); }); // Registration is open and accepts .invalid, so the selected account may be // anyone's. These arms pin that its seat list cannot reach another identity. - it('skips a seat name without the journey prefix, keeping its bot and memory', async () => { - const { human, seatBot, seat } = await syntheticAccount('eng-smoke-journey-1015124709', { seat: 'acme-support' }); + it('refuses an account with a seat name lacking the journey prefix, and deletes none of it', async () => { + const account = await syntheticAccount('eng-smoke-journey-1015124709', { seat: 'acme-support' }); const report = await purgeSyntheticAccounts({ apply: true, now: NOW, pool: fakePool() }); - expect(report.accounts[0].skippedSeats).toEqual([{ name: seat, reason: 'name lacks the journey prefix' }]); - expect(await User.countDocuments({ _id: human._id })).toBe(0); - expect(await User.countDocuments({ _id: seatBot._id })).toBe(1); - expect(await AgentMemory.countDocuments({ agentName: seat })).toBe(1); - expect(await AgentRegistry.countDocuments({ agentName: seat })).toBe(1); + expect(report.accounts[0].skippedSeats).toEqual([{ name: account.seat, reason: 'name lacks the journey prefix' }]); + expect(report.accounts[0].refused).toMatch(/acme-support \(name lacks the journey prefix\)/); + await expectUntouched(account); }); - it('skips a prefixed seat whose bot user is older than the account', async () => { - const { seatBot, seat } = await syntheticAccount(); - await User.collection.updateOne({ _id: seatBot._id }, { $set: { createdAt: new Date('2026-01-01T00:00:00Z') } }); + it('refuses an account with a prefixed seat whose bot user is older than the account', async () => { + const account = await syntheticAccount(); + await User.collection.updateOne({ _id: account.seatBot._id }, { $set: { createdAt: new Date('2026-01-01T00:00:00Z') } }); const report = await purgeSyntheticAccounts({ apply: true, now: NOW, pool: fakePool() }); - expect(report.accounts[0].skippedSeats).toEqual([{ name: seat, reason: 'its bot user predates the account' }]); - expect(await User.countDocuments({ _id: seatBot._id })).toBe(1); - expect(await AgentMemory.countDocuments({ agentName: seat })).toBe(1); + expect(report.accounts[0].skippedSeats).toEqual([{ name: account.seat, reason: 'its bot user predates the account' }]); + expect(report.accounts[0].refused).toMatch(/its bot user predates the account/); + await expectUntouched(account); + }); + + it('a refused account stays refused on the next run, and an ordinary one beside it is still purged', async () => { + const refusedOne = await syntheticAccount('eng-smoke-journey-1015124709', { seat: 'acme-support' }); + const ordinary = await syntheticAccount('eng-smoke-journey-1015130000'); + + const first = await purgeSyntheticAccounts({ apply: true, now: NOW, pool: fakePool() }); + const again = await purgeSyntheticAccounts({ apply: true, now: NOW, pool: fakePool() }); + + expect(first.accounts.map((a) => [a.username, Boolean(a.refused)])).toEqual( + expect.arrayContaining([['eng-smoke-journey-1015124709', true], ['eng-smoke-journey-1015130000', false]]), + ); + expect(await User.countDocuments({ _id: ordinary.human._id })).toBe(0); + expect(again.accounts).toHaveLength(1); + expect(again.accounts[0].refused).toBeTruthy(); + await expectUntouched(refusedOne); }); it("deletes only the seat's own (name, 'default') memory, not the same name under another instance", async () => { diff --git a/backend/services/syntheticAccountPurgeService.ts b/backend/services/syntheticAccountPurgeService.ts index 32bd801461..aa417b738a 100644 --- a/backend/services/syntheticAccountPurgeService.ts +++ b/backend/services/syntheticAccountPurgeService.ts @@ -27,8 +27,12 @@ import { withAdminUserMutationLock } from './adminUserMutationService'; * Registration is open and accepts .invalid, so ANYONE can create an account * this selects; the purge must therefore never reach an identity such an * account merely names. A pod H created that another HUMAN has joined refuses - * the whole account. A seat H published is cleaned only if all of these hold, - * and is otherwise skipped and reported with its reason: + * the whole account. A seat H published is cleaned only if all of these hold; + * otherwise it is skipped, reported with its reason, and the whole account is + * refused, because deleting H would orphan the seat: install refuses an + * ephemeral registry row to anyone but its publisher (routes/registry/ + * install.ts), so the name would be dead for everyone, permanently. A leftover + * test account is harmless; a dead name is not. * - its name starts with the journey prefix (the BYO link derives seat names * from the account's username, so journey seats always carry it); * - no one else has an installation of that name; @@ -193,6 +197,11 @@ const purgeSyntheticAccountsUnlocked = async ( preservedDms: preservedDms.map((pod: any) => String(pod._id)), }; report.accounts.push(account); + if (skippedSeats.length) { + const seats = skippedSeats.map((seat) => `${seat.name} (${seat.reason})`).join(', '); + account.refused = `deleting this account would orphan its skipped seat(s): ${seats}`; + continue; + } // A disposable bot can acquire a life outside this throwaway footprint. // Refuse rather than deleting an identity another DM still depends on. if (scoutBot && await AgentInstallation.exists({