diff --git a/docs/rfc-001-global-palace.md b/docs/rfc-001-global-palace.md index 32b1e98..ea6fff9 100644 --- a/docs/rfc-001-global-palace.md +++ b/docs/rfc-001-global-palace.md @@ -128,6 +128,11 @@ independently disqualifying: 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? | @@ -135,7 +140,7 @@ independently disqualifying: | `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 | + | 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.) @@ -148,14 +153,20 @@ independently disqualifying: 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, and it -may not support remote MCP at all (§9.1). Instead: a sidecar that *is* an MCP server. +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) ─┐ @@ -206,6 +217,32 @@ image (it costs nothing unused) and inserted into the path only when `MEMPALACE_ > (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`, @@ -262,6 +299,47 @@ backoff, resume on reconnect. The primary de-dupes on `op_id`; `add_drawer`'s ow 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 @@ -277,6 +355,25 @@ This shrinks the hard problem to the layer that is already safe, and it is why * 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 @@ -300,9 +397,9 @@ The transport is largely solved; **the gap is authorization, not cryptography.** | 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. | +| **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`). | @@ -312,7 +409,8 @@ The transport is largely solved; **the gap is authorization, not cryptography.** ## 7. Landmines — Phase 0 runbook -Do these **before** any cutover. 7.1 and 7.2 cause silent data loss. +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) @@ -357,6 +455,27 @@ Today every drawer carries exactly `{wing, room, source_file, added_by, filed_at 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 `/