13 KiB
Sticky Erasure — GDPR Art.17 suppression that survives re-import
Date: 2026-07-02 · Feature: #7 Art.17 tail · Status: design approved, implementation pending
Author: brainstormed w/ founder (Marko), grounded by sticky-erasure-recon workflow (6 agents, full write-path map).
Problem
The #7 Art.17 arc (closed 2026-07-02 S1) rotates archive_uid to an opaque random id on erase to
close a re-identification vector. That rotation has a documented side-effect (raw-archive.ts:194-200):
rotating the uid frees RawArchive.append()'s content→uid dedup key, so re-importing an
already-erased source re-materializes it. The searchable summary, verbatim raw-turns, and KG
already came back on re-harvest before the rotation too (pre-existing) — so erasure is not "sticky":
a user who exercises their right-to-erasure on a ChatGPT thread gets it back the next time that
thread is re-exported/re-synced.
The fix named in-code is an erased-subject suppression list consulted at every write seam. A
content-keyed tombstone is explicitly rejected — storing sha256(erased-PII) would reintroduce
the very content-derived re-id vector the rotation removed.
Decisions (founder-ratified 2026-07-02)
| # | Decision | Choice |
|---|---|---|
| Granularity | key on | Per-source (source, source_ref) — the same tuple eraseBySourceRef uses; the only durable identity that survives erasure. NOT per-content-hash (re-id vector), NOT per-subject-string (no schema handle, over-erase). |
| Durability | permanence | Sticky + explicit re-consent — permanent by default; a deliberate "Allow re-import again" UI action deletes the suppression row. |
| Scope | reach | Group-A write paths only; connector + ingest_source documented as a known limitation (they persist no durable subject key today — already un-erasable by subject, orthogonal pre-existing gap). |
| Backfill | past erasures | Backfill erased_subjects from retained raw_archive erased rows so historical erasures are sticky too. |
| Tombstone | content-hash | OFF (consistent with the ratified anti-re-id rotation). |
| Capture point | where recorded | Inside MindErasure — one point feeds both the route and the MCP erase_memory tool (the tool emits no audit event; a ledger fed from audit events would silently miss all MCP erasures). |
| Mind scope | isolation | Per-mind (per the mind-isolation pin feedback_mind_isolation_no_cross_mind_mixing). |
| Read-failure | on isSuppressed error |
Fail-closed on the item (skip re-materialization). A failing local-SQLite read implies a broken DB where the follow-on INSERT fails anyway; Art.17 wins on the ambiguous item. |
Architecture
Suppression must live one level up from FrameStore.createIFrame — that universal chokepoint
sees only (gopId, content) (content-hash), which is exactly the rejected key. (source, source_ref)
is only in scope at the harvest boundaries. So the check hooks at those boundaries; the capture hooks
at the erase boundary.
1. Schema — erased_subjects (new table)
Home: packages/hive-mind-core/src/mind/schema.ts (SCHEMA_SQL, appended after the raw_archive
block so it ships on first-init) + an idempotent guarded block in db.ts runMigrations() (same
pattern as the raw_archive / ai_interactions migrations, no CHECK-list drift).
CREATE TABLE IF NOT EXISTS erased_subjects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
source_ref TEXT NOT NULL,
erased_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
reason TEXT,
UNIQUE(source, source_ref)
);
CREATE INDEX IF NOT EXISTS idx_erased_subjects_lookup ON erased_subjects(source, source_ref);
No content, no content_sha256 — generic substrate only. This is the deliberate opposite of
install_audit (proprietary-interleaved, hand-stripped on OSS export): erased_subjects forward-ports
to marolinik/hive-mind verbatim. No immutability triggers (rows are deletable — that is the re-consent path).
Backfill (one-time, idempotent, in the same migration block, after table create):
INSERT OR IGNORE INTO erased_subjects (source, source_ref, erased_at, reason)
SELECT source, source_ref, erased_at, erased_reason
FROM raw_archive WHERE erased_at IS NOT NULL;
Limitation: past erasures of archive-less summaries (subject-mode on a legacy/append-failed frame with
no raw_archive row) leave no skeleton to backfill from — accepted (they had no durable archive anchor).
2. SuppressionStore (new mind/suppression.ts)
Stateless wrapper over the mind db (like other mind/ stores), exported from @waggle/core and
@waggle/hive-mind-core:
class SuppressionStore {
constructor(db: Database) // or the project's DB wrapper, matching RawArchive's ctor
isSuppressed(source, sourceRef): boolean // indexed SELECT EXISTS; FAIL-CLOSED (error → true)
record(source, sourceRef, reason?): void // INSERT OR IGNORE (idempotent)
unsuppress(source, sourceRef): boolean // DELETE; returns whether a row was removed
list(): { source, sourceRef, erasedAt, reason }[]
}
isSuppressed swallows read errors and returns true (fail-closed on the item) with a loud
logger.error — a genuine read failure means the DB is broken and the subsequent write fails too.
3. Capture — inside MindErasure (mind/erasure.ts)
eraseBySourceRef(source, sourceRef, reason)→suppression.record(source, sourceRef, reason)(subject mode — the canonical key arrives verbatim).eraseFrameComplete(frameId)→ for each resolved(source, source_ref)subject (it already resolves them viareconstructSource+ the content-prefix/metadata.sourceIdfallback) →suppression.record(...). Subject-less frames (connector/ingest single frames) resolve no durable subject → nothing recorded (out of scope, documented).
Because both the /api/memory/erase route (frame + subject mode) and the MCP erase_memory tool call
these primitives, both surfaces feed the ledger with no drift. Recording happens inside the erase
transaction so a rolled-back erase does not leave a stale suppression row.
4. Consumption guards (fail-closed)
| Seam | Guard | Covers |
|---|---|---|
3 harvest loops — server/routes/harvest.ts, hive-mind-mcp-server/tools/harvest.ts, memory-mcp/tools/harvest.ts |
if (isSuppressed(item.source, item.id)) { skippedSuppressed++; continue; } at loop top, via one shared helper in hive-mind-core (kills twin-drift) |
The whole fan-out per item: summary createIFrame + setMetadata + RawArchive.append + writeRawTurnFrames + KG/vector cognify + wiki |
server/local/harvest-autosync-frame.ts writeAutoSyncSummaryFrame |
guard before createIFrame |
30-min in-process auto-sync and daily cron harvest_sync (single shared writer) |
hive-mind-core/mind/raw-archive.ts RawArchive.append |
skip INSERT + return { created:false } if suppressed |
Defense-in-depth for any future non-loop caller of the documented-gap primitive |
The 3 loops call RawArchive.append and writeRawTurnFrames only from inside the loop, so the loop
continue already covers those vectors; the append guard is a durable backstop, not the primary gate.
The shared helper signature: shouldSuppressHarvestItem(suppression, source, sourceRef): boolean (or a
thin method on SuppressionStore) so the 3 call sites are continue-only, logic shared.
skippedSuppressed is surfaced in each import's result summary so a suppressed re-import is visible
(not a silent drop) — per the "no silent caps" discipline.
5. Re-consent surface
SuppressionStore.unsuppress+listexposed via@waggle/core.- Server routes (
server/src/local/routes/memory-center.ts, next to the erase endpoint):GET /api/memory/suppression→list()POST /api/memory/suppression/allow{ source, sourceRef }→unsuppress()
- Memory Center UI (
apps/web/.../memory/MemoryCenterTab.tsxor the trust/erase area): a "Suppressed sources" list, each row with an "Allow re-import again" button (confirm → POST → row disappears). Minimal; co-located with the existing Erase affordance.
6. Out of scope (documented limitation)
ingest_source (doc/URL/PDF/text) and the PRO connector cron persist no durable (source, source_ref)
— connectorDataToItems discards the item id, ingest_source stamps no metadata.sourceId and creates
unlinked KG entities. They are already un-reachable by subject-mode erasure today (pre-existing,
independent of this feature). Making them sticky requires threading the connector item id + stamping
sourceId + routing ingest_source through importEntitiesForFrame — a separate engineering item.
Noted in raw-archive.ts / the erase docstrings.
6b. Known limitations (post-review, deferred — adversarial review 2026-07-02, 21 raised / 14 confirmed)
All confirmed HIGH + most MEDIUM/LOW findings were fixed in-arc. Three are deliberately deferred:
- claude-code
decision-ofderived subject (MEDIUM, notable).extractDecisionsemits a SEPARATE item per parent whose id =stableHarvestId('claude-code','decision-of',parentId)and whose content is the parent's decision-pattern lines. Erasing/suppressing the PARENT subject does not reach this derived subject (distinct key), so the derived frame (quoting the parent's decision text) survives erasure AND re-materializes on re-import/auto-sync. Partly pre-existing (pre-arc it re-materialized under a random key too). Proper fix couples erasure to the derivation (followmetadata.extractedFromin the sweep + record the derived key) — non-trivial, claude-code-specific; deferred to a follow-up. isSuppressedfail-closed labeling (LOW, cosmetic). If ONLYerased_subjectsis unreadable while other tables are healthy (single-table corruption / manual DROP within one process lifetime), every item is skipped and the harvest response mislabels the whole-corpus silent drop as "N erased subjects suppressed (GDPR Art.17)". Fail-closed is the correct Art.17 posture; only the count/message conflates a read-error with a real suppression. Uncommon trigger; deferred (surface a separate error counter).- Workspace-scope suppression is inert (LOW, cosmetic). A
mind=workspaceerase records a suppression row in the workspace mind, but harvest only ever writes/reads the personal mind, so the row can never suppress anything and the workspace re-consent panel's promise is vacuously true. Consistent with the ratified per-mind model; the UI/API framing over both minds is the only misleading part. Deferred.
7. OSS forward-port (P2, monorepo-first per §7.5)
suppression.ts + the schema.ts/db.ts additions are generic substrate (no governance/trust
fields) → forward-port to marolinik/hive-mind packages/core verbatim via the curated forward-port
(co-located tests, import rewrites). Done after the monorepo commits land + any blocking OSS PR, matching
the prior arc's cadence. The schema.ts/db.ts diffs still get the manual proprietary-review pass.
Testing (TDD)
Unit (hive-mind-core/tests/mind/suppression.test.ts):
recordidempotent;isSuppressedtrue/false;unsuppressremoves + returns bool;listshape.isSuppressedfail-closed: a forced read error returnstrue.
Migration/backfill (hive-mind-core/tests/mind/schema or db migration test):
- Table created on fresh init; migration idempotent on re-run.
- Backfill populates
erased_subjectsfrom pre-existingraw_archiveerased rows.
Capture (hive-mind-core/tests/mind/erasure.test.ts):
eraseBySourceRefrecords the pair;eraseFrameCompleterecords each resolved subject; rolled-back erase leaves no suppression row.
Integration — the headline test:
- Harvest a source → erase it (subject or frame mode) → re-import the same source → assert the
summary, raw-turns,
raw_archiverow, and KG entities do NOT re-materialize, andskippedSuppressed>0. unsuppress→ re-import the same source → assert it does re-materialize.RawArchive.appendon a suppressed subject →{ created:false }, no row.writeAutoSyncSummaryFrameon a suppressed subject → no frame.
Gates: npx tsc --noEmit on {shared, hive-mind-core, server, memory-mcp, apps/web}; touched-area
Vitest green; then a multi-lens adversarial review workflow (matching the arc's cadence) with every
confirmed finding fixed/documented in-arc.
Phasing
- Substrate — schema + migration + backfill +
SuppressionStore+ capture inMindErasure. (hive-mind-core) - Guards — shared helper + 3 harvest loops +
writeAutoSyncSummaryFrame+RawArchive.appendbackstop. - Re-consent — routes + Memory Center UI.
- Adversarial review — multi-lens workflow; fix confirmed findings.
- OSS forward-port — P2, after monorepo lands (per §7.5).
Commit per phase; push held until founder asks (main is shared with concurrent sessions — fetch-checked fast-forward at push time, as in the prior arc).