Staging default moves out of ~/.cache to <palace-root>/pi-stage (pi) and <palace-root>/opencode-stage (opencode), resolved with mempalace's own palace-path precedence ($MEMPALACE_PALACE_PATH -> $MEMPAL_PALACE_PATH -> ~/.mempalace/config.json -> ~/.mempalace/palace), then dirname. Why: the convos miner keys dedup on the *staged* path, so a wiped stage plus a sync scoped to include it prunes the drawers mined from those sources -- deleting memories, not a cache. Under ~/.cache that state was reachable by anything treating a cache as disposable. Staging inside the palace makes the coupling structural: the stage cannot be wiped without touching the palace itself. Overrides ($MEMPALACE_PI_STAGE / $MEMPALACE_SESSION_STAGE, --stage) are unchanged. Note the old default had never been created on any host, so this closed a latent hazard, not a live one. Measured, and the docs now claim only this much: sync prunes only within the scope it is given -- wing-only, 1299 scanned / 1299 out of scope / 0 removed; scoped at the palace root, 651 kept / 648 out of scope. The previous blanket "sync prunes every drawer" wording overstated it, which is a liability: the next reader disproves the overstatement and discards the real constraint with it. Also in this change: - cron log dir ~/.cache/mempalace-session -> ~/.cache/mempalace-logs. The stage left that namespace, so the old name now read as "the stage". - AGENTS.md: the convos miner *does* check mtime (verified against upstream convo_miner.py); the previous "no mtime check" claim was wrong. - smoke-test assertions use `mktemp -d` for --sessions-dir. One pointed at /tmp, which still held earlier synthetic transcripts, so a --dry-run exported a fake session into the real stage: --dry-run skips the mine, not the export. docs/phase-1-exposure-runbook.md -- the newt/DNS/auth step that RFC 001 and the synlig runbook leave open (runbook section 4, items 2 and 5). Port 8765 at /mcp, newt targets 172.17.0.1, and the authentication is the single shared bearer token (RFC 6.2, decided 2026-08-09) rather than per-device proxy users. The latter cannot work today: mempalace validates exactly one token, and Pangolin's SSO/PIN/password are browser-shaped while every client here is a headless JSON-RPC POST -- enabling that protection breaks the clients it protects. The per-device axis that *does* exist is the feeder's SSH key + per-device inbox. New finding recorded there: a loopback bind does not merely 403 behind a tunnel (already known, runbook 2.4) -- it also silently starts the server with no token at all, because auto-minting is gated on the bind being non-loopback. extensions/pi/README.md: the HTTP transport IS authenticated as of mempalace 3.6.0; the "sessionless and unauthenticated" note dated from the v1.3.0 era. Closes the RFC section 8 Phase-0 hygiene item.
9.3 KiB
synlig primary — Phase 0 runbook and handoff
Companion to rfc-001-global-palace.md. Records what was actually done
on the primary, with verified evidence, so the next session (or the next machine) does not re-derive it.
Status 2026-08-10 00:30 — Phase 0 prep complete. Not serving. Nothing exposed.
Blocked on two things, both deliberately left to Joakim: the Pangolin update on nyvaken, and one sudo.
1. What synlig is (discovered, not assumed)
| Fact | Value |
|---|---|
| SSH | synlig → synlig.erdc.ericsson.net, user ecsjper (from ~/.ssh/config) |
| OS | Ubuntu 24.04.4 LTS, 7.8 GiB RAM, 78 G disk (29 G free), uptime 12 d |
| Python / uv | system python3 3.12.3; uv at ~/.local/bin/uv (not on the non-login PATH) |
| Interfaces | lo 127.0.0.1, ens3 10.0.0.4/16, docker0 172.17.0.1/16, br-… 172.19.0.1/16 |
| Already listening | 22, 80, 443, 3000 (node), 3389 + 3350 + 4822 (xrdp/guacamole), 631 |
| Docker | present; running act_runner-runner-1 (Gitea Actions runner) and digikam |
| Pre-existing MemPalace | none — no mempalace binary, no ~/.mempalace. Greenfield. |
The Gitea Actions runner living here is worth remembering: synlig is not a dedicated appliance, and CI load competes with the palace for the same 7.8 GiB.
2. Done tonight
2.1 MemPalace installed, pinned to the fleet version
~/.local/bin/uv tool install "mempalace==3.6.0" # → mempalace, mempalace-mcp
Pinned deliberately: the clients run 3.6.0, and the id recipes / idempotency probes this RFC leans on are
version-specific. Reversible with uv tool uninstall mempalace.
2.2 Embedder model pre-warmed — the corporate-network risk that wasn't
The first embed pulls all-MiniLM-L6-v2 ONNX (79.3 MB) from the chroma CDN into
~/.cache/chroma/onnx_models/ (167 M on disk once unpacked). This was the main unknown — an
egress-filtered work VM would have failed here, at the worst possible moment (first client write).
It downloaded at ~20 MB/s with no proxy interference. Done in a throwaway palace, since deleted, so the
real palace never saw it. Same model as the clients use, so the semantic space matches.
2.3 Palace created with the §7.1 landmine structurally removed
~/.mempalace/palace — the stock default, so no MEMPALACE_PALACE_PATH and no config.json is
needed anywhere on synlig. One less thing to drift.
RFC §7.1 says to mv three HOME-anchored stores into the palace dir before first serve. On a
greenfield primary there is nothing to move — but the hazard is not actually a migration hazard, and the
RFC understated it: with stock defaults palace_path is ~/.mempalace/palace while DEFAULT_KG_PATH
is ~/.mempalace/knowledge_graph.sqlite3. Those differ, so the split is the out-of-the-box behaviour,
not a consequence of a custom path. It is permanent, not one-time: serve always passes --palace (KG
inside the palace), while any CLI command run without --palace uses the HOME path. Two KGs on one box,
forever, silently.
Fixed by making both resolution rules land on one inode:
ln -sfn palace/knowledge_graph.sqlite3 ~/.mempalace/knowledge_graph.sqlite3
ln -sfn palace/known_entities.json ~/.mempalace/known_entities.json
Relative targets, so a home-directory move survives. hallways.json is deliberately not symlinked:
it is already palace-derived, and its HOME path is a warning-only legacy probe (hallways.py:73-95) that
never auto-migrates.
Verified the symlink assumption rather than trusting it (python3 sqlite3 on synlig, temp dir):
| Check | Result |
|---|---|
Dangling symlink + sqlite3.connect |
creates the target |
-wal / -shm placement |
next to the target, inside the palace dir — not beside the symlink |
| Write via symlink → read via palace path | same data, same inode |
The WAL placement is the part that mattered: it keeps the palace directory a single self-contained backup/bind-mount unit.
2.4 §6.2's Host/Origin policy verified by experiment, not by reading
Ran on synlig, loopback and docker0 binds, then stopped. 11/11 as predicted:
| # | Bind | Request | Expected | Got |
|---|---|---|---|---|
| A1 | 127.0.0.1 | /healthz, correct Host |
200 | ✅ 200 |
| A2 | 127.0.0.1 | /healthz, Host: palace.example.com |
403 | ✅ 403 |
| A3 | 127.0.0.1 | /healthz, Origin: https://evil.example |
403 | ✅ 403 |
| A4 | 127.0.0.1 | POST /mcp, no token |
401 | ✅ 401 |
| A5 | 127.0.0.1 | POST /mcp, wrong token |
401 | ✅ 401 |
| A6 | 127.0.0.1 | POST /mcp, correct token |
200 | ✅ 200 (tools/list → 36 tools) |
| B1 | 172.17.0.1 | /healthz, bound-host Host |
200 | ✅ 200 |
| B2 | 172.17.0.1 | /healthz, Host: palace.example.com |
200 | ✅ 200 |
| B3 | 172.17.0.1 | /healthz, Origin: https://evil.example |
403 | ✅ 403 |
| B4 | 172.17.0.1 | /healthz, loopback Origin |
200 | ✅ 200 |
| B5 | 172.17.0.1 | POST /mcp, foreign Host + token |
200 | ✅ 200 |
Operational conclusions:
- Do not bind loopback behind the tunnel. A2 vs B2 is the whole story: the reflex "bind 127.0.0.1, it's safer" produces a 403 that looks like a Pangolin misconfiguration and is not one.
- Bind
172.17.0.1(docker0). Non-loopback, so the Host pin relaxes — but reachable only from synlig and its containers, so a newt container on this box can reach it while the LAN cannot. This is strictly better than0.0.0.0here. It is whatcontrib/systemd/mempalace-serve.serviceuses. Originis never relaxed (B3). No browser-based MCP client, and no proxy that injectsOrigin./healthzis Host/Origin-gated but token-free — a usable liveness probe for the tunnel.
Test script kept at /tmp/synlig-phase0-test.sh on this container (ephemeral — re-create from the table
above if needed; it starts, probes and stops the server, and asserts nothing is left listening).
2.5 A start unit, written but not installed
contrib/systemd/mempalace-serve.service — user unit, follows the existing contrib/systemd/ style,
carries the bind rationale inline so nobody "fixes" it back to loopback. A copy is already staged on synlig
at ~/.config/systemd/user/mempalace-serve.service.staged — the .staged suffix is deliberate:
systemd only reads *.service, so the file cannot be activated by accident, not even by a stray
daemon-reload. Not installed, not enabled: it needs one sudo loginctl enable-linger, and
standing up a network-reachable service while you were asleep was not mine to decide.
3. Deliberately NOT done
- Nothing is serving. No listener on 8765; no mempalace process. Re-verified at the end of the run.
- No client
.envwas touched. Your working setup is exactly as you left it (R6: reversible). - No data joined. The palace is empty. The §4.4 join needs the diary-dedup decision (§7.6) first — replaying diaries today duplicates them, and the primary is the one place that must stay clean.
- nyvaken untouched. Read nothing, changed nothing.
- No sudo.
sudo -non synlig requires a password.
4. Tomorrow, in order
2026-08-12: items 1–2 and 5 now have their own runbook —
phase-1-exposure-runbook.md. Pangolin on nyvaken is updated (done). newt is still missing on synlig. That doc also records why per-device Pangolin users are the wrong layer, and an additional loopback finding: a loopback bind does not merely 403, it also silently starts the server with no token at all (auto-minting is gated on the bind being non-loopback).
- Pangolin update on nyvaken (yours). ✅ done 2026-08-12.
- ⚠️ synlig has no tunnel client.
docker psshows only the Gitea runner and digikam — nonewt. Pangolin on nyvaken cannot reach synlig by itself; synlig needs a newt container (or an equivalent outbound tunnel) added. Easy to miss because Pangolin will look healthy on its own side. If newt runs in Docker here, the docker0 bind above is already correct for it. - One sudo, then start (the unit is already staged; just drop the suffix):
sudo loginctl enable-linger ecsjper cd ~/.config/systemd/user && mv mempalace-serve.service.staged mempalace-serve.service systemctl --user daemon-reload && systemctl --user enable --now mempalace-serve curl -s 172.17.0.1:8765/healthz # ok curl -s 127.0.0.1:8765/healthz # 403 — expected, not a bug (§2.4) - Collect the shared token (auto-minted on first non-loopback start, stable across restarts):
That directory name is
cat ~/.mempalace/server/f5d849287f6d73f0141b29d7/tokensha256(realpath(palace))[:24]— it changes if the palace path ever changes. - Route it through Pangolin, then verify
/healthzend-to-end through the public hostname before pointing any client at it. - Then, and only then, Phase 1 client flip — one machine first, and remember opencode containers
need the §4.1 sidecar merge (or Phase 1.5) before the
.envtakes effect. - Before the first join: settle §7.6 diary dedup, then dry-run §4.4 from one palace and check counts.
5. Rollback
systemctl --user disable --now mempalace-serve # if enabled
~/.local/bin/uv tool uninstall mempalace
rm -rf ~/.mempalace # empty today — check before running once it isn't