feat(deploy): self-host recipe, the enforced isolation flag, and the tunnel's door (P9a)

The books can live on a machine of the owner's own (plan 3.8, 8.2 P9a,
D-51). Three things make that honest instead of nominal:

- `--isolation same_user|enforced` sets `server::Isolation`, carried
  through `Served` and `BooksServer` to `books.start`. Locally the books
  stay `same_user (advisory)`; under the self-host unit the server is told
  `enforced` and reports `service_user (enforced)`. The server never
  guesses, and the literal it used to report is gone.
- `--http [ADDR]` binds the same MCP router on loopback (default
  127.0.0.1:7411) beside the Unix sockets, before the ready line, for the
  TLS proxy or tunnel in front of the server. It is the same bearer door
  and the same graceful close; a failed bind is reported and is not fatal
  (3.9). `serve` carries both flags through the host, so the system unit
  can ask for them once.

`deploy/wanderbooks-server.service` is the unit: a dedicated account, a
0700 data root, `ProtectSystem=strict`, no seccomp or Landlock (D-32), and
loopback-only networking. `deploy/README.md` is the recipe: install, setup,
move the books by `books adopt` with its head check (D-31: a copied book
is the transport), mint a bearer token, put Caddy or Tailscale Funnel in
front of the loopback door, and point Claude Code and Codex at it.

The two-account CI job (`.github/workflows/two-user.yml`) makes the
deployment and runs `crates/ledger-cli/tests/two_user.rs` as another
account: `agent_uid_cannot_read_tenant` proves the tenant root and its
journal are refused, then reads, writes, uploads, files, exports and
verifies over HTTP with the bearer (in a second book, so the moved books'
head is not advanced); `self_host_adopt_same_head` compares the moved
books' head and event count with the source clone's.
`isolation_label_is_enforced_under_the_unit` runs everywhere, including
the unit-file read that keeps the recipe from drifting.

Accounting reason for the shape: nothing here changes an entry, a report
or a figure. It changes who can reach them: the move is lossless and
verifiable at the head, and the boundary is the operating system's, not a
promise in our text. No journal code, kernel function, Kani harness or
Lean step is touched, so `just prove` is not required.

No go-ahead needed: no new journal feature or event; the live books are
unaffected.
This commit is contained in:
swaits committed 2026-10-27 09:35:36 -06:00
1 parent cc4f104f06
commit 1441dabcca
18 files changed
+1269 -27

No files matched your search

+32
View File
@@ -220,3 +220,35 @@ Neither bookspec crate depends on `ledger-core`; the dependency points
inward from the adapter. `just check` runs the bookspec workspace's
format, clippy and tests beside the wanderbooks workspace's, so an example
cannot rot (P13 step 1).
## Self-host: the same server, another account (P9a, D-51)
The books can live on a machine the owner controls (plan 3.8). It is the
same `ledger-mcp` server, the same store and the same tools; only the
process boundary changes. `wanderbooks serve` runs as the dedicated
account `wanderbooks` under `deploy/wanderbooks-server.service`, over a
data root only that account can read (`/srv/wanderbooks`, 0700). Agents
have no account on that machine, so the isolation is enforced by the
operating system rather than advisory: the server is told
`--isolation enforced` (the flag, never a guess) and `books.start`
reports `service_user (enforced)` (`server::Isolation`).
The door is the same HTTP service on a second listener:
`--http 127.0.0.1:7411` binds the MCP routes beside the Unix sockets
(3.9), and Caddy, Tailscale Funnel or cloudflared puts TLS and the public
name in front of it. The bearer token guards every call exactly as on the
socket; the tunnel only forwards. The socket's peer-credential check
(`OwnerOnly`) is for local callers and does not apply to the loopback
door, which is why the bearer and the loopback-only bind matter there.
The flag travels `serve` → `ledger-host`'s host → `server`
(`Opener::starting_with`, `start::start_host`, `host::Args`), so a host
already running with other flags keeps them; on a self-host machine
`serve` is the unit's only holder.
The unit sets `IPAddressDeny=any` and `IPAddressAllow=localhost`: the
server reaches nothing out. A book whose backup target is a remote git
repository therefore needs that host allowed in a drop-in (D-31 backups
still run on the server). The recipe, the head check for the move and the
two-account CI job are in `deploy/README.md`, `deploy/wanderbooks-server.service`
and `.github/workflows/two-user.yml`; the checks are
`crates/ledger-cli/tests/two_user.rs`.