From 66de4ef015d89549e5cb575eb252d0fe197d32be 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 06:45:51 -0400 Subject: [PATCH] Q33: document edits and content deletions leave attributed audit records Co-Authored-By: Claude Opus 5.5 --- src/main/services/audit-service.ts | 8 ++ src/main/services/document-service.ts | 30 +++++++- src/main/services/knowledge-service.ts | 27 ++++++- .../src/pages/library/DocumentsPanel.tsx | 2 +- .../src/pages/library/KnowledgePanel.tsx | 2 +- src/shared/commands/collaboration.ts | 8 +- tests/unit/content-audit.test.ts | 76 +++++++++++++++++++ tests/unit/document-versions.test.ts | 7 +- tests/unit/knowledge-revisions.test.ts | 3 +- 9 files changed, 147 insertions(+), 16 deletions(-) create mode 100644 tests/unit/content-audit.test.ts diff --git a/src/main/services/audit-service.ts b/src/main/services/audit-service.ts index a8ba5276..c081f00f 100644 --- a/src/main/services/audit-service.ts +++ b/src/main/services/audit-service.ts @@ -59,6 +59,14 @@ export class AuditService { ); } + /** Content changes name a real member of the organization as the actor. */ + requireActor(orgId: string, actorType: "employee" | "agent", actorId: string): void { + const table = { employee: "employees", agent: "agents" }[actorType]; + if (!table) throw new Error(`Unknown actor type "${actorType}".`); + const actor = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`).get(actorId, orgId); + if (!actor) throw new Error(`No ${actorType} "${actorId}" in this organization.`); + } + createThreadReplay(input: CreateAuditReplayInput): string { const thread = this.requireThread(input.orgId, input.threadId); this.requireEmployee(input.orgId, input.createdByEmployeeId); diff --git a/src/main/services/document-service.ts b/src/main/services/document-service.ts index b4877ac8..b8393ba1 100644 --- a/src/main/services/document-service.ts +++ b/src/main/services/document-service.ts @@ -48,8 +48,9 @@ export class DocumentService { update(input: UpdateDocumentInput): number { const reason = input.reason.trim(); if (!reason) throw new Error("A document update needs a reason."); - const current = this.db.prepare("select version from documents where id = ? and org_id = ?") - .get(input.documentId, input.orgId) as { version: number } | undefined; + this.audit.requireActor(input.orgId, input.changedByType, input.changedById); + const current = this.db.prepare("select version, title, status from documents where id = ? and org_id = ?") + .get(input.documentId, input.orgId) as { version: number; title: string; status: string } | undefined; if (!current) throw new Error("Document was not found."); const version = current.version + 1; const now = new Date().toISOString(); @@ -60,6 +61,15 @@ export class DocumentService { orgId: input.orgId, documentId: input.documentId, version, title: input.title.trim(), content: input.content, status: input.status, changedByType: input.changedByType, changedById: input.changedById, reason, at: now, }); + this.audit.record({ + orgId: input.orgId, + entityType: "document", + entityId: input.documentId, + actorType: input.changedByType, + actorId: input.changedById, + action: "revised", + payload: { version, reason, title: input.title.trim(), status: input.status, previousTitle: current.title, previousStatus: current.status }, + }); return version; } @@ -71,8 +81,20 @@ export class DocumentService { } delete(input: DeleteDocumentInput): void { - const result = this.db.prepare("delete from documents where id = ? and org_id = ?").run(input.documentId, input.orgId); - if (result.changes === 0) throw new Error("Document was not found."); + this.audit.requireActor(input.orgId, "employee", input.deletedByEmployeeId); + const doc = this.db.prepare("select title, doc_type, status, version, content from documents where id = ? and org_id = ?") + .get(input.documentId, input.orgId) as { title: string; doc_type: string; status: string; version: number; content: string } | undefined; + if (!doc) throw new Error("Document was not found."); + this.db.prepare("delete from documents where id = ? and org_id = ?").run(input.documentId, input.orgId); + this.audit.record({ + orgId: input.orgId, + entityType: "document", + entityId: input.documentId, + actorType: "employee", + actorId: input.deletedByEmployeeId, + action: "deleted", + payload: { title: doc.title, docType: doc.doc_type, status: doc.status, version: doc.version, content: doc.content }, + }); } private writeVersion(input: { diff --git a/src/main/services/knowledge-service.ts b/src/main/services/knowledge-service.ts index f1e3e11d..d70e7d24 100644 --- a/src/main/services/knowledge-service.ts +++ b/src/main/services/knowledge-service.ts @@ -87,6 +87,7 @@ export class KnowledgeService { update(input: UpdateKnowledgeEntryInput): number { const reason = input.reason.trim(); if (!reason) throw new Error("A knowledge correction needs a reason."); + this.audit.requireActor(input.orgId, input.changedByType, input.changedById); const now = new Date().toISOString(); const result = this.db.prepare(` update knowledge_entries set topic = ?, category = ?, content = ?, importance = ?, updated_at = ? @@ -104,7 +105,7 @@ export class KnowledgeService { orgId: input.orgId, entityType: "knowledge_entry", entityId: input.entryId, - actorType: input.changedByType === "agent" ? "agent" : "employee", + actorType: input.changedByType, actorId: input.changedById, action: "revised", payload: { revision, reason }, @@ -120,8 +121,28 @@ export class KnowledgeService { } delete(input: DeleteKnowledgeEntryInput): void { - const result = this.db.prepare("delete from knowledge_entries where id = ? and org_id = ?").run(input.entryId, input.orgId); - if (result.changes === 0) throw new Error("Knowledge entry was not found."); + this.audit.requireActor(input.orgId, "employee", input.deletedByEmployeeId); + const entry = this.db.prepare(` + select topic, category, content, importance, scope_type, scope_id, + (select max(revision) from knowledge_revisions where entry_id = knowledge_entries.id) as revision + from knowledge_entries where id = ? and org_id = ? + `).get(input.entryId, input.orgId) as { + topic: string; category: string; content: string; importance: string; scope_type: string; scope_id: string | null; revision: number; + } | undefined; + if (!entry) throw new Error("Knowledge entry was not found."); + this.db.prepare("delete from knowledge_entries where id = ? and org_id = ?").run(input.entryId, input.orgId); + this.audit.record({ + orgId: input.orgId, + entityType: "knowledge_entry", + entityId: input.entryId, + actorType: "employee", + actorId: input.deletedByEmployeeId, + action: "deleted", + payload: { + topic: entry.topic, category: entry.category, content: entry.content, importance: entry.importance, + scopeType: entry.scope_type, scopeId: entry.scope_id, revision: entry.revision, + }, + }); } private writeRevision(input: { diff --git a/src/renderer/src/pages/library/DocumentsPanel.tsx b/src/renderer/src/pages/library/DocumentsPanel.tsx index 8c58c9b4..2327d56b 100644 --- a/src/renderer/src/pages/library/DocumentsPanel.tsx +++ b/src/renderer/src/pages/library/DocumentsPanel.tsx @@ -140,7 +140,7 @@ export function DocumentsPanel({ onClose={() => setConfirmDelete(false)} onSubmit={(event) => { event.preventDefault(); - void perform(() => actions.run(() => window.agentCompany.deleteDocument({ orgId, documentId: selected.id })), () => { + void perform(() => actions.run(() => window.agentCompany.deleteDocument({ orgId, documentId: selected.id, deletedByEmployeeId: employeeId })), () => { setConfirmDelete(false); setSelectedId(null); }); diff --git a/src/renderer/src/pages/library/KnowledgePanel.tsx b/src/renderer/src/pages/library/KnowledgePanel.tsx index 10af902d..c49c88f5 100644 --- a/src/renderer/src/pages/library/KnowledgePanel.tsx +++ b/src/renderer/src/pages/library/KnowledgePanel.tsx @@ -202,7 +202,7 @@ export function KnowledgePanel({ onClose={() => setDialog(null)} onSubmit={(event) => { event.preventDefault(); - perform(() => actions.run(() => window.agentCompany.deleteKnowledgeEntry({ orgId, entryId: selected.id })), () => { + perform(() => actions.run(() => window.agentCompany.deleteKnowledgeEntry({ orgId, entryId: selected.id, deletedByEmployeeId: employeeId })), () => { setDialog(null); setSelectedId(null); }); diff --git a/src/shared/commands/collaboration.ts b/src/shared/commands/collaboration.ts index 0a347387..f9a2ab83 100644 --- a/src/shared/commands/collaboration.ts +++ b/src/shared/commands/collaboration.ts @@ -19,12 +19,13 @@ export interface UpdateDocumentInput { /** Why it changed; kept with the new version. */ reason: string; changedByType: "employee" | "agent"; - changedById: string | null; + changedById: string; } export interface DeleteDocumentInput { orgId: string; documentId: string; + deletedByEmployeeId: string; } export type KnowledgeScopeType = "organization" | "project" | "goal" | "task" | "agent"; @@ -55,8 +56,8 @@ export interface UpdateKnowledgeEntryInput { importance: string; /** Why it changed; kept in the revision history. */ reason: string; - changedByType: string; - changedById: string | null; + changedByType: "employee" | "agent"; + changedById: string; } export interface DocumentVersionRecord { @@ -95,6 +96,7 @@ export interface ListKnowledgeRevisionsInput { export interface DeleteKnowledgeEntryInput { orgId: string; entryId: string; + deletedByEmployeeId: string; } export interface CreateAutomationRuleInput { diff --git a/tests/unit/content-audit.test.ts b/tests/unit/content-audit.test.ts new file mode 100644 index 00000000..ae7d3853 --- /dev/null +++ b/tests/unit/content-audit.test.ts @@ -0,0 +1,76 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { DocumentService } from "../../src/main/services/document-service"; +import { KnowledgeService } from "../../src/main/services/knowledge-service"; + +function setup() { + const connection = new DatabaseConnection(join(tmpdir(), `ac-audit-${randomUUID()}.db`)); + let orgId = ""; + connection.transaction(() => { + orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "A", employeeName: "Eve", mission: "m", goal: "g" }); + }); + const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + const events = (entityId: string) => connection.db.prepare( + "select actor_type, actor_id, action, payload_json from audit_events where entity_id = ? order by rowid", + ).all(entityId).map((row) => ({ ...row, payload_json: JSON.parse(String(row.payload_json)) })); + return { connection, orgId, employeeId, events }; +} + +describe("content audit", () => { + test("document updates and deletion record who changed what", () => { + const { connection, orgId, employeeId, events } = setup(); + const documents = new DocumentService(connection.db); + const id = documents.create({ orgId, title: "Spec", docType: "spec", content: "v1", authorAgentId: null, reviewerAgentId: null, projectId: null, goalId: null, taskId: null }); + documents.update({ orgId, documentId: id, title: "Spec v2", content: "v2", status: "review", reason: "Added API", changedByType: "employee", changedById: employeeId }); + documents.delete({ orgId, documentId: id, deletedByEmployeeId: employeeId }); + + expect(events(id).slice(1)).toEqual([ + { actor_type: "employee", actor_id: employeeId, action: "revised", payload_json: { version: 2, reason: "Added API", title: "Spec v2", status: "review", previousTitle: "Spec", previousStatus: "draft" } }, + { actor_type: "employee", actor_id: employeeId, action: "deleted", payload_json: { title: "Spec v2", docType: "spec", status: "review", version: 2, content: "v2" } }, + ]); + expect(() => documents.delete({ orgId, documentId: id, deletedByEmployeeId: employeeId })).toThrow("Document was not found."); + connection.close(); + }); + + test("knowledge deletion records the removed entry and who removed it", () => { + const { connection, orgId, employeeId, events } = setup(); + const knowledge = new KnowledgeService(connection.db); + const id = knowledge.create({ + orgId, topic: "Refunds", category: "policy", content: "14 days", importance: "high", + sourceType: "employee", sourceId: employeeId, scopeType: "organization", scopeId: null, validUntil: null, supersedesId: null, + }); + knowledge.delete({ orgId, entryId: id, deletedByEmployeeId: employeeId }); + + expect(events(id).at(-1)).toEqual({ + actor_type: "employee", actor_id: employeeId, action: "deleted", + payload_json: { topic: "Refunds", category: "policy", content: "14 days", importance: "high", scopeType: "organization", scopeId: null, revision: 1 }, + }); + connection.close(); + }); + + test("changes by someone outside the organization are refused", () => { + const { connection, orgId, employeeId } = setup(); + const documents = new DocumentService(connection.db); + const id = documents.create({ orgId, title: "Spec", docType: "spec", content: "v1", authorAgentId: null, reviewerAgentId: null, projectId: null, goalId: null, taskId: null }); + expect(() => documents.update({ orgId, documentId: id, title: "Spec", content: "v2", status: "draft", reason: "r", changedByType: "employee", changedById: "emp_stranger" })) + .toThrow('No employee "emp_stranger" in this organization.'); + const knowledge = new KnowledgeService(connection.db); + const entryId = knowledge.create({ + orgId, topic: "T", category: "fact", content: "c", importance: "normal", + sourceType: "employee", sourceId: null, scopeType: "organization", scopeId: null, validUntil: null, supersedesId: null, + }); + expect(() => knowledge.update({ orgId, entryId, topic: "T", category: "fact", content: "c2", importance: "normal", reason: "r", changedByType: "agent", changedById: "agt_stranger" })) + .toThrow('No agent "agt_stranger" in this organization.'); + const badType = { orgId, documentId: id, title: "Spec", content: "v2", status: "draft", reason: "r", changedByType: "system" as "agent", changedById: employeeId }; + expect(() => documents.update(badType)).toThrow('Unknown actor type "system".'); + expect(documents.versions(orgId, id)).toHaveLength(1); + expect(knowledge.revisions(orgId, entryId)).toHaveLength(1); + expect(() => documents.delete({ orgId, documentId: id, deletedByEmployeeId: "emp_stranger" })).toThrow('No employee "emp_stranger" in this organization.'); + expect(connection.db.prepare("select count(*) as n from documents where id = ?").get(id)).toEqual({ n: 1 }); + connection.close(); + }); +}); diff --git a/tests/unit/document-versions.test.ts b/tests/unit/document-versions.test.ts index 069fe7b1..908a613f 100644 --- a/tests/unit/document-versions.test.ts +++ b/tests/unit/document-versions.test.ts @@ -16,16 +16,17 @@ describe("document versions", () => { orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "V", employeeName: "Y", mission: "m", goal: "g" }); }); const documents = new DocumentService(connection.db); + const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; const id = documents.create({ orgId, title: "Spec", docType: "spec", content: "v1 body", authorAgentId: null, reviewerAgentId: null, projectId: null, goalId: null, taskId: null }); - expect(documents.update({ orgId, documentId: id, title: "Spec", content: "v2 body", status: "review", reason: "Added API", changedByType: "employee", changedById: null })).toBe(2); - expect(() => documents.update({ orgId, documentId: id, title: "Spec", content: "v3", status: "review", reason: " ", changedByType: "employee", changedById: null })) + expect(documents.update({ orgId, documentId: id, title: "Spec", content: "v2 body", status: "review", reason: "Added API", changedByType: "employee", changedById: employeeId })).toBe(2); + expect(() => documents.update({ orgId, documentId: id, title: "Spec", content: "v3", status: "review", reason: " ", changedByType: "employee", changedById: employeeId })) .toThrow("A document update needs a reason."); expect(documents.versions(orgId, id).map((v) => [v.version, v.content, v.status, v.reason])).toEqual([ [1, "v1 body", "draft", "created"], [2, "v2 body", "review", "Added API"], ]); expect(connection.db.prepare("select version, content from documents where id = ?").get(id)).toEqual({ version: 2, content: "v2 body" }); - documents.delete({ orgId, documentId: id }); + documents.delete({ orgId, documentId: id, deletedByEmployeeId: employeeId }); expect(documents.versions(orgId, id)).toHaveLength(2); connection.close(); }); diff --git a/tests/unit/knowledge-revisions.test.ts b/tests/unit/knowledge-revisions.test.ts index af495b41..b840f797 100644 --- a/tests/unit/knowledge-revisions.test.ts +++ b/tests/unit/knowledge-revisions.test.ts @@ -39,7 +39,8 @@ describe("knowledge revisions", () => { expect(run("revise_knowledge", { id, content: "x" })).toBe('ERROR: "reason" is required.'); expect(run("revise_knowledge", { id: "kne_missing", reason: "r" })).toBe('ERROR: No knowledge entry with id "kne_missing".'); - new KnowledgeService(connection.db).delete({ orgId, entryId: id }); + const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + new KnowledgeService(connection.db).delete({ orgId, entryId: id, deletedByEmployeeId: employeeId }); expect(run("search_knowledge", { id })).toContain(`Knowledge entry ${id} (deleted), 2 revision(s):`); connection.close(); });