Files
mempalace-toolkit/docs/rfc-001-global-palace.md
T
joakimp 35b1e3d81d docs: add RFC 001 — global palace with local fallback (mempalace-edge)
Design for moving from one MemPalace per machine per harness to a single
primary palace with per-machine offline fallback, plus the source-verified
archaeology behind it.

Key decisions recorded:

- Sync operations (MCP tool calls), not databases. Embeddings are computed
  client-side and are not portable across architectures; the KG `triples`
  table has no UNIQUE(subject,predicate,object,valid_from) and its ids embed
  datetime.now(), so row copies duplicate facts. Replaying tool calls is
  idempotent where it matters.
- Implement as `mempalace-edge`, a local stdio MCP proxy (child mempalace-mcp
  + HTTPS to the primary + outbox.sqlite), not as per-harness patches. Needs
  zero mempalace internals, so it serves pi, opencode and the CLI alike and
  survives mempalace upgrades.
- Merged reads (query both, re-sort, dedupe by drawer id) give fleet-wide
  recall without replication — which is why Phase 3 (pull replication) is
  deferred: "own writes plus whatever it can reach" is good enough.
- Per-wing replication policy: curated/content-addressed wings replicate,
  mined code/docs stay local (derived, re-mineable, path-dependent ids).

Also documents two silently destructive footguns to avoid during rollout:
`--palace` vs MEMPALACE_PALACE_PATH (the KG follows the flag only, so
`mempalace serve` can start with a silently empty knowledge graph), and
`mempalace sync`, which is gitignore-aware drawer deletion rather than
replication and would wipe fleet memory when run from a host lacking the
repos. Notes that mempalace 3.6.0's `serve` already ships token auth + TLS,
making the "unauthenticated, front it with a proxy" notes elsewhere stale.

Includes an evidence index mapping each claim to file:line in mempalace
3.6.0, and four upstream candidates (origin_host provenance, per-wing ACL,
mempalace_kg_supersede missing from service.py WRITE_TOOLS, and a
sync --refuse-shared guard).
2026-08-09 00:30:00 +02:00

24 KiB
Raw Blame History

RFC 001 — Global palace with local fallback (mempalace-edge)

Status Draft — design agreed, not implemented
Created 2026-08-08
Applies to mempalace 3.6.0, mempalace-toolkit @ 96699f2, pi-devbox ≥ v1.3.0
Decision Phases 02 + 4 in scope. Phase 3 (full pull replication) explicitly deferred — "a laptop that can reach its own stuff plus whatever it can reach" is good enough.

Read this first if you are asked to "centralize MemPalace" / "sync palaces between machines". Most of the hard-won facts below are non-obvious and two of them are actively destructive if you guess wrong (§7.1, §7.2). The evidence index in §10 lets you re-verify any claim without re-reading 45k lines.


1. The problem

One palace per machine per harness. Today: a pi-devbox container on EMB-7KJ4VR4G, another on tor-ms22, opencode-devbox containers, MBP-M1-2020, plus native hosts — each with a private ~/.mempalace. Consequences:

  1. Memory is sharded by accident of where you happened to be working. A decision recorded on the laptop is invisible to the agent on the desktop.
  2. Container recreates are amnesia events unless the palace happens to be host-bind-mounted.
  3. The KG is the worst hitkg_query/kg_timeline answers depend on which machine you ask.

Target state: one primary palace holds the fleet's memory. Every client keeps working when the primary is unreachable (writes buffer locally, reads degrade to local), and reconciles when it returns. Access is authenticated per device, with a sane authorization policy.


2. Verified starting point (as of 2026-08-08)

Two of the three pieces already exist. This is not greenfield.

