13 KiB
Verbatim Provenance Archive (#7) — Design Spec
Date: 2026-06-30 · Author: Claude Opus 4.8 (1M) · Audience: Marko (founder) · Status: design — awaiting spec review
Arc: paperclip external-agent recon backlog → STEAL SOON / memory moat → #7
Source backlog: docs/analysis/external-agent-launching-and-memory-comparison-2026-06-29.md §8 #7
1. The finding that reshaped #7
The §8 backlog described #7 as "append-only verbatim archive, new file harvest/raw-turns.ts." That file already exists (W4.6, "Marko GO 2026-06-11"). On inspection, verbatim text is already stored twice in the substrate:
| System | What it stores | Immutable? | Coverage | Purpose |
|---|---|---|---|---|
harvest/raw-turns.ts |
per-turn dialogue text, as [mind-rawturn …] frames |
No — deletable rows in memory_frames (cleanup/dedup/reconcile) |
harvest imports with messages only; gated by WAGGLE_RAWDETAIL |
retrieval (the RAWDETAIL recall lane) |
ai_interactions (schema Layer 7) |
model I/O (input_text/output_text) |
Yes — DDL BEFORE UPDATE/DELETE triggers |
live agent interactions | EU-AI-Act Art.12 event log |
| harvest route summary frame | item.content.slice(0, 10_000) (truncated preview) + metadata.sourceId |
No — deletable | every harvest item | the searchable memory |
So "store verbatim" is not the gap. The irreducible gap is a provenance anchor: the ability to take any distilled/imported memory frame and reconstruct the exact, full, never-mutable source it came from. Today:
- The harvest summary frame is a 10K-char truncation of
item.content— long documents/conversations lose their tail. - raw-turns are deletable and messages-only.
- No store guarantees the full source survives frame cleanup, and nothing carries an immutable integrity hash.
This is precisely the EU-AI-Act audit / "reconstruct the original" value #7 was picked for.
2. Goal & non-goals
Goal: an append-only, immutable, full-fidelity store of each harvested source item, with a provenance link from the frames it produced, and an audit/reconstruction query.
Non-goals (YAGNI — explicitly out of scope for v0):
- Capturing non-harvest ingest paths (
save_memory,agent_inferred,team_sync, connector auto-fetch). Founder-chosen scope = harvest/import only. Most other paths are already verbatim (not lossy). - Touching the retrieval path. No change to
search.ts/scoring.ts/ the ranked corpus. The 87.66% LoCoMo SOTA is regression-locked by construction (the archive is not part of the recall corpus). Anti-rec #1 honored. - Replacing or modifying raw-turns or
ai_interactions. This is additive. - Retention/GC of the archive. It is append-only and grows; retention policy is a documented follow-up (mirrors the
ai_interactionsposture — storage growth accepted, pseudonymize-tombstone flow deferred). - A UI surface. v0 is substrate + wiring + a query API; a Memory-Center "view original" button is a follow-up.
3. Design
3.1 New table raw_archive (hive-mind-core substrate)
CREATE TABLE IF NOT EXISTS raw_archive (
id INTEGER PRIMARY KEY AUTOINCREMENT,
archive_uid TEXT NOT NULL UNIQUE, -- = content_sha256 (stable, idempotent natural key)
source TEXT NOT NULL, -- import source (chatgpt/claude/gemini/url/pdf/…)
source_ref TEXT, -- item.id (the UniversalImportItem id)
title TEXT, -- item.title (audit readability)
content TEXT NOT NULL, -- FULL verbatim item.content (untruncated)
content_sha256 TEXT NOT NULL, -- integrity anchor
injection_flagged INTEGER NOT NULL DEFAULT 0,-- 1 if scanForInjection flagged the content
injection_flags TEXT NOT NULL DEFAULT '', -- comma-joined flags when flagged
source_timestamp TEXT, -- item.timestamp (original event time), if ISO
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_raw_archive_source_ref ON raw_archive (source, source_ref);
CREATE INDEX IF NOT EXISTS idx_raw_archive_created ON raw_archive (created_at DESC);
-- Append-only enforcement — identical posture to ai_interactions (schema Layer 7).
CREATE TRIGGER IF NOT EXISTS raw_archive_no_update
BEFORE UPDATE ON raw_archive
BEGIN SELECT RAISE(ABORT, 'raw_archive is append-only (verbatim provenance archive)'); END;
CREATE TRIGGER IF NOT EXISTS raw_archive_no_delete
BEFORE DELETE ON raw_archive
BEGIN SELECT RAISE(ABORT, 'raw_archive is append-only (verbatim provenance archive)'); END;
Added to both SCHEMA_SQL (fresh DBs) and an idempotent block in db.ts runMigrations() (existing DBs), matching the established pattern. Triggers are CREATE … IF NOT EXISTS so both paths are safe.
3.2 New module mind/raw-archive.ts — RawArchive store
export interface RawArchiveRow {
id: number; archive_uid: string; source: string; source_ref: string | null;
title: string | null; content: string; content_sha256: string;
injection_flagged: 0 | 1; injection_flags: string;
source_timestamp: string | null; created_at: string;
}
export interface ArchiveInput {
source: string; sourceRef?: string; title?: string; content: string; sourceTimestamp?: string;
}
export class RawArchive {
constructor(db: MindDB);
/** Idempotent: archive_uid = sha256(content). INSERT OR IGNORE; returns the uid either way.
* Injection-scans the content and records the flag, but stores verbatim regardless
* (zero-loss forensic semantics — the archive is never fed to an LLM directly). */
append(input: ArchiveInput): { archiveUid: string; created: boolean };
getByUid(archiveUid: string): RawArchiveRow | undefined;
/** Resolves frame.metadata.archiveUid → row. Returns undefined when the frame has no link. */
reconstructSource(frameId: number): RawArchiveRow | undefined;
list(opts?: { limit?: number; offset?: number; source?: string }): RawArchiveRow[];
count(): number;
}
- Idempotency:
archive_uid = content_sha256. Re-importing an unchanged item re-derives the same uid;INSERT OR IGNOREmakes the second append a no-op (created:false). Matches the existing harvest idempotency story (source-level content hash +createIFramededup). - Injection posture: scan-but-don't-drop. A zero-loss audit record must keep exactly what arrived — including a hostile payload (that's evidence). The row is never fed to an LLM; it's read only by
reconstructSource/listfor human/audit eyes.injection_flagged+injection_flagsare recorded so any future consumer that does surface the content to a model re-scans first. (Contrast raw-turns, which drops, because those frames ARE fed to recall.) - Hashing:
createHash('sha256')fromnode:cryptoover the full, untouchedcontent(zero new dependency —content-hash.tsalready usesnode:cryptothe same way). Do NOT reusehashFrameContent— itstripHmPrefix's + trims the body (provenance-insensitive dedup semantics), which would hash a mangled body and break the "integrity hash of the exact verbatim" guarantee. Add a smallhashRaw(content)helper (or inline the 1-liner) inraw-archive.ts.
3.3 Link mechanism — frame metadata (zero migration on the hot table)
The harvest route already stamps the summary frame:
frameStore.setMetadata(frame.id, JSON.stringify({ kind, confidence, status: 'unreviewed', sourceId: item.id }));
We add archiveUid to that same object — no new setMetadata call, no schema change to memory_frames (the metadata JSON column already exists). The archiveUid sits alongside the existing sourceId. reconstructSource(frameId) reads JSON.parse(frame.metadata).archiveUid.
Rationale for metadata-JSON over a bridge table: the link is 1:1 (one summary frame per item on this route), read-on-demand (audit query, not hot-path), and the column already exists — a bridge table would add a migration + join for no query benefit at this scope.
3.4 Wiring — server harvest route (packages/server/src/local/routes/harvest.ts)
In the existing per-item loop (the for (const item of items) block, ~L424–485), once per item:
const { archiveUid } = rawArchive.append({ source: item.source, sourceRef: item.id, title: item.title, content: item.content, sourceTimestamp: providedTimestamp });— fullitem.content, before truncation.- Include
archiveUidin the metadata object already built at thesetMetadatacall (~L468).
This is the primary desktop ingest path and the only wiring point for v0. The two MCP harvest tools (memory-mcp, hive-mind-mcp-server) are a documented follow-up — same RawArchive.append call in their item loops; deferred to keep v0 a single reviewable surface.
4. Data flow
harvest import (UniversalImportItem)
│
├─ rawArchive.append({full item.content}) ──► raw_archive row (immutable, sha256, injection-flagged)
│ returns archiveUid ▲
│ │ metadata.archiveUid
├─ createIFrame(content.slice(0,10K)) ──► memory_frames summary frame ─┘
│ + setMetadata({…, sourceId, archiveUid})
│
└─ writeRawTurnFrames(item) ──► [mind-rawturn…] frames (unchanged; retrieval lane)
audit / reconstruction:
reconstructSource(frameId) → frame.metadata.archiveUid → raw_archive row (full verbatim + integrity hash)
5. Error handling
append()is best-effort-safe: an injection-flagged item still stores (flag recorded). A DB error inappendmust not abort the harvest item — wrap the call so a failed archive logs a warning and the frame still persists without anarchiveUid(degraded provenance beats a failed import). Never silent: log names source + item id.reconstructSource: returnsundefined(not throw) for frames with no/invalidarchiveUidor missing rows.- Append-only triggers: any code path that attempts UPDATE/DELETE on
raw_archivethrows at the DB layer — this is intended; callers must never mutate.
6. Testing strategy (TDD)
Unit (hive-mind-core, co-located tests/mind/raw-archive.test.ts):
appendinserts a row; returnscreated:true+ a stable uid = sha256(content).appendis idempotent — second identical content →created:false, same uid, one row.- append-only triggers — direct
UPDATE/DELETEonraw_archivethrows. - injection content is stored (zero-loss) with
injection_flagged=1+ flags populated. - full content survives — a >10K-char content stores untruncated (vs the frame's 10K cap).
reconstructSource(frameId)round-trips: append → create frame withmetadata.archiveUid→ reconstruct returns the row; returnsundefinedfor an unlinked frame.list/countpaging + source filter.- migration: a pre-existing DB (no
raw_archive) gains the table + triggers idempotently on boot.
Integration (server, tests/local/harvest-*.test.ts sibling): one harvest item produces (a) a raw_archive row, (b) a summary frame whose metadata.archiveUid resolves to that row, (c) re-import is idempotent (no duplicate archive row).
7. Risk & SOTA safety
- No retrieval-path change.
raw_archiveis not in any search/scoring query; frames are unchanged in shape. The 87.66% LoCoMo number cannot move. No LoCoMo re-run required. - Hot-table safety. Zero schema change to
memory_frames; the link uses the existingmetadatacolumn. Theidx_frames_content_hashboot-order regression (2026-06-12) does not apply —raw_archiveis a standalone table with no dependency on a guarded ADD COLUMN. - Storage growth is the accepted tradeoff (same posture the founder already ratified for raw-turns, 2026-06-11). Archive stores full content once per unique item.
8. OSS sync note (§7.5)
raw_archive is generic provenance substrate (like ai_interactions, which is EU-AI-Act-framed yet not in the OSS-excluded list) — not Waggle-proprietary governance like install_audit. So it is OSS-bound: it should forward-port to marolinik/hive-mind in the next curated regeneration. Built in the monorepo first per §7.5; the OSS mirror is regenerated separately by the maintainer. No mirror edit in this arc. Flag for the next oss-drift-check.sh pass.
9. Out of scope / follow-ups (tracked, not built)
- MCP harvest entry points (
memory-mcp,hive-mind-mcp-server) — same one-lineappendwiring. - 4-pass
HarvestPipelinedistilled frames (where item attribution is lost in synthesis) — those persist via a different path; linking them needs pipeline itemId preservation. Not on the server route (which doesn't run the pipeline for its frames). - Memory-Center "view original source" UI button over
reconstructSource. - Retention / GDPR-erasure tombstone flow for the archive.