From c48ac2a5fbd8f90c2a4c546a5f1ce66ec9bd514f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:39:58 -0700 Subject: [PATCH 1/4] fix(purge): preserve historical DMs and independent participants Keep strict DM membership in Mongo and PostgreSQL when purging a synthetic human. Retain synthetic-created DMs with outside participants and their messages/installations; cascade wholly disposable DMs. Refuse a purge when its disposable bot is used in an outside DM or its Scout is installed by another owner. Revoke recorded human-owned credential roots and descendants and pause only their active installations. Retain independent agent identities/memory and unrelated other-owner keys/installations. Reuse the missing-user projection/runtime roster from #2721 so deleted human IDs still render safely. Document the report and remaining late-producer boundary. Validation: old pair/cascade failures4/4, final realMongo7/PG16 minimal-schema service8/8, focused74/74, source typecheck and targeted lint0errors. PG-exclusion, owner-pause, credential-revoke and missing-member projection mutants each fail2/2; restored. Initial real fixture null-email collisions corrected with unique emails. No production purge/deploy claim. Written by Sprint Impl, a Commonly agent https://commonly.me/v2/pods/6addd4d47f8fc798a6f66bbb#message-87328 Co-authored-by: sprint-impl Agent-Model: codex/gpt-6.1-sol/medium --- .../service/syntheticPurgeDm.test.js | 151 ++++++++++++++++++ .../unit/controllers/podController.test.js | 4 +- backend/controllers/podController.ts | 15 +- backend/routes/agentsRuntime.ts | 7 +- backend/routes/podInvites.ts | 3 +- backend/routes/pods.ts | 3 +- backend/services/podContextService.ts | 19 ++- backend/services/podMemberPopulate.ts | 10 ++ .../services/syntheticAccountPurgeService.ts | 60 ++++++- docs/runbooks/alerting.md | 22 +++ 10 files changed, 267 insertions(+), 27 deletions(-) create mode 100644 backend/__tests__/service/syntheticPurgeDm.test.js create mode 100644 backend/services/podMemberPopulate.ts diff --git a/backend/__tests__/service/syntheticPurgeDm.test.js b/backend/__tests__/service/syntheticPurgeDm.test.js new file mode 100644 index 0000000000..376fba7c61 --- /dev/null +++ b/backend/__tests__/service/syntheticPurgeDm.test.js @@ -0,0 +1,151 @@ +const mongoose = require('mongoose'); +const { newDb } = require('pg-mem'); +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils'); +let mockPool; +jest.mock('../../config/db-pg', () => ({ pool: { query: (...args) => mockPool.query(...args) } })); +const User = require('../../models/User'); +const Pod = require('../../models/Pod'); +const AgentCredential = require('../../models/AgentCredential'); +const Message = require('../../models/Message'); +const AgentMemory = require('../../models/AgentMemory'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const { purgeSyntheticAccounts } = require('../../services/syntheticAccountPurgeService'); +const id = () => new mongoose.Types.ObjectId(); +const OLD = new Date('2026-10-15'); +const NOW = new Date('2026-10-25'); +const insertUser = async (fields) => { const row = { _id: id(), email: `${id()}@fixture.test`, createdAt: OLD, ...fields }; await User.collection.insertOne(row); return row; }; + +describe('Synthetic purge DM preservation', () => { + let pool; let schema; let adminPool; + beforeAll(setupMongoDb); + afterAll(closeMongoDb); + beforeEach(async () => { + if (process.env.INTEGRATION_TEST === 'true') { + const { Pool } = require('pg'); + const options = { host: process.env.PG_HOST, port: Number(process.env.PG_PORT), database: process.env.PG_DATABASE, + user: process.env.PG_USER, password: process.env.PG_PASSWORD, ssl: false }; + adminPool = new Pool(options); + schema = `purge_dm_${id().toString()}`; + await adminPool.query(`CREATE SCHEMA ${schema}`); + pool = new Pool({ ...options, options: `-c search_path=${schema}` }); + } else { + const Pool = newDb().adapters.createPg().Pool; pool = new Pool(); + } + mockPool = pool; + await pool.query('CREATE TABLE users (_id varchar(24) PRIMARY KEY)'); + await pool.query('CREATE TABLE pods (id varchar(24) PRIMARY KEY, type text)'); + await pool.query('CREATE TABLE pod_members (pod_id varchar(24) REFERENCES pods(id) ON DELETE CASCADE, user_id varchar(24))'); + await pool.query('CREATE TABLE messages (pod_id varchar(24))'); + await pool.query('CREATE TABLE message_claims (pod_id varchar(24))'); + }); + afterEach(async () => { await clearMongoDb(); await pool.end(); + if (adminPool) { await adminPool.query(`DROP SCHEMA ${schema} CASCADE`); await adminPool.end(); adminPool = null; } }); + + it.each(['agent-room', 'agent-dm'].flatMap((type) => [false, true].map((owned) => [type, owned]))) + ('retains both stores and independent agents in %s (created by synthetic=%s)', async (type, owned) => { + const human = await insertUser({ username: 'eng-smoke-journey-1015124709', email: 'journey@ops.commonly.invalid', isBot: false }); + const agent = await insertUser({ username: 'independent', isBot: true }); + const other = await insertUser({ username: 'other', isBot: false }); + const pod = { _id: id(), name: 'Historical DM', type, createdBy: owned ? human._id : other._id, members: [human._id, agent._id] }; + const shared = { _id: id(), name: 'Shared', type: 'standard', createdBy: other._id, members: [human._id, other._id] }; + await Pod.collection.insertMany([pod, shared]); + await Message.collection.insertOne({ podId: pod._id, userId: agent._id, content: 'Independent history' }); + await AgentMemory.collection.insertOne({ agentName: 'independent', instanceId: 'default' }); + await pool.query('INSERT INTO messages VALUES ($1)', [String(pod._id)]); + for (const p of [pod, shared]) { + await pool.query('INSERT INTO pods VALUES ($1,$2)', [String(p._id), p.type]); + for (const m of p.members) await pool.query('INSERT INTO pod_members VALUES ($1,$2)', [String(p._id), String(m)]); + } + const own = await AgentInstallation.create({ agentName: 'independent', podId: pod._id, version: '1.0.0', installedBy: human._id }); + const keep = await AgentInstallation.create({ agentName: 'other-agent', podId: pod._id, version: '1.0.0', installedBy: other._id }); + const key = await AgentCredential.create({ tokenHash: 'owned', kind: 'runtime', ownerUserId: human._id, agentUserId: agent._id }); + const otherKey = await AgentCredential.create({ tokenHash: 'other', kind: 'runtime', ownerUserId: other._id, agentUserId: agent._id }); + const dry = await purgeSyntheticAccounts({ now: NOW, pool }); + expect(await User.findById(human._id)).not.toBeNull(); + expect((await AgentInstallation.findById(own._id)).status).toBe('active'); + expect((await AgentCredential.findById(key._id)).status).toBe('active'); + await purgeSyntheticAccounts({ apply: true, now: NOW, pool }); + const after = await Pod.findById(pod._id); + const pair = pod.members.map(String).sort(); + expect(after?.members.map(String).sort()).toEqual(pair); + const rows = (await pool.query('SELECT user_id FROM pod_members WHERE pod_id=$1', [String(pod._id)])).rows; + expect(rows.map((r) => r.user_id).sort()).toEqual(pair); + expect((await Pod.findById(shared._id)).members.map(String)).toEqual([String(other._id)]); + expect((await pool.query('SELECT user_id FROM pod_members WHERE pod_id=$1', [String(shared._id)])).rows.map((r) => r.user_id)).toEqual([String(other._id)]); + expect(await User.findById(human._id)).toBeNull(); + expect(await User.findById(agent._id)).not.toBeNull(); + expect(await Message.countDocuments({ podId: pod._id, content: 'Independent history' })).toBe(1); + expect(await AgentMemory.countDocuments({ agentName: 'independent' })).toBe(1); + expect((await pool.query('SELECT pod_id FROM messages WHERE pod_id=$1', [String(pod._id)])).rows).toHaveLength(1); + expect((await AgentInstallation.findById(own._id)).status).toBe('paused'); + expect((await AgentInstallation.findById(keep._id)).status).toBe('active'); + expect((await AgentCredential.findById(key._id)).status).toBe('revoked'); + expect((await AgentCredential.findById(otherKey._id)).status).toBe('active'); + expect(dry.counts['agentinstallations.paused']).toBe(1); + expect(dry.accounts[0].preservedDms).toEqual(owned ? [String(pod._id)] : []); + const response = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + await require('../../controllers/podController').getPodById({ params: { id: String(pod._id), type }, userId: String(agent._id) }, response); + expect(response.status).not.toHaveBeenCalled(); + const projected = response.json.mock.calls[0][0]; + expect(projected.members.find((m) => String(m._id) === String(human._id)).username).toBe('Deleted user'); + expect(await require('../../services/dmService').canViewPod(String(agent._id), projected)).toBe(true); + const context = await require('../../services/podContextService').getPodContext({ podId: String(pod._id), userId: String(agent._id), skillMode: 'none' }); + expect(context.members).toEqual(expect.arrayContaining([{ name: 'Deleted user', isAgent: false, self: false }])); + }); + it('refuses to delete a disposable Scout that another DM depends on', async () => { + const human = await insertUser({ username: 'eng-smoke-journey-1015124709', email: 'journey@ops.commonly.invalid', isBot: false }); + const instance = require('../../services/personaHireService').perUserInstanceId(String(human._id)); + const scout = await insertUser({ username: `scout-${instance}`, isBot: true }); + const other = await insertUser({ username: 'independent', isBot: true }); + const pod = { _id: id(), type: 'agent-dm', createdBy: other._id, members: [scout._id, other._id] }; + await Pod.collection.insertOne(pod); + const report = await purgeSyntheticAccounts({ apply: true, now: NOW, pool }); + expect(report.accounts[0].refused).toMatch(/agent participates in a DM/); + expect(await User.findById(human._id)).not.toBeNull(); + expect(await User.findById(scout._id)).not.toBeNull(); + expect((await Pod.findById(pod._id)).members.map(String)).toEqual(pod.members.map(String)); + }); + + it('deletes a DM wholly inside the disposable footprint', async () => { + const human = await insertUser({ username: 'eng-smoke-journey-1015124709', email: 'journey@ops.commonly.invalid', isBot: false }); + const instance = require('../../services/personaHireService').perUserInstanceId(String(human._id)); + const scout = await insertUser({ username: `scout-${instance}`, isBot: true }); + const pod = { _id: id(), type: 'agent-room', createdBy: human._id, members: [human._id, scout._id] }; + await Pod.collection.insertOne(pod); + await pool.query('INSERT INTO pods VALUES ($1,$2)', [String(pod._id), pod.type]); + await pool.query('INSERT INTO pod_members VALUES ($1,$2)', [String(pod._id), String(human._id)]); + await purgeSyntheticAccounts({ apply: true, now: NOW, pool }); + expect(await Pod.findById(pod._id)).toBeNull(); + // In default mode deletePodCascade intentionally has no configured PG mirror. + if (process.env.INTEGRATION_TEST === 'true') { + expect((await pool.query('SELECT id FROM pods')).rows).toHaveLength(0); + expect((await pool.query('SELECT user_id FROM pod_members')).rows).toHaveLength(0); + } + expect(await User.findById(scout._id)).toBeNull(); + }); + + it('still refuses a synthetic-created DM another human joined', async () => { + const human = await insertUser({ username: 'eng-smoke-journey-1015124709', email: 'journey@ops.commonly.invalid', isBot: false }); + const other = await insertUser({ username: 'real', isBot: false }); + const pod = { _id: id(), type: 'agent-room', createdBy: human._id, members: [human._id, other._id] }; + await Pod.collection.insertOne(pod); + const report = await purgeSyntheticAccounts({ apply: true, now: NOW, pool }); + expect(report.accounts[0].refused).toMatch(/another human/); + expect(await User.findById(human._id)).not.toBeNull(); + expect(await Pod.findById(pod._id)).not.toBeNull(); + }); + + it('refuses to delete a disposable Scout installed by another owner', async () => { + const human = await insertUser({ username: 'eng-smoke-journey-1015124709', email: 'journey@ops.commonly.invalid', isBot: false }); + const other = await insertUser({ username: 'real', isBot: false }); + const instanceId = require('../../services/personaHireService').perUserInstanceId(String(human._id)); + const scout = await insertUser({ username: `scout-${instanceId}`, isBot: true }); + const installation = await AgentInstallation.create({ agentName: 'scout', instanceId, podId: id(), version: '1.0.0', installedBy: other._id }); + const report = await purgeSyntheticAccounts({ apply: true, now: NOW, pool }); + expect(report.accounts[0].refused).toMatch(/Scout is installed by another user/); + expect(await User.findById(human._id)).not.toBeNull(); + expect(await User.findById(scout._id)).not.toBeNull(); + expect((await AgentInstallation.findById(installation._id)).status).toBe('active'); + }); + +}); diff --git a/backend/__tests__/unit/controllers/podController.test.js b/backend/__tests__/unit/controllers/podController.test.js index 6e1e3b6ea0..46494d5b99 100644 --- a/backend/__tests__/unit/controllers/podController.test.js +++ b/backend/__tests__/unit/controllers/podController.test.js @@ -120,7 +120,7 @@ describe('podController', () => { expect(save).toHaveBeenCalled(); expect(savedPod.populate).toHaveBeenCalledWith('createdBy', 'username profilePicture'); - expect(savedPod.populate).toHaveBeenCalledWith('members', 'username profilePicture isBot'); + expect(savedPod.populate).toHaveBeenCalledWith(expect.objectContaining({ path: 'members', select: 'username profilePicture isBot' })); expect(AgentInstallation.install).toHaveBeenCalledWith('commonly-bot', 'p1', expect.objectContaining({ installedBy: 'creator', instanceId: 'default', @@ -290,7 +290,7 @@ describe('podController', () => { expect(pod.members).toEqual(['creator']); expect(pod.save).toHaveBeenCalled(); expect(pod.populate).toHaveBeenCalledWith('createdBy', 'username profilePicture'); - expect(pod.populate).toHaveBeenCalledWith('members', 'username profilePicture isBot'); + expect(pod.populate).toHaveBeenCalledWith(expect.objectContaining({ path: 'members', select: 'username profilePicture isBot' })); expect(res.json).toHaveBeenCalledWith(pod); }); diff --git a/backend/controllers/podController.ts b/backend/controllers/podController.ts index 9b6a27b82c..54e188e2fe 100644 --- a/backend/controllers/podController.ts +++ b/backend/controllers/podController.ts @@ -1,3 +1,4 @@ +import { podMemberPopulate } from '../services/podMemberPopulate'; const mongoose = require('mongoose'); const Pod = require('../models/Pod'); const Message = require('../models/Message'); @@ -248,7 +249,7 @@ exports.getAllPods = async (req: any, res: any) => { let pods = await Pod.find(listedQuery) .populate('createdBy', 'username profilePicture') - .populate('members', 'username profilePicture isBot') + .populate(podMemberPopulate('username profilePicture isBot')) .populate('parentPod', 'name _id') .sort({ updatedAt: -1 }); @@ -313,7 +314,7 @@ exports.getPodsByType = async (req: any, res: any) => { const pods = await Pod.find({ type }) .populate('createdBy', 'username profilePicture') - .populate('members', 'username profilePicture isBot') + .populate(podMemberPopulate('username profilePicture isBot')) .sort({ updatedAt: -1 }); // Personal pod types (agent-admin, agent-room, agent-dm) are always @@ -344,7 +345,7 @@ exports.getPodById = async (req: any, res: any) => { // Get the pod with populated data const pod = await Pod.findById(id) .populate('createdBy', 'username profilePicture') - .populate('members', 'username profilePicture isBot') + .populate(podMemberPopulate('username profilePicture isBot')) .populate('parentPod', 'name _id'); if (!pod) { @@ -449,7 +450,7 @@ exports.createPod = async (req: any, res: any) => { // Populate the user data await pod.populate('createdBy', 'username profilePicture'); - await pod.populate('members', 'username profilePicture isBot'); + await pod.populate(podMemberPopulate('username profilePicture isBot')); // Also create in PostgreSQL if available try { @@ -573,7 +574,7 @@ exports.joinPod = async (req: any, res: any) => { const updatedPod = await Pod.findById(id) .populate('createdBy', 'username profilePicture') - .populate('members', 'username profilePicture isBot'); + .populate(podMemberPopulate('username profilePicture isBot')); return res.json(updatedPod); } catch (err: any) { @@ -626,7 +627,7 @@ exports.leavePod = async (req: any, res: any) => { // Populate the user data await pod.populate('createdBy', 'username profilePicture'); - await pod.populate('members', 'username profilePicture isBot'); + await pod.populate(podMemberPopulate('username profilePicture isBot')); res.json(pod); } catch (err: any) { @@ -694,7 +695,7 @@ exports.removeMember = async (req: any, res: any) => { } await pod.populate('createdBy', 'username profilePicture'); - await pod.populate('members', 'username profilePicture isBot'); + await pod.populate(podMemberPopulate('username profilePicture isBot')); return res.json(pod); } catch (err: any) { diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index 13f6a56948..778058aa34 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -1,3 +1,4 @@ +import { podMemberPopulate } from '../services/podMemberPopulate'; import dualAuth from '../middleware/dualAuth'; import { validateAgentCardPayload } from '../services/uiCardValidation'; export {}; @@ -3287,7 +3288,7 @@ router.post('/pods', phase4RateLimit, agentRuntimeAuth, async (req: any, res: an // Global dedup by name: if a pod with this name already exists anywhere, join it and return it const existingPod = await Pod.findOne({ name }) .populate('createdBy', 'username profilePicture') - .populate('members', 'username profilePicture'); + .populate(podMemberPopulate('username profilePicture')); if (existingPod) { // DM pods are strictly 1:1 (ADR-001 §3.10). VALID_POD_TYPES above gates // the type the caller ASKED for, but this dedup branch matches on name @@ -3379,7 +3380,7 @@ router.post('/pods', phase4RateLimit, agentRuntimeAuth, async (req: any, res: an if (!isMember) { existingPod.members.push(agentUser._id); await existingPod.save(); - await existingPod.populate('members', 'username profilePicture'); + await existingPod.populate(podMemberPopulate('username profilePicture')); } // Ensure an AgentInstallation exists so the agent appears in the Agents section if (sourceInstall) { @@ -3431,7 +3432,7 @@ router.post('/pods', phase4RateLimit, agentRuntimeAuth, async (req: any, res: an const pod = await newPod.save(); await pod.populate('createdBy', 'username profilePicture'); - await pod.populate('members', 'username profilePicture'); + await pod.populate(podMemberPopulate('username profilePicture')); if (process.env.PG_HOST && PGPod) { try { diff --git a/backend/routes/podInvites.ts b/backend/routes/podInvites.ts index 38802649fc..9139b67921 100644 --- a/backend/routes/podInvites.ts +++ b/backend/routes/podInvites.ts @@ -1,3 +1,4 @@ +import { podMemberPopulate } from '../services/podMemberPopulate'; // Pod invite tokens — shareable URLs that let a user join a pod without // going through the invite-only joinPolicy gate. Tokens are random // (16-byte hex), DB-backed (PodInvite), and bound to a single pod by their @@ -304,7 +305,7 @@ router.post('/invites/:token/redeem', inviteWriteRateLimit, auth, async (req: an await invite.save(); const updated = await Pod.findById(pod._id) .populate('createdBy', 'username profilePicture') - .populate('members', 'username profilePicture isBot'); + .populate(podMemberPopulate('username profilePicture isBot')); return res.json({ ok: true, alreadyMember, pod: updated }); } catch (err: any) { console.error('Redeem invite failed:', err.message); diff --git a/backend/routes/pods.ts b/backend/routes/pods.ts index ff44cd0b74..3d57690fe0 100644 --- a/backend/routes/pods.ts +++ b/backend/routes/pods.ts @@ -1,3 +1,4 @@ +import { podMemberPopulate } from '../services/podMemberPopulate'; import path from 'path'; import fs from 'fs'; import { createHash } from 'crypto'; @@ -625,7 +626,7 @@ router.get('/:id/children', auth, async (req: AuthReq, res: Res) => { if (!canViewParent) return res.status(403).json({ msg: 'Not authorized to view child pods' }); const children = await Pod.find({ parentPod: parentId }) .populate('createdBy', 'username profilePicture') - .populate('members', 'username profilePicture') + .populate(podMemberPopulate('username profilePicture')) .sort({ name: 1 }); const visibleChildren = []; for (const child of children) { diff --git a/backend/services/podContextService.ts b/backend/services/podContextService.ts index 83e669d6e8..8cc067cda4 100644 --- a/backend/services/podContextService.ts +++ b/backend/services/podContextService.ts @@ -404,13 +404,18 @@ class PodContextService { const memberDocs = await User.find({ _id: { $in: memberIds } }) .select('_id username isBot botMetadata') .lean(); - members = (memberDocs as Array>).map((u) => ({ - name: u.isBot - ? resolveAgentDisplayLabel(u, (u.username as string) || 'agent') - : ((u.username as string) || 'member'), - isAgent: !!u.isBot, - self: toObjectIdString(u._id) === selfId, - })); + const usersById = new Map((memberDocs as Array>) + .map((user) => [toObjectIdString(user._id), user])); + members = memberIds.map((id) => { + const user = usersById.get(id); + return { + name: user?.isBot + ? resolveAgentDisplayLabel(user, (user.username as string) || 'agent') + : (user ? ((user.username as string) || 'member') : 'Deleted user'), + isAgent: !!user?.isBot, + self: id === selfId, + }; + }); } // Files a human uploaded into this pod, so an agent orienting via diff --git a/backend/services/podMemberPopulate.ts b/backend/services/podMemberPopulate.ts new file mode 100644 index 0000000000..3a1295c529 --- /dev/null +++ b/backend/services/podMemberPopulate.ts @@ -0,0 +1,10 @@ +// Historical DM membership survives account deletion. Mongoose normally drops +// missing User refs from populated arrays; keep the opaque id and a safe label +// so every pod read preserves the pair without reviving the deleted account. +export const podMemberPopulate = (select: string) => ({ + path: 'members', + select, + transform: (member: unknown, id: unknown) => member || { + _id: id, username: 'Deleted user', profilePicture: null, isBot: false, + }, +}); diff --git a/backend/services/syntheticAccountPurgeService.ts b/backend/services/syntheticAccountPurgeService.ts index 444e9a09c4..aa48d98ef6 100644 --- a/backend/services/syntheticAccountPurgeService.ts +++ b/backend/services/syntheticAccountPurgeService.ts @@ -1,3 +1,4 @@ +import { DM_POD_TYPES_GUARD } from './agentIdentityService'; import { withAdminUserMutationLock } from './adminUserMutationService'; /* * Purge the throwaway accounts the stranger journey signs up (TASK-260). @@ -12,7 +13,7 @@ import { withAdminUserMutationLock } from './adminUserMutationService'; * * per synthetic user H (all three must hold: username pattern, email domain, * not a bot) and older than minAgeMs: - * pods created by H → deletePodCascade + the pod-keyed rows it does not + * disposable pods by H → deletePodCascade + the pod-keyed rows it does not * cover (tasks, agent events/runs, approvals, * attention items, first-message and silence marks, * PG message claims) @@ -32,6 +33,9 @@ import { withAdminUserMutationLock } from './adminUserMutationService'; * - no one else has an installation of that name; * - its bot user, if any, was created at or after H (an account cannot own * an identity older than itself). + * DMs with participants outside this disposable footprint retain their historical + * pair and data. A disposable bot used by another DM or a Scout installed by + * another owner refuses the account purge. Shared-pod membership still removes H. * Memory is deleted only for the seat's own (name, 'default') instance. Names * are not yet owned by an identity (TASK-270); once they are, the seat test * becomes "the seat's owner is H". Dry run is the default. @@ -47,6 +51,7 @@ const PodMemberFirstMessage = require('../models/PodMemberFirstMessage'); const OnboardingSilenceEpisode = require('../models/OnboardingSilenceEpisode'); const AgentMemory = require('../models/AgentMemory'); const Summary = require('../models/Summary'); +const AgentCredential = require('../models/AgentCredential'); const { AgentRegistry, AgentInstallation } = require('../models/AgentRegistry'); const { perUserInstanceId } = require('./personaHireService'); const { deletePodCascade } = require('./podDeletionService'); @@ -69,6 +74,8 @@ export interface AccountReport { refused?: string; pods: string[]; scoutBot: string | null; + /** Historical DMs retained for a participant outside the disposable footprint. */ + preservedDms: string[]; seats: string[]; /** Seats left alone, and why. */ skippedSeats: { name: string; reason: string }[]; @@ -168,7 +175,11 @@ const purgeSyntheticAccountsUnlocked = async ( const ownIds = new Set([humanId, scoutBot && String(scoutBot._id), ...seatBots.map((b: any) => String(b._id))] .filter(Boolean) as string[]); - const pods = await Pod.find({ createdBy: human._id }).select('_id members').lean(); + const createdPods = await Pod.find({ createdBy: human._id }).select('_id type members').lean(); + const preservedDms = createdPods.filter((pod: any) => DM_POD_TYPES_GUARD.has(String(pod.type)) + && (pod.members || []).some((member: unknown) => !ownIds.has(String(member)))); + const preservedIds = new Set(preservedDms.map((pod: any) => String(pod._id))); + const pods = createdPods.filter((pod: any) => !preservedIds.has(String(pod._id))); const account: AccountReport = { userId: humanId, username: human.username, @@ -176,9 +187,28 @@ const purgeSyntheticAccountsUnlocked = async ( scoutBot: scoutBot ? scoutUsername : null, seats: seatNames, skippedSeats, + preservedDms: preservedDms.map((pod: any) => String(pod._id)), }; report.accounts.push(account); - const foreignIds = pods.flatMap((p: any) => (p.members || []).map(String)).filter((m: string) => !ownIds.has(m)); + // 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({ + agentName: 'scout', instanceId: perUserInstanceId(humanId), installedBy: { $ne: human._id }, + })) { + account.refused = 'the disposable Scout is installed by another user'; + continue; + } + const disposableBotIds = [scoutBot?._id, ...seatBots.map((bot: any) => bot._id)].filter(Boolean); + const botDms = disposableBotIds.length ? await Pod.find({ + type: { $in: [...DM_POD_TYPES_GUARD] }, members: { $in: disposableBotIds }, + }).select('createdBy members').lean() : []; + if (botDms.some((pod: any) => String(pod.createdBy) !== humanId + || (pod.members || []).some((member: unknown) => !ownIds.has(String(member))))) { + account.refused = 'a disposable agent participates in a DM outside the synthetic footprint'; + continue; + } + const foreignIds = createdPods.flatMap((p: any) => (p.members || []).map(String)) + .filter((member: string) => !ownIds.has(member)); const foreignHuman = foreignIds.length ? await User.findOne({ _id: { $in: foreignIds }, isBot: { $ne: true } }).select('_id').lean() : null; @@ -228,13 +258,31 @@ const purgeSyntheticAccountsUnlocked = async ( await remove('onboardingsilenceepisodes', OnboardingSilenceEpisode, { userId: human._id }); await remove('podmemberfirstmessages', PodMemberFirstMessage, { userId: human._id }); if (apply) { + const credentials = await AgentCredential.find({ ownerUserId: human._id }).select('_id').lean(); beginWrite(); - const res = await Pod.updateMany({ members: human._id }, { $pull: { members: human._id } }); + await AgentCredential.revokeCascade(credentials.map((credential: any) => credential._id)); + const paused = await AgentInstallation.updateMany( + { installedBy: human._id, status: 'active' }, { $set: { status: 'paused' } }, + ); + tally('agentinstallations.paused', paused.modifiedCount); + const res = await Pod.updateMany( + { members: human._id, type: { $nin: [...DM_POD_TYPES_GUARD] } }, + { $pull: { members: human._id } }, + ); tally('pod.members', res.modifiedCount); } else { - tally('pod.members', await Pod.countDocuments({ members: human._id, createdBy: { $ne: human._id } })); + tally('agentinstallations.paused', await AgentInstallation.countDocuments({ + installedBy: human._id, status: 'active', podId: { $nin: pods.map((pod: any) => pod._id) }, + })); + tally('pod.members', await Pod.countDocuments({ + members: human._id, createdBy: { $ne: human._id }, type: { $nin: [...DM_POD_TYPES_GUARD] }, + })); } - await pgRemove('pg.pod_members', 'DELETE FROM pod_members WHERE user_id = $1', [humanId]); + const dmTypes = [...DM_POD_TYPES_GUARD]; + const dmParams = dmTypes.map((_type, index) => `$${index + 2}`).join(', '); + await pgRemove('pg.pod_members', + `DELETE FROM pod_members WHERE user_id = $1 + AND pod_id NOT IN (SELECT id FROM pods WHERE type IN (${dmParams}))`, [humanId, ...dmTypes]); await pgRemove('pg.users', 'DELETE FROM users WHERE _id = $1', [humanId]); await remove('users', User, { _id: human._id }); } diff --git a/docs/runbooks/alerting.md b/docs/runbooks/alerting.md index 27666a251e..be9af4271e 100644 --- a/docs/runbooks/alerting.md +++ b/docs/runbooks/alerting.md @@ -300,6 +300,28 @@ as uid 65534 with a read-only root filesystem and no capabilities. The `/register` limit is 10 per hour per client IP, and the cluster egress IP is not shared with anyone's laptop. +### Synthetic purge and historical DMs (TASK-005) + +The purge retains both historical member IDs in Mongo and PostgreSQL for +`agent-room` and `agent-dm`. Ordinary shared pods lose the synthetic member. +A synthetic-created DM with a participant outside the disposable footprint is +reported in `preservedDms` and retained with its messages and installations. +Only active installations owned by the purged human pause; their recorded credential +ledger roots and descendants are revoked. Unrelated other-owner keys and installations, +and independent agent identities and memory, remain. The pod/member projection +and runtime context retain an opaque missing-user ID with a `Deleted user` label. +This uses the same read projection as account deletion (#2721). + +A DM wholly within the disposable footprint can still be cascaded. If a disposable +Scout or seat bot is used in a DM outside that footprint, or the Scout is +installed by another owner, the whole account is +refused rather than deleting that identity. The existing refusal for a +synthetic-created pod another human joined remains. Inspect `refused` and +`preservedDms` in the dry-run report; do not force a refused candidate through. +The global administrator mutation lock and uncertain-write recovery rules still +apply ([runbook](admin-user-mutation-lock.md)). This change does not add a durable +late-producer fence to synthetic purge or implement receipt retirement. + ### User spend "One user's agent spend passed the daily line" (WARNING) fires when a user's From 269a15e22038837f092c41918c882f99f2e0bdc2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:44:50 -0700 Subject: [PATCH 2/4] Rate-limit lifecycle writes exposed by purge DM read projection CodeQL on c48ac2a flags three real unbounded pod create/leave/member-removal handlers. Bring the focused lifecycle budget from #2721: shared60/min per-presented-token limiter before auth, retire the three unlimited baseline rows, retain authorization and other-token isolation. No suppression. Validation: route, pre-auth guard and all-limiter census31/31; source typecheck/lint/diffcheck pass. Fresh CI required; no formal gate engaged before this content move. Written by Sprint Impl, a Commonly agent https://commonly.me/v2/pods/6addd4d47f8fc798a6f66bbb#message-87371 Co-authored-by: sprint-impl Agent-Model: codex/gpt-6.1-sol/medium --- backend/__tests__/unit/routes/pods.test.js | 15 +++++++++++++++ .../routes/routeRateLimitGuard.baseline.json | 3 --- backend/routes/pods.ts | 16 +++++++++++++--- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/backend/__tests__/unit/routes/pods.test.js b/backend/__tests__/unit/routes/pods.test.js index e1d455c982..fac3a84fa8 100644 --- a/backend/__tests__/unit/routes/pods.test.js +++ b/backend/__tests__/unit/routes/pods.test.js @@ -58,6 +58,21 @@ describe('pods routes', () => { expect(limited.body.msg).toMatch(/20 pod joins per 60s/); }); + it.each([ + ['post', '/api/pods', 201, 'createPod'], + ['post', '/api/pods/123/leave', 200, 'leavePod'], + ['delete', '/api/pods/123/members/456', 200, 'removeMember'], + ])('bounds lifecycle writes on %s %s without consuming another token’s budget', async (method, url, status, handler) => { + const token = `Bearer lifecycle-${handler}`; + const callsBefore = controllers[handler].mock.calls.length; + for (let i = 0; i < 60; i += 1) { + await request(app)[method](url).set('Authorization', token).expect(status); + } + await request(app)[method](url).set('Authorization', token).expect(429); + expect(controllers[handler].mock.calls.length - callsBefore).toBe(60); + await request(app)[method](url).set('Authorization', `${token}-other`).expect(status); + }); + it('DELETE /api/pods/123/members/456 calls removeMember', async () => { await request(app).delete('/api/pods/123/members/456').expect(200); expect(controllers.removeMember).toHaveBeenCalled(); diff --git a/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json b/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json index e137c4cfe4..c1228de736 100644 --- a/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json +++ b/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json @@ -99,7 +99,6 @@ "routes/pg-status.ts GET / [unlimited]", "routes/pg-status.ts POST /sync-user [unlimited]", "routes/pods.ts DELETE /:id [unlimited]", - "routes/pods.ts DELETE /:id/members/:memberId [unlimited]", "routes/pods.ts DELETE /announcement/:id [unlimited]", "routes/pods.ts DELETE /external-link/:id [unlimited]", "routes/pods.ts GET /:id/children [unlimited]", @@ -112,8 +111,6 @@ "routes/pods.ts GET /:podId/files [unlimited]", "routes/pods.ts GET /:type/:id [unlimited]", "routes/pods.ts GET /external-link/:linkId/qrcode [unlimited]", - "routes/pods.ts POST / [unlimited]", - "routes/pods.ts POST /:id/leave [unlimited]", "routes/pods.ts POST /announcement [unlimited]", "routes/registry/admin.ts DELETE /admin/installations/:installationId [unlimited]", "routes/registry/admin.ts DELETE /admin/installations/:installationId/runtime-tokens/:tokenId [unlimited]", diff --git a/backend/routes/pods.ts b/backend/routes/pods.ts index 3d57690fe0..8a65fa6c52 100644 --- a/backend/routes/pods.ts +++ b/backend/routes/pods.ts @@ -120,8 +120,18 @@ const podListingRateLimit = rateLimit({ legacyHeaders: false, keyGenerator: podJoinRateLimitKey, }); +// Bound lifecycle writes before authentication. Share a per-presented-token +// budget across create, leave and member removal, matching the existing pod +// ingress key policy; authorization still decides which writes are allowed. +const podLifecycleRateLimit = rateLimit({ + windowMs: 60_000, + limit: 60, + standardHeaders: 'draft-7', + legacyHeaders: false, + keyGenerator: podJoinRateLimitKey, +}); router.get('/', podListingRateLimit, auth, getAllPods); -router.post('/', auth, createPod); +router.post('/', podLifecycleRateLimit, auth, createPod); router.post('/announcement', auth, async (req: AuthReq, res: Res) => { try { @@ -406,8 +416,8 @@ router.post('/:id/join', rateLimit({ keyGenerator: podJoinRateLimitKey, handler: (_req: any, res: any) => res.status(429).json({ msg: 'rate limit exceeded: 20 pod joins per 60s' }), }), auth, joinPod); -router.post('/:id/leave', auth, leavePod); -router.delete('/:id/members/:memberId', auth, removeMember); +router.post('/:id/leave', podLifecycleRateLimit, auth, leavePod); +router.delete('/:id/members/:memberId', podLifecycleRateLimit, auth, removeMember); // Pod admin actions hit Mongo on every call but don't need much volume. // Capping per-IP keeps a stuck client from re-pinging contacts at full From befa428a478f289d28d443bf778b1e8d41ab5e79 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:06:12 -0700 Subject: [PATCH 3/4] Bound rotating credentials before pod lifecycle authentication Add a shared 6000/minute canonical client-IP ingress budget before the existing shared 60/minute presented-token lifecycle budget. Pin the combined budget across all three routes and prove rotating invalid credentials stop before auth while a second client IP remains independent. CodeRabbit discussion_r4372831952 / discussion_r4372831955 reproduced: independent-route limiter mutant fails the shared-budget check; old source lets rotating credentials reach auth after 6000 requests. Targeted 39/39, source tsc and TS lint pass. Broader 44/45 includes an unrelated census failure from 404 provider-relay responses on three runtime probes, recorded without calling that aggregate green. Stores remain process-local and the canonical IP trust assumptions still apply. Written by Sprint Impl, a Commonly agent https://commonly.me/v2/pods/6addd4d47f8fc798a6f66bbb#message-87371 Co-authored-by: sprint-impl Agent-Model: codex/gpt-6.1-sol/medium --- backend/__tests__/unit/routes/pods.test.js | 52 +++++++++++++++++++++- backend/routes/pods.ts | 17 +++++-- docs/runbooks/alerting.md | 9 ++++ 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/unit/routes/pods.test.js b/backend/__tests__/unit/routes/pods.test.js index fac3a84fa8..56ce8520c3 100644 --- a/backend/__tests__/unit/routes/pods.test.js +++ b/backend/__tests__/unit/routes/pods.test.js @@ -12,7 +12,10 @@ jest.mock('../../../controllers/podController', () => ({ deletePod: jest.fn((req, res) => res.status(200).end()), })); -jest.mock('../../../middleware/auth', () => (req, res, next) => next()); +jest.mock('../../../middleware/auth', () => jest.fn((req, res, next) => { + if (req.get('authorization')?.startsWith('Bearer invalid-')) return res.status(401).end(); + return next(); +})); const routes = require('../../../routes/pods'); const controllers = require('../../../controllers/podController'); @@ -73,6 +76,53 @@ describe('pods routes', () => { await request(app)[method](url).set('Authorization', `${token}-other`).expect(status); }); + it('shares one lifecycle budget across create, leave and member removal', async () => { + const token = 'Bearer lifecycle-shared'; + const cases = [ + ['post', '/api/pods', 201, 'createPod'], + ['post', '/api/pods/123/leave', 200, 'leavePod'], + ['delete', '/api/pods/123/members/456', 200, 'removeMember'], + ]; + for (const [method, url, status, handler] of cases) { + const before = controllers[handler].mock.calls.length; + for (let i = 0; i < 20; i += 1) { + await request(app)[method](url).set('Authorization', token).expect(status); + } + expect(controllers[handler].mock.calls.length - before).toBe(20); + } + for (const [method, url, , handler] of cases) { + const before = controllers[handler].mock.calls.length; + await request(app)[method](url).set('Authorization', token).expect(429); + expect(controllers[handler].mock.calls.length).toBe(before); + } + }); + + it('bounds rotating invalid credentials before auth while separating client IPs', async () => { + const server = app.listen(0); + try { + const auth = require('../../../middleware/auth'); + const urls = ['/api/pods', '/api/pods/123/leave']; + for (let i = 0; i < 6000; i += 1) { + await request(server).post(urls[i % urls.length]) + .set('cf-connecting-ip', '198.51.100.90') + .set('Authorization', `Bearer invalid-${i}`).expect((res) => { + if (res.status !== 401) throw new Error(`iteration ${i}: ${res.status} ${res.text}`); + }); + } + const before = auth.mock.calls.length; + await request(server).delete('/api/pods/123/members/456') + .set('cf-connecting-ip', '198.51.100.90') + .set('Authorization', 'Bearer invalid-over-budget').expect(429); + expect(auth.mock.calls.length).toBe(before); + await request(server).post('/api/pods') + .set('cf-connecting-ip', '203.0.113.90') + .set('Authorization', 'Bearer invalid-other-client').expect(401); + expect(auth.mock.calls.length).toBe(before + 1); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }, 60000); + it('DELETE /api/pods/123/members/456 calls removeMember', async () => { await request(app).delete('/api/pods/123/members/456').expect(200); expect(controllers.removeMember).toHaveBeenCalled(); diff --git a/backend/routes/pods.ts b/backend/routes/pods.ts index 8a65fa6c52..c6be759714 100644 --- a/backend/routes/pods.ts +++ b/backend/routes/pods.ts @@ -120,6 +120,17 @@ const podListingRateLimit = rateLimit({ legacyHeaders: false, keyGenerator: podJoinRateLimitKey, }); +// Coarse ingress ceiling also counts rotating invalid credentials before they +// allocate token-store entries or reach authentication. Use the canonical edge +// IP key, not the reverse proxy address. Clients sharing a public IP share this +// broader budget; the 60/min token budget below remains their write isolation. +const podLifecycleIngressRateLimit = rateLimit({ + windowMs: 60_000, + limit: 6000, + standardHeaders: 'draft-7', + legacyHeaders: false, + keyGenerator: cloudflareIpRateLimitKeyGenerator, +}); // Bound lifecycle writes before authentication. Share a per-presented-token // budget across create, leave and member removal, matching the existing pod // ingress key policy; authorization still decides which writes are allowed. @@ -131,7 +142,7 @@ const podLifecycleRateLimit = rateLimit({ keyGenerator: podJoinRateLimitKey, }); router.get('/', podListingRateLimit, auth, getAllPods); -router.post('/', podLifecycleRateLimit, auth, createPod); +router.post('/', podLifecycleIngressRateLimit, podLifecycleRateLimit, auth, createPod); router.post('/announcement', auth, async (req: AuthReq, res: Res) => { try { @@ -416,8 +427,8 @@ router.post('/:id/join', rateLimit({ keyGenerator: podJoinRateLimitKey, handler: (_req: any, res: any) => res.status(429).json({ msg: 'rate limit exceeded: 20 pod joins per 60s' }), }), auth, joinPod); -router.post('/:id/leave', podLifecycleRateLimit, auth, leavePod); -router.delete('/:id/members/:memberId', podLifecycleRateLimit, auth, removeMember); +router.post('/:id/leave', podLifecycleIngressRateLimit, podLifecycleRateLimit, auth, leavePod); +router.delete('/:id/members/:memberId', podLifecycleIngressRateLimit, podLifecycleRateLimit, auth, removeMember); // Pod admin actions hit Mongo on every call but don't need much volume. // Capping per-IP keeps a stuck client from re-pinging contacts at full diff --git a/docs/runbooks/alerting.md b/docs/runbooks/alerting.md index be9af4271e..2e75a585dc 100644 --- a/docs/runbooks/alerting.md +++ b/docs/runbooks/alerting.md @@ -322,6 +322,15 @@ The global administrator mutation lock and uncertain-write recovery rules still apply ([runbook](admin-user-mutation-lock.md)). This change does not add a durable late-producer fence to synthetic purge or implement receipt retirement. +The overlapping pod lifecycle handlers (create, leave and member removal) share +60 requests/minute per presented token, preceded by a coarse 6,000/minute client-IP +budget. The latter bounds rotating invalid credentials before authentication and +token-store allocation. Both use process-local stores; this is not a cluster-wide +quota or a global bound across arbitrarily many IPs. Clients sharing a public IP +share the coarse budget. Client-IP attribution inherits the documented edge/tunnel +assumptions in `middleware/ipRateLimit.ts`; use that deployment's configured client +identity rather than treating the reverse proxy address as the external caller. + ### User spend "One user's agent spend passed the daily line" (WARNING) fires when a user's From 22d838a81fcde3f9195b6790d1aaec52f80f6eef Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:39:02 -0700 Subject: [PATCH 4/4] Refuse synthetic purge across outside-owner pod installations Rhea's HOLD on befa428a4 was correct: my owned-only claim omitted the pod-wide cascade. Old service source fails both dry-run/apply controls; apply removes the outside owner's installation. Refuse before cleanup when any pod selected for cascade contains an installation whose owner is outside ownIds, even when that owner is not a pod member. Default service and purge unit suites22/22; real Mongo7/PG16 minimal-schema service10/10; tsc, targeted TS lint and diffcheck pass. Existing human-owned installation cleanup remains covered. This is a snapshot refusal, not a concurrent-install fence. TASK-007's foreign-group bot dependency remains separate; shared deletePodCascade is unchanged. Written by Sprint Impl, a Commonly agent https://commonly.me/v2/pods/6addd4d47f8fc798a6f66bbb#message-87549 Co-authored-by: sprint-impl Agent-Model: codex/gpt-6.1-sol/medium --- .../service/syntheticPurgeDm.test.js | 27 +++++++++++++++++++ .../services/syntheticAccountPurgeService.ts | 17 +++++++++--- docs/runbooks/alerting.md | 6 +++-- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/backend/__tests__/service/syntheticPurgeDm.test.js b/backend/__tests__/service/syntheticPurgeDm.test.js index 376fba7c61..d03713ba4b 100644 --- a/backend/__tests__/service/syntheticPurgeDm.test.js +++ b/backend/__tests__/service/syntheticPurgeDm.test.js @@ -148,4 +148,31 @@ describe('Synthetic purge DM preservation', () => { expect((await AgentInstallation.findById(installation._id)).status).toBe('active'); }); + it.each([false, true])('refuses a disposable group with an outside-owner install (apply=%s)', async (apply) => { + const human = await insertUser({ username: 'eng-smoke-journey-1015124709', email: 'journey@ops.commonly.invalid', isBot: false }); + const other = await insertUser({ username: 'outside-owner', isBot: false }); + // The installer is intentionally not a member: the foreign-human refusal + // must not accidentally supply the protection being tested here. + const pod = { _id: id(), name: 'Synthetic group', type: 'standard', createdBy: human._id, members: [human._id] }; + await Pod.collection.insertOne(pod); + await pool.query('INSERT INTO pods VALUES ($1,$2)', [String(pod._id), pod.type]); + await pool.query('INSERT INTO pod_members VALUES ($1,$2)', [String(pod._id), String(human._id)]); + await Message.collection.insertOne({ podId: pod._id, userId: human._id, content: 'Retain on refusal' }); + await pool.query('INSERT INTO messages VALUES ($1)', [String(pod._id)]); + const installation = await AgentInstallation.create({ agentName: 'outside-agent', podId: pod._id, version: '1.0.0', installedBy: other._id }); + const credential = await AgentCredential.create({ tokenHash: 'outside-install-key', kind: 'runtime', ownerUserId: other._id }); + const report = await purgeSyntheticAccounts({ apply, now: NOW, pool }); + expect((await AgentInstallation.findById(installation._id))?.status).toBe('active'); + expect(report.accounts[0].refused).toMatch(/disposable pod has an installation owned outside/); + expect(await User.findById(human._id)).not.toBeNull(); + expect(await User.findById(other._id)).not.toBeNull(); + expect(await Pod.findById(pod._id)).not.toBeNull(); + expect(await Message.countDocuments({ podId: pod._id })).toBe(1); + expect((await AgentCredential.findById(credential._id)).status).toBe('active'); + expect((await pool.query('SELECT id FROM pods WHERE id=$1', [String(pod._id)])).rows).toHaveLength(1); + expect((await pool.query('SELECT user_id FROM pod_members WHERE pod_id=$1', [String(pod._id)])).rows).toHaveLength(1); + expect((await pool.query('SELECT pod_id FROM messages WHERE pod_id=$1', [String(pod._id)])).rows).toHaveLength(1); + expect(Object.values(report.counts).every((count) => count === 0)).toBe(true); + }); + }); diff --git a/backend/services/syntheticAccountPurgeService.ts b/backend/services/syntheticAccountPurgeService.ts index aa48d98ef6..32bd801461 100644 --- a/backend/services/syntheticAccountPurgeService.ts +++ b/backend/services/syntheticAccountPurgeService.ts @@ -8,8 +8,9 @@ import { withAdminUserMutationLock } from './adminUserMutationService'; * every deploy. Its own cleanup can only uninstall the seat and delete the pod: * there is no self-delete route, and the admin delete removes the Mongo user * row only. Everything else a signup leaves behind would otherwise accumulate - * as fake users. This removes exactly that footprint, and nothing that a real - * person could own: + * as fake users. Cleanup is limited by the footprint and dependency refusals + * below; these are snapshot checks, not a fence against concurrent installs. + * Foreign group dependencies of disposable bots remain tracked in TASK-007: * * per synthetic user H (all three must hold: username pattern, email domain, * not a bot) and older than minAgeMs: @@ -35,7 +36,9 @@ import { withAdminUserMutationLock } from './adminUserMutationService'; * an identity older than itself). * DMs with participants outside this disposable footprint retain their historical * pair and data. A disposable bot used by another DM or a Scout installed by - * another owner refuses the account purge. Shared-pod membership still removes H. + * another owner refuses the account purge. Any pod selected for cascade with an + * outside-footprint installation also refuses before cleanup. Shared-pod + * membership still removes H. * Memory is deleted only for the seat's own (name, 'default') instance. Names * are not yet owned by an identity (TASK-270); once they are, the seat test * becomes "the seat's owner is H". Dry run is the default. @@ -207,6 +210,14 @@ const purgeSyntheticAccountsUnlocked = async ( account.refused = 'a disposable agent participates in a DM outside the synthetic footprint'; continue; } + // Cascading a disposable pod removes every installation in it. Membership + // alone cannot prove ownership: an outside installer need not be a member. + if (pods.length && await AgentInstallation.exists({ + podId: { $in: pods.map((pod: any) => pod._id) }, installedBy: { $nin: [...ownIds] }, + })) { + account.refused = 'a disposable pod has an installation owned outside the synthetic footprint'; + continue; + } const foreignIds = createdPods.flatMap((p: any) => (p.members || []).map(String)) .filter((member: string) => !ownIds.has(member)); const foreignHuman = foreignIds.length diff --git a/docs/runbooks/alerting.md b/docs/runbooks/alerting.md index 2e75a585dc..d84a52ce90 100644 --- a/docs/runbooks/alerting.md +++ b/docs/runbooks/alerting.md @@ -315,8 +315,10 @@ This uses the same read projection as account deletion (#2721). A DM wholly within the disposable footprint can still be cascaded. If a disposable Scout or seat bot is used in a DM outside that footprint, or the Scout is installed by another owner, the whole account is -refused rather than deleting that identity. The existing refusal for a -synthetic-created pod another human joined remains. Inspect `refused` and +refused rather than deleting that identity. Any pod selected for cascade with an +installation owned outside the disposable footprint also refuses the whole account before cleanup, even when that installer +is not a member. The existing refusal for a synthetic-created pod another human +joined remains. Inspect `refused` and `preservedDms` in the dry-run report; do not force a refused candidate through. The global administrator mutation lock and uncertain-write recovery rules still apply ([runbook](admin-user-mutation-lock.md)). This change does not add a durable