Piece State Evidence
Primary server Exists. mempalace serve --host --port --token --tls-cert --tls-key --read-only --allow-insecure. Bearer token compared with hmac.compare_digest, mandatory on non-loopback binds (unless --allow-insecure), TLS 1.2+ resolved before bind, Host-header pinning + Origin allowlist (anti-DNS-rebinding), 16 MiB body cap, token-free /healthz. cli.py:cmd_serve (~1448); mcp_server.py:5205-5215, 5284-5289
Remote client ⚠️ Exists for pi only, and it is either/or. createClient() picks stdio or HTTP once at process start. extensions/pi/mempalace.ts:629-641
Fallback + resync Absent everywhere. On remote failure the pi bridge re-handshakes the same URL, then de-registers all palace tools and runs blind. extensions/pi/mempalace.ts:665-673
opencode client stdio only. {"type":"local","command":["mempalace-mcp"]} in ~/.config/opencode/opencode.json. No remote entry exists anywhere in myconfigs. myconfigs/tor-ms22.home.arpa/.config/opencode/opencode.json
Server compose Exists: pi-devbox/docker-compose.mempalace.yml (canonical) + a tor-ms22 derivative (docker-compose-repo/tor-ms22/pi-devbox/, df2c2ae, port 8766, uid 1000, binds the real ~/.mempalace). Not enabled. pi-devbox CHANGELOG v1.3.0 (2026-07-02)

Stale docs warning. docker-compose.mempalace.yml, .env.example and CHANGELOG v1.3.0 all say the HTTP transport is unauthenticated and should be fronted by a reverse proxy. That was true for mempalace-mcp --transport http in the v1.3.0 era. mempalace 3.6.0's serve has token + TLS built in (upstream #1877). Fix those comments during Phase 1.

Two things that sound like the feature and are not

  • sync.py is not replication. It is gitignore-aware drawer deletion: "Removes drawers whose source files are now gitignored, deleted, or moved out of the project" (sync.py:1-12). See §7.2 — running it against a shared palace is a fleet-wide memory wipe.
  • wal/write_log.jsonl is not a replayable WAL. _WAL_REDACT_KEYS strips content/content_preview/document/entry/entry_preview/query/text and replaces them with "[REDACTED N chars]"; all 12 references are writers, there is no reader and no replay function (wal.py:74). Reconstructing memory from it is information-theoretically impossible.

3. Why we sync operations, not databases

Row-level / file-level replication of a palace is a trap in this codebase. Four findings, each independently disqualifying:

  1. Vectors are not portable. Embeddings are computed client-sidebackends/pgvector.py:6-8: "Embeddings are still produced locally by MemPalace through the core embedding wrapper before vectors are written to Postgres." Cross-architecture ONNX determinism (arm64 macOS vs x86-64 Linux, different execution providers) is not guaranteed. → Ship text + metadata, always re-embed on receipt. Cheap and safe: the raw text is always stored as the chroma document.

  2. KG rows cannot be copied. triples has no UNIQUE(subject,predicate,object,valid_from) — only id is unique, and make_triple_id embeds datetime.now(). A row copy therefore duplicates every fact. But add_triple() guards at the application level (SELECT id … WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL → returns the existing id), so replaying kg_add is idempotent for open facts (knowledge_graph.py:163-178, 305-313).

  3. Only one write path has deterministic IDs.

    Write path ID recipe Same content on 2 hosts → same ID?
    add_drawer / checkpoint drawer_{wing}_{room}_{sha256(wing|room|content)[:24]} YES — content-addressed, merges for free
    project/format miner …sha256(source_file|chunk_index) NO — absolute path (/Users/joakim/x vs /workspace/x)
    convo miner …sha256(source_file|extract_mode|chunk_index) NO — same reason
    diary diary_{wing}_{YYYYmmdd_HHMMSSffffff}_{sha256(entry)[:12]} NO (µs timestamp) — but the 12-hex suffix is a usable content dedup key
    KG triple t_{s}_{p}_{o}_{sha256(valid_from|recorded_at)[:12]} NO (recorded_at = now())

    (ids.py:56,71ID_RECIPE = "v3", length-prefixed delimited hashing.)

  4. Batch dedup will not save a naive merge. dedup.py groups by source_file and only compares within a group (cosine < 0.15) → cross-host duplicates with differing paths are never compared. The mechanism that does work is write-time: tool_check_duplicate(content, threshold=0.9) queries the whole collection, and mempalace_checkpoint already runs it per item.

