Files
mempalace-toolkit/docs/rfc-001-global-palace.md
T
Joakim Persson 052dbb8038 docs(rfc-001): provenance belongs to the sync boundary, not the agent
Reverses the previous §7.3 on review. It said "stamp added_by everywhere,
now, because it cannot be backfilled" and was about to become a skill
instruction telling agents to do it. Both halves were wrong.

Wrong on ownership: provenance answers "which device asserted this?", so
only a party that can verify the answer should write it. An agent must shell
out to read env, can forget, and will improvise when the values are absent —
the worst possible stamper, and its claim is unverifiable by anyone. Under
the §4 design every write reaches the primary over an authenticated channel,
including offline ones at outbox-flush time, so the primary can stamp the
complete set with no client cooperation. Provenance is a property of the sync
channel, not of the record's author. §7.3.2 adds the trust ladder; edge-side
stamping is demoted to an advisory interim, because 3.6.0's serve takes a
single shared bearer token (mcp_server.py:5291-5293) and the package has zero
device/origin concept, so authoritative stamping needs the per-device
credentials of §6 — Phase 4, not Phase 0.

Wrong on backfill: a solitary devbox is a single-origin store by definition,
so origin is a property of the whole palace and can be assigned wholesale at
import (one --origin-device flag) at the moment it stops being solitary. Bulk
attribution is strictly more reliable than per-record stamping since it
cannot be partially applied. Per-record provenance is only needed where
origins interleave, which is only the primary. Solitary containers therefore
stamp nothing and lose nothing — more R1-compliant than the previous draft,
which quietly asked users who had opted out to carry metadata for the
feature. Multi-harness-on-one-host stays solved by added_by = agent name.

