202 lines
13 KiB
Markdown
202 lines
13 KiB
Markdown
# 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).
|
|
|
|
```sql
|
|
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):
|
|
```sql
|
|
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`:
|
|
|
|
```ts
|
|
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 via `reconstructSource` + the content-prefix/`metadata.sourceId` fallback) →
|
|
`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` + `list` exposed 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.tsx` or 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-of` derived subject (MEDIUM, notable).** `extractDecisions` emits 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 (follow `metadata.extractedFrom`
|
|
in the sweep + record the derived key) — non-trivial, claude-code-specific; deferred to a follow-up.
|
|
- **`isSuppressed` fail-closed labeling (LOW, cosmetic).** If ONLY `erased_subjects` is 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=workspace` erase 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`):
|
|
- `record` idempotent; `isSuppressed` true/false; `unsuppress` removes + returns bool; `list` shape.
|
|
- `isSuppressed` **fail-closed**: a forced read error returns `true`.
|
|
|
|
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_subjects` from pre-existing `raw_archive` erased rows.
|
|
|
|
Capture (`hive-mind-core/tests/mind/erasure.test.ts`):
|
|
- `eraseBySourceRef` records the pair; `eraseFrameComplete` records 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_archive` row, and KG entities do **NOT** re-materialize, and `skippedSuppressed>0`.
|
|
- `unsuppress` → re-import the same source → assert it **does** re-materialize.
|
|
- `RawArchive.append` on a suppressed subject → `{ created:false }`, no row.
|
|
- `writeAutoSyncSummaryFrame` on 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
|
|
|
|
1. **Substrate** — schema + migration + backfill + `SuppressionStore` + capture in `MindErasure`. (hive-mind-core)
|
|
2. **Guards** — shared helper + 3 harvest loops + `writeAutoSyncSummaryFrame` + `RawArchive.append` backstop.
|
|
3. **Re-consent** — routes + Memory Center UI.
|
|
4. **Adversarial review** — multi-lens workflow; fix confirmed findings.
|
|
5. **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).
|