Conclusion: the MCP tool surface is already a small, coarse-grained, mostly-idempotent operation vocabulary. Log the intent, replay the intent. That sidesteps chroma internals, chunking, embedder drift and vector portability in one move.


4. Design: mempalace-edge, a local MCP proxy

4.1 Shape

Do not put fallback logic in extensions/pi/mempalace.ts — opencode would need it again, and it may not support remote MCP at all (§9.1). Instead: a sidecar that is an MCP server.

pi        (mempalace.ts, stdio) ─┐
opencode  (mcp: type=local)     ─┼──►  mempalace-edge  ──HTTPS MCP──►   PRIMARY
mempalace CLI (local palace)    ─┘        │   (stdio MCP server)       mempalace serve
                                          ├─ child: mempalace-mcp       --token --tls-cert
                                          │  (local palace, always writable)
                                          └─ outbox.sqlite (durable op queue)

mempalace-edge needs zero mempalace internals — it is an MCP-to-MCP proxy. It speaks stdio down to a child mempalace-mcp and HTTPS up to the primary, reusing the RemoteMcpClient already written in 96699f2 (extensions/pi/mempalace.ts:390, vendored from pi-extensions/mcp-loader.ts — mind the MCP-STREAMABLE-HTTP-CLIENT-SYNC: v1 drift token if you copy it again).