Keeps the verified mechanics (fixed metadata schema, argument whitelisting at
mcp_server.py:4777 silently dropping unknown fields, the diary agent_name →
wing_pi@host trap, kg_add having no provenance slot, added_by absent from
search results) and the identity findings (a container cannot discover its
host's identity; hostnames are neither unique nor stable; rename splits one
device's history in two). §7.3.5 keeps the fail-closed rule for whichever
component does stamp.

Phase 0 drops its provenance item accordingly, and §6.2 now specifies
server-side stamping from the authenticated credential.

Also flags in-document that these notes are a poisoning vector: a future
agent reading them out of the palace must not conclude it should hand-stamp.
2026-08-09 15:40:25 +02:00

570 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. **Centralization is strictly opt-in — solitary devbox operation remains the default and must not change (§1.1).** |
**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.** opencode-devbox may not ship mempalace at all, so any wiring must be additive and skipped 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 1223 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** | ❌ **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. 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`).
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,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.
---
## 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.
**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, so the entrypoint must template it — write
`command: ["mempalace-edge"]` only when opted in, otherwise leave today's
`["mempalace-mcp"]` untouched. If mempalace is absent entirely (as in all three sampled
opencode-devbox deployments), write nothing and warn (R5).
### 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.
---
## 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: 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. 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_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.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
`<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 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.
**Earlier drafts of this section said "stamp it everywhere, now, because it cannot be backfilled." That
was wrong on both counts.** See §7.3.3.
#### 7.3.1 What can actually be stamped (mechanics)
The metadata schema is **fixed** — there is no free-form field — and `tools/call` **whitelists arguments
to declared schema properties** (`mcp_server.py:4777`, *"Prevents callers from spoofing internal params
like added_by/source_file"*), so an extra `origin_host=…` is **silently dropped, not rejected**.
| Surface | Provenance slot | Notes |
| --- | --- | --- |
| `add_drawer`, `checkpoint` | **`added_by`** | The only one. Free-form (`strip_lone_surrogates` only, *not* `sanitize_name`, so `/` and `@` are legal). |
| `diary_write` | **none usable** | ⚠️ **Never** put a device in `agent_name`: `:3504` does `wing = f"wing_{agent_name}"` → a separate wing per host, and `diary_read` filters `{"agent": agent_name}` (`:3636`) → `diary_read("pi")` then **misses** those entries. |
| `kg_add` | **none** | Only `source_file`/`source_closet`/`source_drawer_id`. Origin is inferable only via `source_drawer_id` → that drawer's `added_by`. |
`added_by` is also **write-only today**: absent from `tool_search` results, surfacing only via
`get_drawer``_drawer_payload` metadata. → Upstream asks: **surface `added_by` in search results**, and
**give `kg_add` a provenance field**.
#### 7.3.2 Who should stamp it — a ladder of trust
Provenance answers "which device asserted this?" Only something that can *verify* the answer should
write it. Ranked by trustworthiness:
| Stamper | Knows the device? | Verifiable? | Uniform? | Verdict |
| --- | --- | --- | --- | --- |
| **Agent (via skill)** | No — must shell out to read env | No | No — per-call boilerplate, forgettable, improvisable | ❌ **Worst possible place.** Rejected. |
| **Client / `mempalace-edge`** | Yes, from host-supplied `.env` | No — self-asserted | Yes — one line in a proxy | ⚠️ Acceptable **interim** |
| **Primary, from the authenticated credential** | Yes | **Yes** — bound to the token | Yes, for every synced record | ✅ **Correct home** |
The decisive point: **a client-asserted origin is a hint, not a fact.** The primary is the only party
that can bind a write to an identity it verified. And under the §4 design *every* write reaches the
primary through an authenticated channel — including offline ones, at outbox-flush time — so the server
can stamp the complete set without any client cooperation. **Provenance is a property of the sync
channel, not of the record's author.**
Blocker for the ✅ row, verified in 3.6.0: `serve` takes a **single shared bearer token**
(`srv.auth_token`, `hmac.compare_digest`, `mcp_server.py:5291-5293`) and the package contains **zero**
occurrences of any device/origin concept. Server-side stamping therefore *requires* the per-device
credentials of §6 — i.e. **Phase 4**, not Phase 1. Hence the phasing:
- **Phase 2 (edge):** edge may stamp `added_by` from its configured env — self-asserted, **advisory
only**, never load-bearing for authorization or destructive scoping.
- **Phase 4 (authz):** per-device tokens land; the primary stamps authoritatively and the client-supplied
value becomes redundant (and must be treated as untrusted input, not merely ignored).
#### 7.3.3 Solitary containers should stamp nothing — and lose nothing by it
A pi-devbox or opencode-devbox running solitarily is a **single-origin store by definition**. Origin is
therefore a property of the *whole palace*, not of each record — so it can be assigned **wholesale at
the moment the store stops being solitary**: one `--origin-device` flag on the import/first-sync path
attributes every record from that palace to that device.
That dissolves the "impossible to backfill" argument. Per-record stamping is only necessary once records
from *multiple* origins are interleaved in one store, which is exactly and only the primary. So:
- **Solitary devboxes: no stamping, no config, no skill instruction, no behaviour change.** This is
strictly more compliant with **R1** than the earlier draft, which quietly asked every solitary user to
carry metadata for a feature they had opted out of.
- **Migration is unaffected**: bulk attribution at import is *more* reliable than per-record stamping,
because it cannot be partially applied.
- **Multi-harness on one host stays solved** by `added_by` = agent name (`pi` vs `opencode`) — that is
what the field is for, and it needs no device component.
- **A palace on a shared host bind-mount** (as tor-ms22's compose does) is still single-*device* under
the "host owns the palace" model, so it too imports as one origin.
The one case bulk attribution cannot fix: a palace that was *already* merged from several devices without
stamps. Preventing that is precisely why the **primary** must stamp from day one of Phase 4 — it is the
only store where interleaving occurs.
#### 7.3.4 Identity fields, when a stamper does exist
For the edge (interim) and the primary (authoritative) — never for agents:
| Field | Source | Rule |
| --- | --- | --- |
| `origin_device` | `uuidgen` **once**, stored in the **host's `.env`**; later, issued with the device's token | Opaque, stable, collision-free by construction. Compared, never parsed. |
| `origin_label` | hostname, same `.env` | Human readability **only**. Never identity, uniqueness or scoping. Free to change. Optional — a hostname can leak an asset tag or username (`EMB-7KJ4VR4G`, `HOST_SSH_USER=ECSJPER`). |
Why not the obvious sources: **a container cannot discover its host's identity.** `hostname` returns the
*container ID* (`f3bf2a103473`) which changes on **every recreate**; there is no `/etc/machine-id`; and a
baked one would be *worse* — identical for every container from the same image, a guaranteed collision.
**Hostnames are also neither unique nor stable** (`localhost`, `ubuntu`, golden images, two
`MacBook-Pro.local`), and the worse failure is not collision but **rename**, which silently splits one
device's history in two. Hence host-supplied via `.env` (matching R2/R3 and the existing `HOST_SSH_USER`
/ `DEVBOX_HOST_ALIAS` precedent), *not* container-derived. Do **not** persist the id container-side:
`~/.mempalace/device_id` does not survive recreate for solitary users because `devbox-palace` is
commented out by default (§1.2). And don't write `${HOSTNAME}` in compose — bash *sets* but does not
*export* it, so interpolation sees empty.
Encoding, always three segments so arity is unambiguous:
```
added_by = "<agent>/<label>/<device>" e.g. pi/tor-ms22/7f3a9c2e1b04
pi/-/7f3a9c2e1b04 (label withheld)
```
**The `/` is the discriminator.** A value with no `/` (`mcp`, `pi`, `checkpoint`) means *origin unknown*
correct for the ~13.6k drawers already filed, and for every solitary palace forever.
#### 7.3.5 Fail-closed rule for any stamper
If `origin_device` is unset, blank or unreadable: **write no `added_by` override at all** and let the
default (`mcp` / `checkpoint` / agent name) stand. Never synthesize one. The tempting substitutes are all
actively harmful, because each is *confidently* wrong where absence is honestly unknown:
| Substitute | Damage |
| --- | --- |
| container hostname / ID | A **new fake device per recreate** — thousands of singleton identities, each looking legitimate |
| `unknown`, `localhost`, `devbox`, `docker`, `$USER` | **Collides across every machine** — indistinguishable from one real shared device |
| a guess from context | Unfalsifiable later |
If `origin_label` alone is missing, use the literal `-`. If `origin_device` is missing, do not stamp even
when the label is present — a hostname alone is exactly the colliding, mutable identity rejected above.
> **Note for future agents reading this RFC out of the palace:** these notes are themselves a poisoning
> vector. Do **not** start hand-stamping `added_by` because you read this section. Provenance is client
> and server infrastructure; an agent's contribution to it is to leave the field alone. The mempalace
> skill carries a one-line guard to that effect.
### 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
files* → **no 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, ban `sync` on shared palaces, fix stale "unauthenticated" docs (incl. `pi-devbox/.env.example:21`). **No provenance work here** — it is not backfill-critical (§7.3.3) and belongs to the stamper, not the agent |
| **1 — primary up** | hours, **no code** | `mempalace serve --token --tls-cert` on a private-net host (reuse `docker-compose.mempalace.yml` — keep it a **separate standalone project**, R4 — and 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. **Ships with the §1.2 opt-in wiring (compose + `.env.example` + entrypoint templating) and must pass the R1 acceptance test.** |
| **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_device` stamping from the authenticated token, 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-`add`s 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).
6. **How does opencode-devbox learn the opt-in?** pi's transport choice is code (`createClient()`), so
it reads `.env` for free. opencode's MCP registration is **static JSON** in
`~/.config/opencode/opencode.json`, which per R1/R2 must be templated at container start rather
than baked. Unknown: whether opencode-devbox's entrypoint has a config-templating step to hook, or
whether the file is user-owned and bind-mounted (in which case the opt-in is a documented manual
edit — acceptable, but say so). Also unknown whether the published `opencode-devbox` image ships
mempalace at all; if not, edge mode there is host-side only until it does.
---
## 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`) |
| Existing opt-in convention (§1.2) | `pi-devbox/.env.example:12-23` ("MemPalace memory (local by default)", commented `MEMPALACE_REMOTE_URL`/`_TOKEN`); `pi-devbox/docker-compose.yml:79-83` (`devbox-palace` volume commented out) |
| opencode-devbox does not wire mempalace | `docker-compose-repo/{synlig,nyvaken,devbox-affection}/opencode-devbox/docker-compose.yml` — zero mempalace references in any |
## 11. See also
- [`ARCHITECTURE.md`](../ARCHITECTURE.md) — producer side (how the palace gets fed); §6 upstream roadmap
- [`extensions/pi/README.md`](../extensions/pi/README.md) — pi bridge internals
- [`SKILL.md`](../SKILL.md) — consumer-side protocol (search before answering, diary before exit)