Skip to content

Commit 0707ebb

Browse files
fix(retention): purge deletes and forget redacts the raw capture (#65)
Purge left the document's raw_capture rows and forget left raw_capture.raw_text, so deleted text stayed stored. Purge now deletes the raw captures only the document's versions referenced, in the same transaction; forget redacts them.
1 parent 7d4fb55 commit 0707ebb

4 files changed

Lines changed: 84 additions & 26 deletions

File tree

‎README.md‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,15 @@ Other root exports: `MemoryError` and `RerankConfigError`, the `SEARCH_LIMIT_*`
7575

7676
Returns a `Hono<TenantEnv>` sub-app. Mount it at `/api/tenants/:tenantId/memory`, below the middleware that sets `principal` and `tenant` on the context (Interchange's `createResolveTenant` does). Identity always comes from the context, never from the request body.
7777

78-
| Route | Grant | Does |
79-
| ------------------------------------------- | --------------- | ---------------------------------------------------------------- |
80-
| `POST /add` | `memory:add` | Adds a document, or a new version of one. |
81-
| `POST /search` | `memory:search` | Hybrid search over the documents the caller can see. |
82-
| `GET /list` | `memory:search` | Recent documents the caller can see. |
83-
| `GET /feed` | `memory:search` | New versions after a `feed_seq` cursor, for consumers. |
84-
| `POST /documents/:documentId/forget` | `memory:forget` | Drops a document from search and redacts its text. Creator only. |
85-
| `POST /documents/:documentId/purge` | `memory:purge` | Deletes a document and its versions. Creator only. |
86-
| `POST /versions/:versionId/retention-class` | `memory:forget` | Sets a version's retention class. Creator only. |
78+
| Route | Grant | Does |
79+
| ------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------- |
80+
| `POST /add` | `memory:add` | Adds a document, or a new version of one. |
81+
| `POST /search` | `memory:search` | Hybrid search over the documents the caller can see. |
82+
| `GET /list` | `memory:search` | Recent documents the caller can see. |
83+
| `GET /feed` | `memory:search` | New versions after a `feed_seq` cursor, for consumers. |
84+
| `POST /documents/:documentId/forget` | `memory:forget` | Drops a document from search and redacts its text, including the raw capture. Creator only. |
85+
| `POST /documents/:documentId/purge` | `memory:purge` | Deletes a document, its versions and its raw captures. Creator only. |
86+
| `POST /versions/:versionId/retention-class` | `memory:forget` | Sets a version's retention class. Creator only. |
8787

8888
A missing principal answers `401` and a missing grant answers `403`. `callerResolver` is optional: it resolves a non-session caller to a `{ tenantId, principalId }` before the same grant checks run, and a malformed result answers `500`.
8989

‎docs/RETENTION.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,13 @@ Service module: `src/services/retention.ts`.
4444
**Tombstone vs. hard delete stay distinct verbs, distinct grants.** A UI
4545
offering "forget this" must never be one flag away from "shred this" by
4646
accident. `forget` (tombstone) is **not** an undo-able action: the document
47-
stops appearing in search/feed and its chunk text is overwritten with
48-
`[redacted]` — the original content does not survive, there is no
47+
stops appearing in search/feed, its chunk text is overwritten with
48+
`[redacted]`, and so is the text of any raw capture only its versions
49+
referenced — the original content does not survive, there is no
4950
un-tombstone/restore verb, and only version metadata (status, timestamps,
5051
retention class) remains for audit. `purge` (hard delete) goes further and
51-
removes the document row itself; it has its own grant action and is refused
52+
removes the document row itself, along with the raw captures only its
53+
versions referenced; it has its own grant action and is refused
5254
outright while a `durable`-class version on the document is untombstoned. The
5355
distinction that matters is _what's still queryable_: after `forget` a
5456
document row and its metadata still exist (for audit) but its content is

‎e2e/grants.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ describe.skipIf(testDatabaseUrl() === undefined)(
9696
const [row] = await db.sql<{ status: string }[]>`
9797
SELECT status FROM memory.version WHERE document_id = ${documentId}`;
9898
expect(row?.status).toBe("tombstoned");
99+
const raw = await db.sql<{ raw_text: string | null }[]>`
100+
SELECT r.raw_text FROM memory.raw_capture r
101+
JOIN memory.version v ON v.raw_capture_id = r.id
102+
WHERE v.document_id = ${documentId}`;
103+
expect(raw.length).toBeGreaterThan(0);
104+
for (const r of raw) expect(r.raw_text).not.toContain("Forget me");
99105
});
100106

101107
test("purge removes the document, its versions and its chunks", async () => {
@@ -118,6 +124,10 @@ describe.skipIf(testDatabaseUrl() === undefined)(
118124
(SELECT count(*)::int FROM memory.version WHERE document_id = ${documentId}) AS versions,
119125
(SELECT count(*)::int FROM memory.chunk WHERE document_id = ${documentId}) AS chunks`;
120126
expect(left).toEqual({ docs: 0, versions: 0, chunks: 0 });
127+
const [raw] = await db.sql<{ n: number }[]>`
128+
SELECT count(*)::int AS n FROM memory.raw_capture
129+
WHERE raw_text LIKE '%Purge me%'`;
130+
expect(raw?.n).toBe(0);
121131
});
122132

123133
test("legacy capture and find grants authorize neither add nor search", async () => {

‎src/services/retention.ts‎

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
import { and, eq, inArray, isNotNull, lt, or, sql } from "drizzle-orm";
66

77
import type { Db } from "../db/client.js";
8-
import { memoryChunk, memoryDocument, memoryVersion } from "../db/schema.js";
8+
import {
9+
memoryChunk,
10+
memoryDocument,
11+
memoryVersion,
12+
rawCapture,
13+
} from "../db/schema.js";
914
import type { RetentionClass } from "../core/enums.js";
1015

1116
export type RetentionMutationResult = {
@@ -46,7 +51,8 @@ export async function deprecateVersion(
4651
}
4752

4853
/**
49-
* Tombstone: hide from search/feed, redact chunk text, keep row for audit.
54+
* Tombstone: hide from search/feed, redact chunk and raw capture text, keep
55+
* rows for audit.
5056
* Applies to the document's live active (or deprecated) versions.
5157
*/
5258
export async function tombstoneDocument(
@@ -84,12 +90,30 @@ export async function tombstoneDocument(
8490
eq(memoryChunk.documentId, input.documentId),
8591
),
8692
);
93+
await db
94+
.update(rawCapture)
95+
.set({ rawText: "[redacted]", rawBytes: null })
96+
.where(
97+
inArray(
98+
rawCapture.id,
99+
db
100+
.select({ id: memoryVersion.rawCaptureId })
101+
.from(memoryVersion)
102+
.where(
103+
and(
104+
eq(memoryVersion.tenantId, input.tenantId),
105+
eq(memoryVersion.documentId, input.documentId),
106+
),
107+
),
108+
),
109+
);
87110
}
88111
return { versions: versions.length };
89112
}
90113

91114
/**
92-
* Hard-delete a document (cascade chunks/versions/edges via FKs where set).
115+
* Hard-delete a document (cascade chunks/versions/edges via FKs where set)
116+
* and the raw captures only its versions referenced.
93117
* Blocked for durable retention_class on any non-tombstoned version.
94118
*/
95119
export async function hardDeleteDocument(
@@ -119,17 +143,39 @@ export async function hardDeleteDocument(
119143
};
120144
}
121145

122-
const deleted = await db
123-
.delete(memoryDocument)
124-
.where(
125-
and(
126-
eq(memoryDocument.tenantId, input.tenantId),
127-
eq(memoryDocument.id, input.documentId),
128-
),
129-
)
130-
.returning({ id: memoryDocument.id });
131-
132-
return { deleted: deleted.length > 0 };
146+
return db.transaction(async (tx) => {
147+
const captures = await tx
148+
.selectDistinct({ id: memoryVersion.rawCaptureId })
149+
.from(memoryVersion)
150+
.where(
151+
and(
152+
eq(memoryVersion.tenantId, input.tenantId),
153+
eq(memoryVersion.documentId, input.documentId),
154+
isNotNull(memoryVersion.rawCaptureId),
155+
),
156+
);
157+
const deleted = await tx
158+
.delete(memoryDocument)
159+
.where(
160+
and(
161+
eq(memoryDocument.tenantId, input.tenantId),
162+
eq(memoryDocument.id, input.documentId),
163+
),
164+
)
165+
.returning({ id: memoryDocument.id });
166+
const captureIds = captures.flatMap((c) => (c.id === null ? [] : [c.id]));
167+
if (captureIds.length > 0) {
168+
await tx
169+
.delete(rawCapture)
170+
.where(
171+
and(
172+
inArray(rawCapture.id, captureIds),
173+
sql`NOT EXISTS (SELECT 1 FROM ${memoryVersion} WHERE ${memoryVersion.rawCaptureId} = ${rawCapture.id})`,
174+
),
175+
);
176+
}
177+
return { deleted: deleted.length > 0 };
178+
});
133179
}
134180

135181
/**

0 commit comments

Comments
 (0)