Q135: remove the thread replay that only digested events and reconstructed nothing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
403c57785c
commit
f8f852c326
21 files changed
+17
-288
No files matched your search
@@ -1,36 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateAuditReplayInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
|
||||
interface AuditEventRow {
|
||||
id: string;
|
||||
org_id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
actor_type: string;
|
||||
actor_id: string | null;
|
||||
action: string;
|
||||
payload_json: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ChatThreadRow {
|
||||
id: string;
|
||||
root_message_id: string | null;
|
||||
}
|
||||
|
||||
interface ReplayEvent {
|
||||
id: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
actorType: string;
|
||||
actorId: string | null;
|
||||
action: string;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export class AuditService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
@@ -66,98 +36,4 @@ export class AuditService {
|
||||
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);
|
||||
const messageIds = this.threadMessageIds(input.orgId, input.threadId, thread.root_message_id);
|
||||
const events = this.threadEvents(input.orgId, input.threadId, messageIds);
|
||||
if (events.length === 0) throw new Error("Thread replay requires audit events.");
|
||||
const replay = {
|
||||
threadId: input.threadId,
|
||||
rootMessageId: thread.root_message_id,
|
||||
messageIds: [...messageIds].sort(),
|
||||
events,
|
||||
};
|
||||
const replayJson = JSON.stringify(replay);
|
||||
const eventDigest = createHash("sha256").update(replayJson).digest("hex");
|
||||
const id = createId("arp");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into audit_replay_runs(
|
||||
id, org_id, thread_id, event_count, event_digest, replay_json,
|
||||
created_by_employee_id, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.threadId, events.length, eventDigest, replayJson, input.createdByEmployeeId, now);
|
||||
this.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "audit_replay_run",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: input.createdByEmployeeId,
|
||||
action: "created",
|
||||
payload: { threadId: input.threadId, eventCount: events.length, eventDigest },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
private requireThread(orgId: string, threadId: string): ChatThreadRow {
|
||||
const thread = this.db.prepare("select id, root_message_id from chat_threads where id = ? and org_id = ?")
|
||||
.get(threadId, orgId) as ChatThreadRow | undefined;
|
||||
if (!thread) throw new Error("Chat thread was not found.");
|
||||
return thread;
|
||||
}
|
||||
|
||||
private requireEmployee(orgId: string, employeeId: string): void {
|
||||
const employee = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId);
|
||||
if (!employee) throw new Error("Audit replay employee was not found.");
|
||||
}
|
||||
|
||||
private threadMessageIds(orgId: string, threadId: string, rootMessageId: string | null): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
if (rootMessageId) ids.add(rootMessageId);
|
||||
const rows = this.db.prepare("select id from chat_messages where org_id = ? and thread_id = ? order by created_at asc")
|
||||
.all(orgId, threadId) as Array<{ id: string }>;
|
||||
for (const row of rows) ids.add(row.id);
|
||||
return ids;
|
||||
}
|
||||
|
||||
private threadEvents(orgId: string, threadId: string, messageIds: Set<string>): ReplayEvent[] {
|
||||
const rows = this.db.prepare("select * from audit_events where org_id = ? order by created_at asc, id asc")
|
||||
.all(orgId) as unknown as AuditEventRow[];
|
||||
return rows
|
||||
.filter((row) => eventMatchesThread(row, threadId, messageIds))
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
entityType: row.entity_type,
|
||||
entityId: row.entity_id,
|
||||
actorType: row.actor_type,
|
||||
actorId: row.actor_id,
|
||||
action: row.action,
|
||||
payload: parsePayload(row.payload_json),
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function eventMatchesThread(row: AuditEventRow, threadId: string, messageIds: Set<string>): boolean {
|
||||
if (row.entity_type === "audit_replay_run") return false;
|
||||
if (row.entity_type === "chat_thread" && row.entity_id === threadId) return true;
|
||||
if (row.entity_type === "chat_message" && messageIds.has(row.entity_id)) return true;
|
||||
const payload = parsePayload(row.payload_json);
|
||||
return stringField(payload, "threadId") === threadId
|
||||
|| stringField(payload, "sourceThreadId") === threadId
|
||||
|| messageIds.has(stringField(payload, "messageId"))
|
||||
|| messageIds.has(stringField(payload, "sourceMessageId"));
|
||||
}
|
||||
|
||||
function parsePayload(payloadJson: string): Record<string, unknown> {
|
||||
const parsed = JSON.parse(payloadJson) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("Audit event payload is invalid.");
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringField(payload: Record<string, unknown>, key: string): string {
|
||||
const value = payload[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
@@ -200,8 +200,6 @@ export class ComplianceService {
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update retention_freezes set created_by_employee_id = null where org_id = ? and created_by_employee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update audit_replay_runs set created_by_employee_id = null where org_id = ? and created_by_employee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update rollback_snapshots set created_by_employee_id = null where org_id = ? and created_by_employee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update migration_reconciliation_items set resolved_by_employee_id = null where org_id = ? and resolved_by_employee_id = ?")
|
||||
|
||||
@@ -70,7 +70,6 @@ const LEGACY_TABLES = [
|
||||
"legal_holds",
|
||||
"retention_freezes",
|
||||
"search_documents",
|
||||
"audit_replay_runs",
|
||||
"rollback_snapshots",
|
||||
"migration_cutover_events",
|
||||
"migration_reconciliation_items",
|
||||
@@ -239,9 +238,6 @@ const RELATION_CHECKS: RelationCheck[] = [
|
||||
{ table: "legal_holds", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "retention_freezes", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "retention_freezes", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "audit_replay_runs", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "audit_replay_runs", column: "thread_id", referencedTable: "chat_threads", referencedColumn: "id", reason: "dangling_thread_id" },
|
||||
{ table: "audit_replay_runs", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "rollback_snapshots", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "rollback_snapshots", column: "migration_report_id", referencedTable: "migration_reports", referencedColumn: "id", reason: "dangling_migration_report_id" },
|
||||
{ table: "rollback_snapshots", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
|
||||
@@ -156,7 +156,6 @@ export class SnapshotService {
|
||||
featureFlags,
|
||||
sloTargets: list(this.db, "slo_targets", "signal_key asc"),
|
||||
operationalEvents,
|
||||
auditReplayRuns: list(this.db, "audit_replay_runs", "created_at desc"),
|
||||
auditEvents: recentList<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc", 200),
|
||||
documents: list(this.db, "documents", "updated_at desc"),
|
||||
knowledgeEntries: list(this.db, "knowledge_entries", "updated_at desc"),
|
||||
|
||||
Reference in new issue
Block a user