Why this shape wins:

  • One implementation for every harness. opencode's change is one line: "command": ["mempalace-edge"]. pi's is one line. The CLI is untouched.
  • Tools never vanish from the tool list, so wake-up context injection keeps working offline — unlike today's fail-soft de-registration.
  • Survives mempalace upgrades: coupled to the tool schema, not to chroma/HNSW.
  • The remote transport is sessionless JSON-RPC today (per 96699f2's own note), so reconnect after an outage is cheap — there is no session to re-establish.

4.2 Routing policy

service.py:29,54,72 already ships the exact three-way split we need — READ_TOOLS, WRITE_TOOLS, MAINTENANCE_TOOLS ({mine, sync, reconnect}) — plus classify_tool().

Class Primary up Primary down
read (search, kg_query, diary_read, list_*, traverse…) query both, merge ranked lists, dedupe by drawer id local only, response flagged degraded: true
write (add_drawer, checkpoint, diary_write, kg_add…) apply local and enqueue → primary apply local, enqueue, keep working
maintenance (mine, sync, reconnect) never proxied — local only (§5, §7.2) local only
destructive (delete_drawer, delete_by_source, delete_tunnel…) admin-scoped only; tombstone, never hard-delete remotely local only

⚠️ classify_tool() must be treated as fail-closed. mempalace_kg_supersede is a real MCP tool (3 references in mcp_server.py) but is absent from WRITE_TOOLSclassify_tool() returns "unknown" for it. An edge proxy that routed "unknown" as read would silently drop supersede operations. Maintain our own tool→class table, default unknown ⇒ write, and file the upstream fix.

The merged-read trick is what makes Phase 3 optional. Both palaces return ranked results with distances; if the embedder identity matches (§7.4) the distances are comparable, so a union + re-sort + dedupe-by-id gives a correct combined result set. You get fleet-wide recall without replicating anything. Offline you simply see less.

KG reads are the exception: unioning triples is unsafe because invalidate/supersede are order-dependent. Use primary-first, local-fallback (no union) for kg_* reads, and accept that an offline KG answer is incomplete.

4.3 Outbox

Model it on daemon.py, which already implements a durable local job queue and is the closest existing prior art (token auth via ensure_token, 0600, per-palace state dir, MAX_ATTEMPTS = 3, recover_running, JOB_RETENTION_DAYS = 7). Its key primitive is worth copying verbatim:

CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_dedupe_active
  ON jobs(dedupe_key) WHERE state IN ('queued', 'running');

A TOCTOU-safe, cross-process "at most one active job per key". Our outbox rows:

Column Purpose
op_id sha256(origin_device, local_seq, tool_name, canonical_payload) — stable across retries, the primary's idempotency key
local_seq Per-device monotonic counter → causal order for kg_invalidate/supersede replay
origin_device, origin_host Provenance (§7.3)
tool_name, payload_json The MCP call to replay
state, attempts, created_at, flushed_at, error_json Retry/audit

Flush = drain in local_seq order, stop on first hard failure (preserve ordering), exponential backoff, resume on reconnect. The primary de-dupes on op_id; add_drawer's own pre-write probe and add_triple's open-fact guard make replay safe even if op_id tracking is lost.

write_routing.py is the upstream-shaped seam for this if we ever want it in core: its WriteRoutingPolicy{DIRECT,PREFER,REQUIRE}WriteRoutingTarget{DIRECT,DAEMON,BLOCKED} enums would gain a REMOTE target. The module says it "changes no caller defaults by itself" — i.e. it exists precisely to be extended.


5. Not everything should be global

Per-wing replication policy, declared in edge config:

Policy Wings Rationale
replicated diaries, wing_pi, checkpoints, KG, hand-authored notes Small, curated, content-addressed → already merge-safe
local mined code/docs (e.g. the 13,597-drawer workspace wing on this host) Derived data, re-mineable from git, and carries exactly the path-dependent IDs that break merging (§3.3)

This shrinks the hard problem to the layer that is already safe, and it is why mine must never be remote: the primary serializes every request behind one lock (§7.5), so a bulk remote mine would stall every other agent in the fleet.


6. Security model

The transport is largely solved; the gap is authorization, not cryptography.

6.1 Threats, in priority order

  1. Memory poisoning / persistent cross-machine prompt injection — the underrated one. Palace content is injected into agent context at wake-up. A shared palace means one careless or compromised container can plant instructions that every other agent in the fleet reads as trusted memory. Mitigation: provenance on every record (§7.3), don't auto-inject wake-up content authored by devices outside a trusted set, keep an admin-only wing for anything instruction-shaped.
  2. Aggregation raises exfiltration impact. One primary holds every machine's diaries — which already quote internal hostnames, paths and token prefixes. Mitigation: per-wing ACL; don't put secrets in memory (the WAL redaction list exists for a reason); private-network-only exposure.
  3. Accidental mass deletion by a client (sync, delete_by_source) — see §7.2.
  4. Availability: the server is single-writer by design; one long operation blocks everyone.

6.2 Policy

Control Decision
Network posture Primary never internet-exposed. Bind loopback in the container; publish only onto the private overlay / existing tunnel (Pangolin/newt).
Transport TLS via serve --tls-cert/--tls-key, or terminate TLS + mTLS in the reverse proxy (cheaper than patching Python's TLS surface).
Authentication Per-device bearer tokens, not the one shared token. Server-side registry token → {device_id, scopes}. Store in the existing .env.age flow, 0600 on disk. Enables revoking one laptop and rotating without a fleet outage.
Authorization Per-device read/write wing globs. Server-side refusal of mine/sync/delete_* except for an admin device. --read-only gives a free observer tier.
Provenance/audit origin_host + origin_device + op_id on every record (§7.3); server-side append-only op log.
Recovery Deletes as tombstones; server-side backups with backups.py retention (MEMPALACE_MAX_BACKUPS).
DoS Keep the 16 MiB cap; add rate limiting; no remote mine.

7. Landmines — Phase 0 runbook

Do these before any cutover. 7.1 and 7.2 cause silent data loss.

7.1 MEMPALACE_PALACE_PATH--palace (silent empty KG)

# mcp_server.py:708-711  (_palace_flag_given = bool(_args.palace), line 325)
def _resolve_kg_path() -> str:
    if _palace_flag_given:
        return os.path.join(_config.palace_path, "knowledge_graph.sqlite3")
    return DEFAULT_KG_PATH        # knowledge_graph.py:49 → ~/.mempalace/knowledge_graph.sqlite3

cmd_serve always passes --palace. So the moment you start the server, the KG becomes <palace>/knowledge_graph.sqlite3 — a different file from the live ~/.mempalace/knowledge_graph.sqlite3. The vector store looks fine and the knowledge graph is silently empty.

Four stores, three location rules:

Store Location rule
drawers backend-abstracted (BaseCollection)
knowledge_graph.sqlite3 HOME-anchored unless --palace flag (knowledge_graph.py:49)
hallways.json derived from palace_path, with legacy HOME fallback (hallways.py:73,83)
known_entities.json HOME-anchored (miner.py:701)

Action: mv all three files into the palace directory before first serve, and verify kg_stats is non-empty afterwards. Corollary: a shared pgvector/qdrant backend shares drawers and nothing else — it would leave the KG as fragmented as it is today. That is why the shared-backend option is not the answer.

7.2 Never run mempalace sync against a shared palace

It classifies drawers whose source files are absent on the running host as orphans and deletes them; _auto_detect_project_roots guesses roots from drawer metadata, so the blast radius is data-dependent rather than obvious. From a laptop that lacks the repos, it is a fleet-wide wipe. Action: edge blocks mempalace_sync from ever reaching the primary; document it; consider an upstream --refuse-shared guard.

7.3 Add provenance metadata now

Today every drawer carries exactly {wing, room, source_file, added_by, filed_at, id_recipe} (+chunk_index, parent_drawer_id); diaries add {hall, topic, type, agent, date}. added_by/agent is the agent name (pi, mcp, checkpoint) — never the machine. So after any merge you cannot tell which host wrote a record, cannot audit, and cannot compute per-device high-water marks. Metadata is free-form, so this is cheap — but it is impossible to backfill. Do it first.

7.4 Embedder identity is client-local and currently toothless

check_embedder_identity() raises DimensionMismatchError / EmbedderIdentityMismatchError, but the sidecar on this host reads {"mempalace_drawers": {"model_name": "minilm", "dimension": 0}} — and dimension: 0 means unknown and is skipped, so only the model name is compared. The sidecar lives in the client's palace dir (_sidecar.py), so with a remote backend the check is per-client and never centrally enforced. Action: the primary must reject writes from a client whose embedder identity does not match, and merged reads (§4.2) must be disabled on mismatch — comparing distances across models silently degrades recall.

7.5 Concurrency expectations

  • _HTTP_REQUEST_LOCK = threading.Lock() wraps every dispatch (mcp_server.py:5115, held at 5346/5513), commented: "HTTP gives us a safer transport, not concurrent Chroma/HNSW mutation." The primary is a single-writer, one-request-at-a-time service. Size for a handful of agents.
  • mine_palace_lock uses fcntl.flock(LOCK_EX|LOCK_NB) and exists because parallel HNSW inserts "can corrupt the HNSW graph" — but the lock file is HOME-derived (~/.mempalace/locks/ mine_palace_{sha256(realpath(palace))[:16]}.lock) while the key is palace-derived. Two containers with different ~/.mempalace mounts but the same palace path compute the same key on different filesno mutual exclusion.
  • Therefore: reject "put the palace on a NAS/SMB share and point everyone at it." flock over NFS/SMB is unreliable, and the lock wouldn't be shared anyway. One process owning the files and serving HTTP is the supported model — which is exactly why serve exists.

8. Phasing

Phase Effort Deliverable
0 — hygiene hours §7 runbook: move KG/hallways/entities, add provenance metadata, ban sync on shared palaces, per-device tokens, fix stale "unauthenticated" docs
1 — primary up hours, no code mempalace serve --token --tls-cert on a private-net host (reuse docker-compose.mempalace.yml, mind port 8765 vs pi-studio — tor-ms22 already moved to 8766). Repoint pi clients via MEMPALACE_REMOTE_URL/MEMPALACE_REMOTE_TOKEN. Shared memory today, no offline.
2 — mempalace-edge ~1 week The actual ask: local-first writes + outbox flush + merged reads + per-wing policy. Fixes opencode as a side effect.
3 — pull replication ~1 week DEFERRED (2026-08-08). Server-side op-log with monotonic seq → each edge a full offline replica. Only needed if a laptop must hold everything offline. Accepted trade-off: offline recall = own writes + last-reachable state.
4 — authz days Per-wing ACL, per-device scopes, audit log, token rotation

Phase 1 is worth doing on its own — it is pure configuration and immediately ends KG fragmentation for online clients.


9. Open questions

  1. Does opencode's MCP config support a remote/HTTP transport at all? Every sampled entry in myconfigs is "type":"local"; no documentation found either way. If it does not, the edge proxy is not merely nicer — it is the only option for opencode clients. Verify against opencode's MCP client source before Phase 2.
  2. Upstream or local? mempalace-edge needs no core changes, so it belongs in this repo (bin/ + extensions/). But origin_host metadata, per-wing ACL, the kg_supersede classification fix, and a sync --refuse-shared guard all want to go upstream (github.com/MemPalace/mempalace).
  3. Chunked drawers under merge. Oversized content splits into {drawer_id}_chunk_NNNNNN with parent_drawer_id, and the add-time idempotency probe checks only the last chunk id — a partially transferred chunk set may look "already present" and stay truncated. Unverified; test before trusting bulk replay.
  4. migrate.py as a bootstrap tool. extract_drawers_from_sqlite() reads {id, document, metadata} straight out of chroma's SQLite (bypassing the chromadb API) and re-adds them with ids and metadata preserved and embeddings recomputed (migrate.py:326) — the right shape for a one-shot "seed the primary from an existing palace". Unverified: col.add behaviour on id collision into a non-empty target. Test before relying on it. (exporter.py is markdown-only, lossy, and has zero callers — not a transfer format. backups.py is retention pruning only.)
  5. Does _HTTP_REQUEST_LOCK stay held for the duration of an MCP-triggered mine? Strongly suggested by the code shape; if yes, a remote mine makes the primary unusable for its duration (another argument for §5).

10. Evidence index

Re-verify without re-reading the package. Paths relative to /opt/uv-tools/mempalace/lib/python3.13/site-packages/mempalace/ (mempalace 3.6.0) unless noted.

Claim Where
Server auth/TLS/read-only cli.py:1448-1503; mcp_server.py:282-320, 5205-5215, 5284-5289
Single global request lock mcp_server.py:5115, 5346, 5513
KG path follows --palace flag only mcp_server.py:325, 708-711; knowledge_graph.py:49
KG DDL, no (s,p,o) uniqueness, open-fact guard knowledge_graph.py:163-178, 305-313
ID recipes ids.py:25, 56, 71; diary at mcp_server.py:3511-3514
Tool classification (+ kg_supersede gap) service.py:29, 54, 72-84
Write-routing seam write_routing.py:1-60
Outbox prior art (queue DDL, token, retries) daemon.py (_init_db, ensure_token, MAX_ATTEMPTS)
sync is destructive pruning sync.py:1-12
WAL redacts + has no reader wal.py:74, _WAL_REDACT_KEYS
Client-side embeddings backends/pgvector.py:6-8; embedding.py:6-11; _sidecar.py
Dedup scoped by source_file dedup.py:get_source_groups
Store location rules hallways.py:73,83; miner.py:701
Mine lock (HOME-derived file) palace.py:1090, 1123-1128
Migrate as export primitive migrate.py:extract_drawers_from_sqlite, :326
pi remote transport + fail-soft mempalace-toolkit/extensions/pi/mempalace.ts:11-12, 87, 108, 390, 629-641, 665-673 (commit 96699f2)
Server compose + port history pi-devbox/docker-compose.mempalace.yml; pi-devbox/CHANGELOG.md v1.3.0; docker-compose-repo/tor-ms22/pi-devbox/ (df2c2ae)

11. See also