유대선
프로젝트로
·기술 회고·3

Redact memory at the exit, not at the source: GraphStore's local-private boundary

DalkkakAI's memory graph keeps raw receipts locally on purpose, and only redacts secrets and local paths at the two boundaries where records actually leave the machine — support/export and cloud sync. Two parallel work items (gov02, gov03) built the projection and then wired it in.

The hardest part of a redaction feature is deciding where it runs. The naive instinct is to scrub secrets the moment they enter the store. We deliberately did the opposite.

DalkkakAI's operating memory is a GraphStore — append-only JSONL of nodes and edges capturing what happened across a founder's startups: git changes, agent transcripts, support emails, work-item command output, evidence URIs. That data is the receipt. It exists to answer "what did Claude actually run, with what cwd, against which file" weeks later, during a debugging session, an approval review, or an audit. If we redacted on the way in, we'd be destroying the exact evidence the memory engine exists to preserve.

So the rule we landed on: append stays raw and local-private; redaction only happens at the boundary where a record leaves the machine.

Two parallel work items implemented this in sequence.

gov02 — build the projection. This added a pure Rust function in apps/desktop/src-tauri/src/graph_index.rs:

pub fn redact_graph_record_for_external_boundary(value: &Value) -> Value {
    redact_graph_value(value)
}

It walks a JSON value recursively. Sensitive keys (transcript_path, cwd, uri, cookie, anything containing authorization, secret, credential, password, api_key, or ending in token) get replaced wholesale with [REDACTED]. Sensitive text gets four passes: prefixed secret tokens (sk-ant-, sk-, ghp_, github_pat_), Bearer tokens, key-value secret assignments, and local home paths (/Users/..., C:\Users\...). Crucially the projection preserves node and edge IDs, provenance, source, timestamps, counts, and output_tokens — everything you need for diagnostics survives; only the secret-bearing payload is masked.

The doc comment is the whole philosophy in two lines: "This does not make a graph body public-safe. It only strips likely secrets and local path fields." Redaction is not a publishing approval. A redacted body can still be private customer text.

gov02 explicitly noted the function was not wired into anything — a pure, tested projection with no caller. That was the honest gap.

gov03 — adopt it at real boundaries. This closed the gap by routing the two paths where graph data actually exits local storage, in apps/desktop/src-tauri/src/lib.rs:

Then the live cloud paths were re-pointed off the raw reader: CloudSync.tsx and home/useHomePush.ts now invoke("graph_cloud_sync_preview") instead of graph_list before pushing anything to Supabase or the master/home aggregator. The raw graph_list, graph_edges, and GraphStore::append/append_edge stayed untouched and local by design.

Verification was real: gov02 ran cargo test ... graph_index (9 tests) and the full suite (101 passing); gov03 added graph_boundary_redaction_tests and ran the full Rust suite (104 passing) plus the desktop TS suite (249 tests). One honest scar: gov02's required @ddalkkak/shared test was not green — a pre-existing Node-v24 strip-types failure on an extensionless import, unrelated to the change, that gov03 later found resolved.

What we deliberately did not ship: there's still no per-node sensitivity field (local_private / shared_summary / public_redacted), and no export/delete workflow for the JSONL, backups, keychain, or cloud rows. The boundary is enforced at the two exits that exist today; the taxonomy comes later. The lesson worth keeping: in a memory product, the safe default is to retain raw and gate the exits, not to lossily sanitize at ingest. You can always redact a receipt later — you can't un-shred one.