From 1f484df8940c9eaf1eabe23dafd28d06212f3b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 20:42:23 -0400 Subject: [PATCH] Q127: erasure removes the subject's content and keeps only a receipt Co-Authored-By: Claude Opus 5.5 --- .../migrations/090-erasure-receipts.ts | 23 +++++++++ src/main/database/schema.ts | 2 + src/main/services/compliance-service.ts | 50 ++++++++++++++----- tests/unit/compliance-service.test.ts | 41 +++++++++++++++ tests/unit/domain-services.test.ts | 2 +- 5 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 src/main/database/migrations/090-erasure-receipts.ts diff --git a/src/main/database/migrations/090-erasure-receipts.ts b/src/main/database/migrations/090-erasure-receipts.ts new file mode 100644 index 00000000..b0f51302 --- /dev/null +++ b/src/main/database/migrations/090-erasure-receipts.ts @@ -0,0 +1,23 @@ +// Deletion records made before receipts copied the erased data into themselves; that copy is removed here. +// Audit events stay immutable except for one change: an erasure may replace a payload with the redaction marker, nothing else. +export const erasureReceiptsSchema = ` + update compliance_exports + set payload_json = '{"erased":null,"note":"This record once held a copy of the erased data; it was removed."}' + where export_type = 'gdpr_delete'; + + drop trigger if exists trg_audit_events_no_update; + create trigger trg_audit_events_no_update + before update on audit_events + for each row + when not ( + new.payload_json is '{"redacted":true}' + and new.id is old.id and new.org_id is old.org_id and new.entity_type is old.entity_type and new.entity_id is old.entity_id + and new.actor_type is old.actor_type and new.actor_id is old.actor_id and new.action is old.action and new.created_at is old.created_at + ) + begin + select raise(abort, 'Audit events are immutable.'); + end; + + insert into schema_migrations(version, name, applied_at) + values (90, 'erasure_receipts', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 696fcd4a..ef166c96 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -88,6 +88,7 @@ import { heldDeliverablesSchema } from "./migrations/086-held-deliverables"; import { goalAuditorSchema } from "./migrations/087-goal-auditor"; import { hostAccessNoteSchema } from "./migrations/088-host-access-note"; import { fileVersionsSchema } from "./migrations/089-file-versions"; +import { erasureReceiptsSchema } from "./migrations/090-erasure-receipts"; interface Migration { version: number; @@ -185,6 +186,7 @@ const MIGRATIONS: Migration[] = [ { version: 87, name: "goal_auditor", sql: goalAuditorSchema }, { version: 88, name: "host_access_note", sql: hostAccessNoteSchema }, { version: 89, name: "file_versions", sql: fileVersionsSchema }, + { version: 90, name: "erasure_receipts", sql: erasureReceiptsSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/compliance-service.ts b/src/main/services/compliance-service.ts index 4e640fc5..b2b4dbfc 100644 --- a/src/main/services/compliance-service.ts +++ b/src/main/services/compliance-service.ts @@ -24,8 +24,11 @@ export class ComplianceService { this.assertEmployee(input.orgId, input.requestedByEmployeeId, "Requesting employee was not found."); this.assertSubject(input.orgId, input.subjectType, input.subjectId); if (input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE) this.assertDeleteAllowed(input); - const payload = this.subjectPayload(input.orgId, input.subjectType, input.subjectId); - if (input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE) this.applyGdprDelete(input.orgId, input.subjectType, input.subjectId); + const gathered = this.subjectPayload(input.orgId, input.subjectType, input.subjectId); + const erasing = input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE; + // A deletion keeps a receipt of what it erased, never a copy of it. + const payload = erasing ? erasureReceipt(gathered) : gathered; + if (erasing) this.applyGdprDelete(input.orgId, input.subjectType, input.subjectId); const requestedByEmployeeId = deletedEmployeeRequestor(input) ? null : input.requestedByEmployeeId; const id = createId("cmp"); const now = new Date().toISOString(); @@ -51,7 +54,7 @@ export class ComplianceService { entityType: "compliance_export", entityId: id, actorType: "employee", - actorId: input.requestedByEmployeeId, + actorId: requestedByEmployeeId, action: input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE ? "gdpr_deleted" : "created", payload: { subjectType: input.subjectType, subjectId: input.subjectId, exportType: input.exportType }, }); @@ -150,7 +153,7 @@ export class ComplianceService { if (subjectType === "employee") { return { profile: this.one("employees", orgId, subjectId), - chatMessages: this.db.prepare("select * from chat_messages where org_id = ? and sender_type = 'employee' and sender_id = ? order by created_at").all(orgId, subjectId), + chatMessages: this.db.prepare("select * from chat_messages where org_id = ? and sender_type in ('employee', 'board') and sender_id = ? order by created_at").all(orgId, subjectId), approvalsRequested: this.db.prepare("select * from approvals where org_id = ? and requested_by_type = 'employee' and requested_by_id = ? order by created_at").all(orgId, subjectId), approvalsDecided: this.db.prepare("select * from approvals where org_id = ? and decided_by_employee_id = ? order by created_at").all(orgId, subjectId), employeeIdentities: this.db.prepare("select * from employee_identities where org_id = ? and employee_id = ? order by created_at").all(orgId, subjectId), @@ -173,11 +176,10 @@ export class ComplianceService { } private applyGdprDelete(orgId: string, subjectType: string, subjectId: string): void { + this.eraseAuthoredContent(orgId, subjectType, subjectId); if (subjectType === "employee") { - this.db.prepare("update employees set display_name = ?, role = ? where id = ? and org_id = ?") + this.db.prepare("update employees set display_name = ?, role = ?, avatar_emoji = null, avatar_color = null, avatar_image = null where id = ? and org_id = ?") .run("Deleted employee", "Deleted", subjectId, orgId); - this.db.prepare("update chat_messages set sender_id = null where org_id = ? and sender_type = 'employee' and sender_id = ?") - .run(orgId, subjectId); this.db.prepare("update approvals set requested_by_id = null where org_id = ? and requested_by_type = 'employee' and requested_by_id = ?") .run(orgId, subjectId); this.db.prepare("update approvals set decided_by_employee_id = null where org_id = ? and decided_by_employee_id = ?") @@ -212,12 +214,27 @@ export class ComplianceService { .run(orgId, subjectId); return; } - this.db.prepare("update agents set name = ?, role = ?, lifecycle = ?, model_provider_id = null, model_id = null where id = ? and org_id = ?") + this.db.prepare("update agents set name = ?, role = ?, lifecycle = ?, model_provider_id = null, model_id = null, avatar_emoji = null, avatar_color = null, avatar_image = null where id = ? and org_id = ?") .run("Deleted agent", "Deleted", "archived", subjectId, orgId); this.db.prepare("update tasks set assignee_agent_id = null where org_id = ? and assignee_agent_id = ?").run(orgId, subjectId); this.db.prepare("update task_runs set agent_id = null where org_id = ? and agent_id = ?").run(orgId, subjectId); - this.db.prepare("update chat_messages set sender_id = null where org_id = ? and sender_type = 'agent' and sender_id = ?") - .run(orgId, subjectId); + } + + /** What the subject wrote and what the log says about them: message text, its attachment links and command arguments, and audit payloads. */ + private eraseAuthoredContent(orgId: string, subjectType: string, subjectId: string): void { + // An employee also speaks and acts as the board under the same id. + const roles = subjectType === "employee" ? ["employee", "board"] : [subjectType]; + const marks = roles.map(() => "?").join(", "); + const authored = `select id from chat_messages where org_id = ? and sender_type in (${marks}) and sender_id = ?`; + this.db.prepare(`delete from chat_attachments where message_id in (${authored})`).run(orgId, ...roles, subjectId); + this.db.prepare(`delete from chat_slash_invocations where message_id in (${authored})`).run(orgId, ...roles, subjectId); + // A thread is titled from its first message, so threads the subject started lose that title too. + this.db.prepare(`update chat_threads set title = '[deleted]' where org_id = ? and root_message_id in (${authored})`).run(orgId, orgId, ...roles, subjectId); + this.db.prepare(`update chat_messages set body_md = '[deleted]', body_structured_json = '{}', sender_id = null, deleted_at = ? where org_id = ? and sender_type in (${marks}) and sender_id = ?`) + .run(new Date().toISOString(), orgId, ...roles, subjectId); + // The events stay, so the log still shows what happened and when; only what they carried about the subject goes. + this.db.prepare(`update audit_events set payload_json = '{"redacted":true}' where org_id = ? and ((actor_type in (${marks}) and actor_id = ?) or entity_id = ?)`) + .run(orgId, ...roles, subjectId, subjectId); } private assertDeleteAllowed(input: CreateComplianceExportInput): void { @@ -233,7 +250,7 @@ export class ComplianceService { select 1 from chat_messages where org_id = ? and thread_id = legal_holds.entity_id - and sender_type = ? + and (sender_type = ? or (? = 'employee' and sender_type = 'board')) and sender_id = ? ) ) @@ -256,6 +273,7 @@ export class ComplianceService { input.orgId, input.orgId, input.subjectType, + input.subjectType, input.subjectId, input.orgId, input.subjectType, @@ -272,7 +290,7 @@ export class ComplianceService { select 1 from chat_messages where org_id = ? and space_id = retention_freezes.scope_id - and sender_type = ? + and (sender_type = ? or (? = 'employee' and sender_type = 'board')) and sender_id = ? ) ) @@ -293,6 +311,7 @@ export class ComplianceService { input.orgId, input.orgId, input.subjectType, + input.subjectType, input.subjectId, input.orgId, input.subjectType, @@ -353,3 +372,10 @@ function deletedEmployeeRequestor(input: CreateComplianceExportInput): boolean { && input.subjectType === "employee" && input.subjectId === input.requestedByEmployeeId; } + +/** A deletion's record: how much of each kind it erased, with no content. */ +function erasureReceipt(gathered: Record): { erased: Record } { + const erased: Record = {}; + for (const [kind, value] of Object.entries(gathered)) erased[kind] = Array.isArray(value) ? value.length : value ? 1 : 0; + return { erased }; +} diff --git a/tests/unit/compliance-service.test.ts b/tests/unit/compliance-service.test.ts index 8863bb3e..d58c0448 100644 --- a/tests/unit/compliance-service.test.ts +++ b/tests/unit/compliance-service.test.ts @@ -89,6 +89,7 @@ describe("compliance service", () => { employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" }); chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "employee", senderId: employeeId, bodyMd: "Delete me", attachmentFileIds: [] }); + chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "board", senderId: employeeId, bodyMd: "As the board", attachmentFileIds: [] }); approvalId = approvals.create({ orgId, kind: "security_review", @@ -170,6 +171,22 @@ describe("compliance service", () => { expect(state.complianceExports[0]?.exportType).toBe("gdpr_delete"); expect(state.complianceExports[0]?.requestedByEmployeeId).toBeNull(); expect(state.auditEvents.some((event) => event.entityType === "compliance_export" && event.action === "gdpr_deleted")).toBe(true); + // The erased content is gone, not moved: no message text, no copy in the deletion record, no personal detail in audit payloads. + expect(state.chatMessages.map((message) => [message.bodyMd, message.senderId])).toEqual([["[deleted]", null], ["[deleted]", null]]); + expect(state.chatMessages.every((message) => !JSON.stringify(message).includes("Delete me") && !JSON.stringify(message).includes("As the board"))).toBe(true); + expect(state.chatThreads.every((thread) => !thread.title.includes("Delete me") && !thread.title.includes("As the board"))).toBe(true); + const receipt = state.complianceExports.find((record) => record.exportType === "gdpr_delete")!.payloadJson; + expect(receipt).not.toContain("Dana Lee"); + expect(receipt).not.toContain("Delete me"); + expect(receipt).not.toContain("As the board"); + expect(JSON.parse(receipt).erased).toMatchObject({ chatMessages: 2 }); + const subjectEvents = state.auditEvents.filter((event) => (event.actorType === "employee" && event.actorId === employeeId) || event.entityId === employeeId); + expect(subjectEvents.length).toBeGreaterThan(0); + expect(subjectEvents.every((event) => event.payloadJson === '{"redacted":true}')).toBe(true); + // Redaction is the one change the log allows; anything else is still refused. + expect(() => connection.db.prepare("update audit_events set action = 'rewritten' where id = ?").run(subjectEvents[0].id)).toThrow("Audit events are immutable."); + expect(() => connection.db.prepare("update audit_events set payload_json = '{}' where id = ?").run(subjectEvents[0].id)).toThrow("Audit events are immutable."); + expect(() => connection.db.prepare("update audit_events set id = null, payload_json = '{\"redacted\":true}' where id = ?").run(subjectEvents[0].id)).toThrow("Audit events are immutable."); connection.close(); }); @@ -301,4 +318,28 @@ describe("compliance service", () => { }))).toThrow("Active compliance hold prevents GDPR delete."); connection.close(); }); + + test("a hold on a thread or channel where the employee spoke as the board blocks their deletion too", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-board-hold-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + const chat = new ChatService(connection.db); + const compliance = new ComplianceService(connection.db); + let orgId = ""; + let spaceId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana Lee", mission: "m", goal: "g" }); + spaceId = chat.createSpace({ orgId, spaceType: "project", name: "board-room", topic: "t", visibility: "workspace" }); + }); + const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + connection.transaction(() => chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "board", senderId: employeeId, bodyMd: "Board decision", attachmentFileIds: [] })); + const threadId = (connection.db.prepare("select thread_id from chat_messages where org_id = ?").get(orgId) as { thread_id: string }).thread_id; + const holdId = connection.transaction(() => compliance.createLegalHold({ orgId, entityType: "chat_thread", entityId: threadId, reason: "litigation", createdByEmployeeId: employeeId })); + const erase = () => connection.transaction(() => compliance.createExport({ orgId, subjectType: "employee", subjectId: employeeId, exportType: "gdpr_delete", requestedByEmployeeId: employeeId })); + expect(erase).toThrow("Active compliance hold prevents GDPR delete."); + connection.transaction(() => compliance.setLegalHoldState({ orgId, legalHoldId: holdId, active: false, employeeId })); + connection.transaction(() => compliance.createRetentionFreeze({ orgId, scopeType: "chat_space", scopeId: spaceId, reason: "audit", createdByEmployeeId: employeeId })); + expect(erase).toThrow("Active compliance hold prevents GDPR delete."); + expect(connection.db.prepare("select body_md from chat_messages where org_id = ?").get(orgId)).toEqual({ body_md: "Board decision" }); + connection.close(); + }); }); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index dc68e1ce..b5515754 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -35,7 +35,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90]); connection.close(); });