# 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 0–2 + 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. **Centralization is strictly opt-in — solitary devbox operation remains the default and must not change (§1.1).** | | **Recon update** | **2026-08-09 — §9 Q1 and Q6 are RESOLVED, both in the permissive direction** (opencode supports remote MCP; opencode-devbox already templates the mempalace entry). Neither is a blocker. See those entries for evidence; §2, §4.1 and R5 were corrected accordingly. | **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 hit** — `kg_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. ### 1.1 Hard requirement: solitary-first, centralization strictly opt-in **This is a requirement, not a preference, and it constrains every choice below.** Centralization solves a problem specific to *this* usage pattern: several machines and containers (pi-devbox, opencode-devbox, native hosts) doing development and other work against a **common set of artifacts**. For most users of the published `joakimp/pi-devbox` and `joakimp/opencode-devbox` images it is **useless** — one machine, one palace, done. Evidence that this is already the norm: all three sampled `opencode-devbox` deployments (`docker-compose-repo/{synlig,nyvaken,devbox-affection}/`) contain **zero** mempalace references. | ID | Requirement | | --- | --- | | **R1** | **Solitary operation stays the default and stays unchanged.** With no `MEMPALACE_*` variables set, a devbox must behave exactly as it does today: harness → local `mempalace-mcp` over stdio, palace at `~/.mempalace`. No extra process, no outbox, no network calls, no new failure mode, no measurable startup cost. | | **R2** | **Opt-in lives in `docker-compose.yml` + `.env`** — the mechanism users already know. No opt-in via image rebuild, no baked-in defaults, no `latest-central` variant. | | **R3** | **Credentials only in `.env`** (`chmod 600`, gitignored). Never in `docker-compose.yml`, never in the image, never on a command line (visible in `ps`), never logged. Compose passes them through with a `${VAR:-}` empty default so solitary users never define them. | | **R4** | **No new required services.** The primary stays in the separate standalone `docker-compose.mempalace.yml` project; it is never merged into the main compose file. A solitary `docker compose up` starts exactly what it starts today. | | **R5** | **Degrade, never fail, when mempalace is absent.** The published `opencode-devbox` image *does* ship mempalace by default (`Dockerfile.base:380,402` — `ARG INSTALL_MEMPALACE=true`, `MEMPALACE_VERSION=3.6.0`, installed via `uv tool install`), but it is a build arg precisely so it can be omitted to save ~300 MB — and a bind-mounted host config or a non-devbox client may have no mempalace either. So any wiring must stay additive and skip when the binary is missing — the probe-and-warn idiom `install.sh` already uses (`warn` + `return 0`, never halt). | | **R6** | **Reversible.** Commenting the `.env` lines out returns the container to pure solitary operation, with the local palace intact and readable. | **Acceptance test for R1** (must pass before Phase 2 ships): bring up a devbox with no `MEMPALACE_*` variables; assert the palace tool list, drawer counts and diary writes are identical to the previous image, and that no edge/outbox process exists (`pgrep -f mempalace-edge` → empty). ### 1.2 The opt-in surface The existing convention is already the right one — **extend it, do not invent a new one.** pi-devbox's `.env.example` lines 12–23 already ship a commented `MEMPALACE_REMOTE_URL` / `MEMPALACE_REMOTE_TOKEN` pair under the heading "MemPalace memory (local by default)", and `docker-compose.yml:79-83` already ships the `devbox-palace` volume commented out. So the opt-in ladder becomes three states derived from two variables — **both existing behaviours are preserved, the third is new**: | `MEMPALACE_REMOTE_URL` | `MEMPALACE_EDGE` | Behaviour | Status | | --- | --- | --- | --- | | unset | — | local stdio `mempalace-mcp`, palace at `~/.mempalace` | **default, today, unchanged (R1)** | | set | unset/`0` | direct remote HTTP; no local palace, no offline | today (`96699f2`), unchanged | | set | `1` | `mempalace-edge`: local-first writes + outbox → primary + merged reads | **new (Phase 2)** | > **Volume coupling reverses under edge mode — document it prominently.** Today `.env.example` correctly > says that with `MEMPALACE_REMOTE_URL` set "*the devbox-palace volume is then irrelevant*", because the > remote owns all state. Under **edge** mode that flips: the local palace holds the outbox and all > local-first writes, so an un-persisted palace means **losing un-flushed writes on container > recreate**. Opting into `MEMPALACE_EDGE=1` therefore *requires* uncommenting > `devbox-palace:/home/developer/.mempalace`. Corollary for §4.1: **`mempalace-edge` must not be in the path unless opted in.** Registering it unconditionally (letting it decide by env at runtime) is tempting — one static config for every harness — but it violates R1 by inserting a process and a failure mode into every solitary user's setup. Selection must happen at registration time. See open question §9.6. --- ## 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** | ✅ **Remote is supported and already wired.** opencode's published schema (`https://opencode.ai/config.json`, `$defs.McpRemoteConfig`) makes `{"type":"remote","url","headers","oauth"}` a first-class sibling of `McpLocalConfig`, `headers` being a free string→string map (so bearer is a convention, not a constraint). `opencode-devbox` already emits exactly that entry when `MEMPALACE_REMOTE_URL` is set. The all-`type:local` configs in `myconfigs` are a *deployment* fact, not a capability limit. | `generate-config.py:107-118`; hook at `entrypoint-user.sh:117`; schema `$defs.McpRemoteConfig` | | **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. Specifically: > `pi-devbox/.env.example:21` still advertises `mempalace-mcp --transport http --host 0.0.0.0 --port > 8765` as the way to serve a shared palace — replace with `mempalace serve --token … --tls-cert …`, > and change the example URL from `http://mempalace.lan:8765/mcp` to `https://`. ### 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-side** — `backends/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`). ⚠️ **Scoped to open facts only** (verified 2026-08-09). The guard's `WHERE … valid_to IS NULL` means an already-**closed** historical fact — one written with `valid_to`, or closed later by `kg_invalidate`/`kg_supersede` — has no guard at all, so replaying it inserts a duplicate row every time. A bootstrap that replays full KG *history* rather than just currently-open facts must dedupe closed facts client-side on `(s,p,o,valid_from,valid_to)` (§4.4). 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) — and the write is a bare `col.add` with **no pre-write probe at all** (`mcp_server.py:3546`), unlike `add_drawer`. The 12-hex suffix is the only usable content dedup key, and it must be applied client-side (§7.6) | | KG triple | `t_{s}_{p}_{o}_{sha256(valid_from\|recorded_at)[:12]}` | **NO** (`recorded_at = now()`) | (`ids.py:56,71` — `ID_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. **One exception, and it is not cosmetic:** `diary_write` has no idempotency guard whatsoever, so for diaries "replay the intent" *duplicates* rather than merges (§7.6). Every other write path either content-addresses or guards. §4.4 carries the per-record-type dedup keys a joining client must therefore bring with it. --- ## 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 — not because opencode lacks remote MCP (it has it, §9.1), but because *offline-first writes* are a client concern that every harness would otherwise reimplement. 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. **But per R1, that one-line change is conditional, not baked in.** The edge binary is present in the image (it costs nothing unused) and inserted into the path only when `MEMPALACE_EDGE=1`: - **pi**: `createClient()` (`extensions/pi/mempalace.ts:629`) already branches on env at startup — add a third branch. Zero change to the default path. - **opencode**: the MCP server entry is static JSON — but **the templating step already exists and already implements the first two rungs of the §1.2 ladder.** `rootfs/usr/local/lib/opencode-devbox/generate-config.py` (run unconditionally from `entrypoint-user.sh:117`) registers `mempalace` as `{"type":"remote", url, headers:{Authorization: Bearer …}}` when `MEMPALACE_REMOTE_URL` is set, else `{"type":"local","command":["mempalace-mcp"]}` when the binary is on PATH, else nothing — R5-compliant already. Its own comment states it uses the "*same env contract as the mempalace.ts pi extension … so one shared MemPalace can serve pi + opencode + native*", i.e. the two images are deliberately kept in step. **Phase 2's opencode work is therefore a third branch in an existing script, not a new mechanism.** > ⚠️ **But the opt-in does not propagate to an existing container.** `generate-config.py` *never* > overwrites an existing config, and `~/.config/opencode` is the named volume > `devbox-opencode-config` (`docker-compose.yml:64,153`) — so a config generated during solitary use > survives recreate, and later setting `MEMPALACE_REMOTE_URL`/`MEMPALACE_EDGE` only produces a > non-loaded `opencode.jsonc.proposed` sidecar for manual merge (`generate-config.py:203-244`). > Flipping the `.env` alone is a no-op there. Phase 1/2 docs must say: merge the sidecar, or > `docker volume rm` the config volume, to adopt the change — and the same applies in reverse for R6 > (reversibility). This asymmetry with pi — whose `createClient()` re-reads env every start — is the > single biggest behavioural difference between the two harnesses under this RFC. > ✅ **Fix shape (decided 2026-08-09): make the `mcp.mempalace` subtree env-authoritative.** Four > verified inputs: > 1. **pi is immune for a *structural* reason worth naming:** its MemPalace wiring is **code, not > config** — `createClient()` reads env at every startup, with no generated file in the path. pi's > own `settings.json` *does* sit on a preserved volume and *does* go stale, and pi-devbox already > solved that properly at `pi-devbox/entrypoint-user.sh:131-162`: `jq -s '.[0] * .[1]'` deep-merge > with **template first, live second** so the user's values always win and only *missing* keys are > filled; arrays treated as leaves (a deliberately removed model is not re-added); rewrite only when > the merge changes something; timestamped `.bak` first; `PI_SETTINGS_MERGE=0` to disable; invalid > JSON on either side → skip, never clobber. **That is the pattern to port.** > 2. **The precedence must be inverted for this one subtree.** pi's "live wins" is right for *adding* > new keys and wrong for a *changed* env value — and a changed `MEMPALACE_REMOTE_URL` is the whole > problem. So `mcp.mempalace` needs env-wins, which is only safe with a **fingerprint**: store a > hash of what was last auto-generated and refresh only while the live value still matches it; > otherwise fall back to the `.proposed` sidecar for that key. `write_proposed` > (`generate-config.py:203-244`) already diffs rendered config against the live file, so this is an > extension of existing logic at narrower granularity, not new machinery. > 3. **No higher-precedence layer exists to hide in.** There is no `OPENCODE_CONFIG*` env override in > opencode's published schema (or anywhere in `opencode-devbox`/`myconfigs`), and MCP registration > is global rather than project-scoped, so the real file must be written. > 4. **The fingerprint is the one genuinely new mechanism** — neither repo has a managed-marker > convention for JSON; the only prior art is the markdown `` idiom. > > This is a **self-contained opencode-devbox change, independent of the rest of this RFC**, and it is > what turns Phase 1 into "flip `.env`, restart" for *both* harnesses — hence Phase 1.5 in §8. ### 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_TOOLS`** → `classify_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: ```sql 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_label` | Provenance, stamped by edge (interim) or the primary (authoritative) — never by the agent (§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. ### 4.4 Joining an existing primary (bootstrap) **Framing correction (2026-08-09).** This is *not* "seed the primary from one chosen palace". Every container joins the same way, at any time, repeatedly — so a join is **idempotent replay of local history**, and the only real question per record type is *what dedupes it*. | Record type | Dedupe on replay | Client work needed | | --- | --- | --- | | `add_drawer` / `checkpoint` drawers | **Server-side**: content-addressed id + pre-write `col.get` probe → `{"reason":"already_exists"}`, no write (`mcp_server.py:2593-2600`) | **None.** Just replay | | KG **open** facts | **Server-side**: `add_triple` guard on `(s,p,o) WHERE valid_to IS NULL` | **None.** Just replay | | KG **closed** facts | **None** — the guard is scoped to open facts (§3.2) | Dedupe on `(s,p,o,valid_from,valid_to)` before sending | | **Diary entries** | **None whatsoever** (§7.6) | Skip any local entry whose `sha256(entry)[:12]` suffix already exists remotely | | Mined drawers | id is path-dependent → same content from two hosts = two rows | Out of scope: mined wings are `local` (§5) | Existence checks available today, with **no new server code**: | Tool | Kind | Fit | | --- | --- | --- | | `get_drawer(id)` | exact id, clean not-found (`mcp_server.py:3103-3114`) | The right check wherever ids are deterministic | | `list_drawers(wing, room, since, before)` | metadata page | Bulk "what does this wing already hold" — the cheap way to collect existing diary id suffixes. Note `since`/`before` filter in **Python**, not in the backend `where` (chroma 1.5.7 rejects string `$gte`/`$lt`) | | `check_duplicate(content, threshold)` | semantic, **whole collection, no wing/room scoping** | One embed + one HNSW query *per call* → the cost driver at thousands-of-records scale. Reserve it for diaries, where nothing cheaper works | | `kg_query` | fact lookup | Redundant for open facts (the guard covers them); useful for closed ones | **Two containers on one host are the *easy* case, not the hard one.** They share a bind-mounted palace, so it is *one* palace joining once, and content-addressing makes even a concurrent double-join harmless for everything except diaries. Belt and braces: - **Keep join state in the shared palace, not in the container** — e.g. `/edge/bootstrap.json` holding `{target_url: {joined_at, high_water_local_seq}}`. Both containers then see "this palace has already joined", a recreate does not repeat the work, and it is the same durable-state-next-to-the-data pattern as the outbox (§4.3). It must **not** live in a container-only path: `~/.mempalace` is not preserved by default for solitary users (§1.2), which is exactly why the file belongs to the palace directory. - **First join is a dry run.** Bootstrap one palace, verify counts (`status`, `kg_stats`, per-wing `list_drawers`) against expectations, *then* let the rest join. Ordering matters only because of diaries and closed facts; everything else is order-free. The genuinely hard case is **two different palaces holding overlapping mined content** — the same repo mined on a laptop and a workstation under different absolute paths. §5 excludes it by keeping mined wings `local`; that exclusion is load-bearing, not tidiness. --- ## 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. **Decided 2026-08-09: diaries are `replicated`.** Cross-machine continuity is the entire point, and diaries are the highest-value content in the palace to share ("*given their value, I say go with (a)*"). Accepted consequence, stated plainly because it follows from the primary being **synlig, a work VM** (§8.1): personal diaries will live on employer infrastructure. They already quote internal hostnames, paths, moods and the occasional token prefix (§6.1 threat 2) — so "don't put secrets in memory" stops being advice and becomes a precondition. **The work/personal boundary is a property of the wing, not of the device.** Rejected design (2026-08-09): splitting the fleet into a work primary and a personal primary along machine lines. The reasoning is decisive — pi-devbox/opencode-devbox are *simultaneously* work and home projects, and personal machines get used for work-adjacent work, so **the device where the work happened cannot classify the project**. That is precisely the axis this table already encodes, which is why `replicated`/`local` per wing is the right knob and a second primary is not needed to express it. **Shape the config so multiple stores stay possible without paying for them now.** Phase 1 keeps `MEMPALACE_REMOTE_URL` a **scalar** (one primary). Per-wing *targets* — this same policy column, plus a destination — belong to the edge config in Phase 2+, so nothing in the `.env` contract has to be un-designed later. --- ## 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: server-stamped provenance on every synced record (§7.3.2), 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. Publish only onto the private overlay / existing tunnel (Pangolin/newt). ⚠️ **The reflexive "bind loopback in the container" is the failure mode here — see Transport.** | | **Transport** | **Decided 2026-08-09: terminate TLS in the existing Pangolin/newt tunnel**, not in `serve` (cheaper than patching Python's TLS surface; one DNS record per service on the web hotel is the whole setup cost). ⚠️ **Host-pinning is coupled to the bind address**, verified: `enforce_host_pin = _http_is_loopback(host)` (`mcp_server.py:5367`). On a **loopback** bind, `Host` is pinned to loopback literals + the bound host, so a proxy forwarding `Host: palace.example.com` gets **403 Forbidden** — either make the proxy rewrite `Host` to `127.0.0.1:`, or bind the private interface instead. On a **non-loopback** bind the pin is deliberately **relaxed** ("*may sit behind a proxy that rewrites Host … lean on the Origin check + optional token instead*", `:5362-5365`), which is also why `cli.py:1450` makes a tokenless non-loopback bind require `--allow-insecure`. **The `Origin` check is never relaxed:** an absent `Origin` is allowed (every non-browser MCP client, incl. pi and opencode), but a *present* non-loopback `Origin` is 403 with no override — so keep browser-based clients and `Origin`-injecting proxies out of the path. | | **Authentication** | Target: **per-device bearer tokens** with a server-side `token → {device_id, scopes}` registry — revoke one laptop, rotate without a fleet outage. **Decided 2026-08-09: Phase 1 ships the single shared token** ("*iterate more feature rich but more complex solutions over time*"), so per-device lands with Phase 4. Store in the existing `.env.age` flow, 0600 on disk. **Consequence: until then the primary cannot tell devices apart, so `origin_device` stays client-asserted and advisory — nothing load-bearing may depend on it (§7.3.2).** | | **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_device` (+ optional `origin_label`) + `op_id` on every record, stamped **server-side from the authenticated credential** — a client-asserted origin is a hint, not a fact (§7.3.2); 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.6 causes silent *duplication* the first time a palace joins (§4.4). ### 7.1 `MEMPALACE_PALACE_PATH` ≠ `--palace` (silent empty KG) ```python # 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 `/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 Provenance belongs to the sync boundary — not to the agent, and not to a solitary container 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 in a merged store you cannot tell which host wrote a record, cannot audit, and cannot compute per-device high-water marks. **Verified 2026-08-09 (`mcp_server.py:2578-2585` drawers, `3527-3536` diaries): those lists are exhaustive — there is no session, PID or conversation field either.** Two consequences, because both are natural questions: - **Two concurrent pi sessions** (the tmux pattern pi's own docs suggest) writing to one palace are **indistinguishable**. Nothing records which session produced which record. - **pi vs opencode is only *accidentally* distinguishable.** `added_by` is a free-form optional string (default `"mcp"`; `checkpoint` resolves explicit arg → diary `agent_name` → `"checkpoint"`), and `extensions/pi/mempalace.ts` **never sets it** for `add_drawer`/`checkpoint` — it sets identity only for diaries (`agent_name` from `$MEMPALACE_AGENT_NAME`, default `pi`, `:758`). `kg_add` has no attribution field at all. So the *only* real harness attribution today is the diary wing, and for drawers the value is whatever string an LLM happened to pass. **Design consequence: device + agent, never session.** Provenance has exactly three consumers — poisoning triage ("which box planted this?"), per-device high-water marks, and revocation — and none of them needs session granularity; adding it would put a field on every record with no reader. Under §6's per-device tokens, **provenance granularity equals token granularity**: one token per host makes two containers on that host a single origin, while a token per container separates them. §7.3.4's `/