feat(iios): retention_policy_snapshot + sweep (archive→redact, hold-aware) (P9)
Adds IiosRetentionPolicySnapshot (per-interaction frozen lifecycle timestamps) + IiosInteraction.dataClass, and a RetentionService whose sweep captures snapshots then archives (status change) and, past delete_after, redacts content in place (reusing the Slice-8 tombstone) — never a hard delete. An active compliance hold blocks both; every action is audited and tenant-scopeable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { recordAudit } from '../observability/audit';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
export interface SweepResult {
|
||||
archived: number;
|
||||
redacted: number;
|
||||
skippedHeld: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data retention (P9). Each interaction gets a per-resource retention snapshot with
|
||||
* frozen archive/delete timestamps; a scheduled sweep archives (status change) then
|
||||
* deletes = redacts-in-place (reusing the DSR tombstone semantics) once a resource ages
|
||||
* past its window. An active compliance hold blocks both. Never a hard delete.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RetentionService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(RetentionService.name);
|
||||
private timer?: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const ms = Number(process.env.IIOS_RETENTION_SWEEP_INTERVAL_MS ?? 0);
|
||||
if (ms > 0) {
|
||||
this.timer = setInterval(() => {
|
||||
void this.sweep().catch((err) => this.logger.warn(`retention sweep failed: ${(err as Error).message}`));
|
||||
}, ms);
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
|
||||
private windowDays(dataClass: string, kind: 'ARCHIVE' | 'DELETE'): number {
|
||||
const cls = process.env[`IIOS_RETENTION_${kind}_DAYS_${dataClass.toUpperCase()}`];
|
||||
const glob = process.env[`IIOS_RETENTION_${kind}_DAYS`];
|
||||
return Number(cls ?? glob ?? (kind === 'ARCHIVE' ? 90 : 365));
|
||||
}
|
||||
|
||||
/** Lazily capture a per-resource snapshot for any interaction that lacks one. */
|
||||
async ensureSnapshots(scopeId?: string): Promise<number> {
|
||||
const interactions = await this.prisma.iiosInteraction.findMany({
|
||||
where: scopeId ? { scopeId } : {},
|
||||
select: { id: true, scopeId: true, dataClass: true, receivedAt: true },
|
||||
});
|
||||
let created = 0;
|
||||
for (const i of interactions) {
|
||||
const exists = await this.prisma.iiosRetentionPolicySnapshot.findUnique({
|
||||
where: { targetType_targetId: { targetType: 'interaction', targetId: i.id } },
|
||||
});
|
||||
if (exists) continue;
|
||||
const base = i.receivedAt.getTime();
|
||||
await this.prisma.iiosRetentionPolicySnapshot.create({
|
||||
data: {
|
||||
policyKey: `default:${i.dataClass}:v1`,
|
||||
scopeSnapshotId: i.scopeId,
|
||||
targetType: 'interaction',
|
||||
targetId: i.id,
|
||||
dataClass: i.dataClass,
|
||||
archiveAfter: new Date(base + this.windowDays(i.dataClass, 'ARCHIVE') * DAY_MS),
|
||||
deleteAfter: new Date(base + this.windowDays(i.dataClass, 'DELETE') * DAY_MS),
|
||||
sourceVersion: process.env.IIOS_RETENTION_POLICY_VERSION ?? 'v1',
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Act on due snapshots: archive, or delete (redact-in-place), honoring compliance holds. */
|
||||
async applySweep(scopeId?: string): Promise<SweepResult> {
|
||||
const now = new Date();
|
||||
const due = await this.prisma.iiosRetentionPolicySnapshot.findMany({
|
||||
where: { status: { in: ['ACTIVE', 'ARCHIVED'] }, archiveAfter: { lte: now }, ...(scopeId ? { scopeSnapshotId: scopeId } : {}) },
|
||||
});
|
||||
const result: SweepResult = { archived: 0, redacted: 0, skippedHeld: 0 };
|
||||
|
||||
for (const s of due) {
|
||||
const held = await this.prisma.iiosComplianceHold.findFirst({
|
||||
where: { targetId: s.targetId, status: 'ACTIVE', OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
|
||||
});
|
||||
if (held) {
|
||||
result.skippedHeld++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s.deleteAfter <= now) {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.iiosMessagePart.updateMany({ where: { interactionId: s.targetId }, data: { bodyText: '[redacted]', contentRef: null } }),
|
||||
this.prisma.iiosInboundRawEvent.updateMany({ where: { interactionId: s.targetId }, data: { payload: { redacted: true } } }),
|
||||
this.prisma.iiosInteraction.update({ where: { id: s.targetId }, data: { status: 'REDACTED' } }),
|
||||
this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }),
|
||||
]);
|
||||
await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
|
||||
result.redacted++;
|
||||
} else if (s.status === 'ACTIVE') {
|
||||
await this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'ARCHIVED' } });
|
||||
await recordAudit(this.prisma, { action: 'retention.archived', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
|
||||
result.archived++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Full sweep: capture missing snapshots, then act on due ones. */
|
||||
async sweep(scopeId?: string): Promise<SweepResult> {
|
||||
await this.ensureSnapshots(scopeId);
|
||||
return this.applySweep(scopeId);
|
||||
}
|
||||
|
||||
/** Snapshot lifecycle counts for /metrics (global ops). */
|
||||
async summary(): Promise<{ total: number; byStatus: Record<string, number> }> {
|
||||
const groups = await this.prisma.iiosRetentionPolicySnapshot.groupBy({ by: ['status'], _count: true });
|
||||
const byStatus: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const g of groups) {
|
||||
byStatus[g.status] = g._count;
|
||||
total += g._count;
|
||||
}
|
||||
return { total, byStatus };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user