GNL 0.17: early and moving fast — APIs can change between releasesJoin us

GNL
Docs menu
Core · Free@gnldev/durable

GDPR / PII purge

Erase a person with one call (eraseSubject), or delete a single run/thread's journal trace (purgeRun/purgeThread) — the journal's append-only philosophy's only exception, made for legal deletion. purgeRun recursively cascade-deletes sub-agent/network children as well.

What it's for / when to use it#

GNL's journal is normally append-only (see deterministic replay, exactly-once tools) — records are never deleted or overwritten. But when a user makes a GDPR "forget me" request, or PII (personal data) accidentally ends up in a run, that trace needs to be permanently deleted. purgeRun and purgeThread exist exactly for this scenario: as a deliberate, single exception to the journal's append-only rule, the entire key prefix of the relevant run or thread (model/tool/input/wf/proc/cfg/memory entries) is permanently deleted — irreversibly. For a person's erasure request, reach for eraseSubject: one call erases that person from every store the storage holds. purgeRun and purgeThread stay for a single run or a single thread.

Setup / import#

import
import { eraseSubject, purgeRun, purgeThread } from '@gnldev/durable';

There's no separate subpackage path — purgeRun, purgeThread, and sweepRuns all come directly from @gnldev/durable's root export. The journal you're using (SqliteStorage, PostgresStorage, InMemoryStorage) must support the deletePrefix port — all three built-in adapters (InMemory/SQLite/Postgres) provide it; a journal that doesn't support it throws a clear error. eraseSubject comes from the same root export; the background packages bring their own erasers (jobEraser from @gnldev/queue, triggerEraser from @gnldev/scheduler, eventEraser from @gnldev/events).

Erasing a person with one call#

When an erasure request arrives, call eraseSubject. It takes the storage itself, not a list of its stores, so no store it holds can be left out: the journal, the memory store, the vector store and the work log. What lives outside the storage comes in as erasers: the background packages' own records, or a store of yours.

erase a person
import { InMemoryStorage, eraseSubject, toJournal } from '@gnldev/durable';
import { jobEraser } from '@gnldev/queue';
import { triggerEraser } from '@gnldev/scheduler';
import { eventEraser } from '@gnldev/events';

const storage = new InMemoryStorage();
const report = await eraseSubject(storage, 'ayse', {
  orgId: 'acme', // omit for a person outside organizations
  erasers: [jobEraser(storage), triggerEraser(toJournal(storage)), eventEraser(storage.work!)],
});
// { journalRows, workRecords, memoryThreads, workingMemory, unreachedThreads, byEraser: { jobs, triggers, events } }

It deletes the person's runs; their own threads (not other people's they wrote into) with messages, observations, and observational-memory records and vectors; their working memory under both the 0.7 and the 0.6 keys; their documents; and their owned jobs and events. The erasers add the job, trigger and event records the background packages keep.

Pass the ROOT storage and name the person's organization with orgId; leave it out for a person outside organizations. An organization's view of the storage is refused, because owned jobs and events live in the root work log.

It refuses, before deleting anything, a store that cannot delete what it holds — a journal without deletePrefix, a work store without deleteIdPrefix, a vector store that cannot delete. A half-done erasure never happens silently.

The report counts what was deleted, never what was looked for. If your agents' memory was built over ANOTHER storage, pass that storage as memory. Without it, a thread the person owns whose messages no reached store held is listed in unreachedThreads — not empty means the erasure did NOT reach everything, whatever the other counts say.

It is not a snapshot
eraseSubject deletes what is there when it runs. A turn of the person that is running at that moment can still write after the erasure returns. Revoke the person's credential first, then ask subjectRuns: wait (or cancel) until no run is inFlight, erase, and ask once more.

To delete just one run or one conversation instead of a whole person, use purgeRun and purgeThread below.

Step-by-step usage#

1. Permanently delete a run's entire trace (all runId:* keys belonging to that run, plus its memory marker):

run purge
import { purgeRun, purgeThread } from '@gnldev/durable';

const removed = await purgeRun(journal, 'run-123');
// removed: the number of journal keys deleted (parent + EVERY sub-agent/network child)

Before deletion, if the run has already been added to a budget counter (see budget/quota), that cost is automatically deducted from the counter — otherwise __usage__ would go stale after the purge and the deleted run's cost would keep being counted as a ghost. This deduction only happens if the run was actually counted; it doesn't accidentally push an uncounted run (e.g. one still suspended awaiting approval) into the negative.

purgeRun is RECURSIVE: a run may have branched into other runs — children of dynamic multi-agent routing (runNetwork) (net:<runId>:<i>) or sub-agent runs opened by static agent_<name> tool calls (agent:<toolCallId>). BEFORE deletion, the parent's journal is scanned (from its tool entries and net:-prefixed keys); every child found is cascade-deleted along with its own children, at any depth — with cycle/repeat protection included. Result: a user's GDPR "forget me" request leaves no orphaned trace at any level, even if that user's PII leaked into sub-agent outputs.

2. Delete a thread's (BasicMemory-based) journal trace:

thread purge
await purgeThread(journal, 'thread-abc');
// deletes mem:thread-abc:*, om:thread-abc:*, xthr:thread-abc:* and the thread's owner record
Rich memory stores
purgeThread deletes the thread's journal trace: the journal-based BasicMemory keys (mem:<threadId>:*), observational memory's om: counters and memoized summaries, the xthr: dedup family, and the thread's owner record. If you're using a separate rich memory store (e.g. vector-based), you also need to call that store's own deletion API (AgentMemory.deleteThread) separately — or erase the whole person with eraseSubject.

3. Delete a single run from Studio (requires a writable journal + operator authorization):

Studio — run purge
DELETE /runs/:id
→ { ok: true, deleted: <number of keys removed> }

API reference#

fneraseSubject

(storage, resourceId, { orgId?, erasers?, memory? }) → Promise<EraseReport>. Erases one person from every store the storage holds (runs, threads, working memory incl. 0.6 keys, observational-memory records and vectors, documents, owned jobs and events) plus what the erasers reach. Report: { journalRows, workRecords, memoryThreads, workingMemory, unreachedThreads, byEraser }. Refuses, before deleting anything, a store that cannot delete.

fnpurgeRun

(journal, runId, seen?) → Promise<number>. Permanently deletes a run's ENTIRE trace (<runId>:* + memory marker); it is RECURSIVE — it cascade-deletes sub-agent (agent:<toolCallId>) and network (net:<runId>:<i>) children at any depth (cycle-safe). Deducts the run's cost from the budget counter before deleting (best-effort).

fnpurgeThread

(journal, threadId) → Promise<number>. Permanently deletes a thread's journal trace: mem:<threadId>:*, observational memory's om: counters and memoized summaries, the xthr: dedup family, and the thread's owner record; returns the number of deleted keys.

fnsweepRuns

(journal, { olderThanMs, keepSuspended?, now? }) → Promise<SweepResult>. Bulk-deletes old runs via purgeRun according to a retention policy (see retention/TTL sweep).

Scope and authorization
Studio's DELETE /runs/:id is a root-level (organization-unscoped) admin endpoint — it operates on the raw journal. That's why an identity bound to an organization cannot call this endpoint (returns 403); only an operator unbound from any organization can perform a run purge — otherwise a bound identity could delete another organization's run too. The operation is irreversible and is written to the audit log as run.purge, along with the actor and the number of deleted records.