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`.
+8 -5
View File
@@ -115,9 +115,12 @@ settings-and-conventions section says how to use them.
(below); its next step comes first, before anything else, at every
start until it is done: `books.handbook {section: "format-upgrade"}`,
"Tell the human first: …".
`isolation` is `same_user (advisory)` (D-32): the books are hidden from
agents in the owner's own account, not locked against them. Lists cut
to fit say so in `truncated` ("conventions: 4 more (books.conventions)").
`isolation` is `same_user (advisory)` locally (D-32): the books are
hidden from agents in the owner's own account, not locked against them.
On the owner's own machine, where the server runs as a separate account
and agents have none, the unit passes `--isolation enforced` and it is
`service_user (enforced)`; the server never guesses (P9a, 3.8). Lists
cut to fit say so in `truncated` ("conventions: 4 more (books.conventions)").
`projects` lists the active and paused projects by name, at most 20
("projects: N more (books.projects)" past that).
`next_steps`, in order: the format upgrade's (when waiting); the
@@ -326,8 +329,8 @@ The long guidance is the **handbook**, compiled in from
contract, entries, drafts-and-reversals, evidence-and-receipts,
attachments, files-in-out, reconciliation, fixed-assets,
prepaid-and-recurring, revenue-and-payouts, parties-and-tins, 1099,
cpa-handoff, settings-and-conventions, viewer, backups, format-upgrade,
rules-that-change, control-roles, tracing-figures.
cpa-handoff, settings-and-conventions, viewer, backups, self-host,
format-upgrade, rules-that-change, control-roles, tracing-figures.
- The same as resources: `books://handbook` (the index, JSON) and
`books://handbook/{section}` (Markdown, `text/markdown`), one listed per
section.
+13
View File
@@ -91,3 +91,16 @@ trait), the runner (`bookspec/runner`: pass, fail or not claimed per rule),
and the rule index from B-1 to B-45 (`bookspec/spec/rules-index.md`). Every
existing bookspec example still runs on every `cargo test`, through both
the loader's own checks and the `Engine` trait.
### P9a self-host deployment (2026-10-26)
P9a is a deployment, not a semantic feature: the same engine, store, MCP
surface and reports on a machine of the owner's own. No row above changed
status and the floor stands. What it adds is the enforced isolation the
catalog's parity claims rest on in practice: a dedicated account, a data
root only that account can read, `books.start` saying
`service_user (enforced)` (`--isolation`), and the same bearer-guarded MCP
door on loopback for Caddy or a tunnel. `crates/ledger-cli/tests/two_user.rs`
proves the account boundary and the move's head in CI
(`.github/workflows/two-user.yml`); the recipe is `deploy/README.md`.
The cutover rule in `docs/capability-parity.md` is unchanged.
+72
View File
@@ -2970,3 +2970,75 @@ negative-zero test; `filter(|a| a.tool.is_some()).take(12)` and
intact-blob set (a damaged blob reported per attachment) and `Store::
disk_check`'s snapshot load and `Unreadable` arm; the viewer wrapper's
`Target` arms.
## P9a: self-host, the move, and the static bearer over a tunnel (2026-10-26)
Plan 8.2 P9a, D-51. The books on a machine of the owner's own: the same
server as a dedicated account (`deploy/wanderbooks-server.service`), a
data root only that account can read, `books.start` saying
`service_user (enforced)` when the unit passes `--isolation enforced`, the
move by `books adopt` with its head check, and the same MCP service on a
loopback door (`--http`) for Caddy or a tunnel.
- **ledger-mcp:** `server::Isolation` (`label`, `parse`), carried from
`--isolation` through `Served::open_with` into `BooksServer`, and read by
`books.start`'s `start_out` (`tools/kept.rs`); `--http` binds a second
`axum::serve` listener over the same router and the same door, before the
ready line, and closes with the same graceful shutdown (a failed bind is
said and is not fatal, 3.9). `common/mod.rs`'s harness is unchanged; the
local flag test starts its own server (`two_user.rs::FlaggedServer`)
because `common::Server` takes no extra flags.
- **ledger-host:** `serve --isolation/--http` → `Opener::starting_with` →
`start::start_host` → `host::Args` → the server's command line. A host
already running keeps the flags it was started with (documented in
`deploy/README.md` and the opener's comment).
- **`crates/ledger-cli/tests/two_user.rs`:**
`isolation_label_is_enforced_under_the_unit` (a server with no flag says
`same_user (advisory)`; with `--isolation enforced --http` it says
`service_user (enforced)` over the Unix socket and over the loopback
door, and both report the same head; the unit file itself is read to
require the `--isolation enforced`, `--http` and `IPAddress*` lines the
test exercises);
`agent_uid_cannot_read_tenant` and `self_host_adopt_same_head` (two
accounts, run by `.github/workflows/two-user.yml`; they skip with a line
when `WB_TWO_USER` is unset, so a laptop's suite stays green). The first
proves `read_dir` and a journal `open` are refused with
`PermissionDenied`, then reads (`books.chart`), writes
(`books.add_account`), uploads (`books.begin_upload` + the `u/` route),
exports (`books.export` + the `x/` route, checked against its SHA-256)
and verifies (`books.verify {blobs:true}`) with the bearer over HTTP, in
a second book so the moved books' head is never advanced; the second
reads the source clone's journal, computes its head from the last line's
canonical bytes, and requires the moved books' `books.start` and
`books.verify` to report the same seq, hash and event count.
- **The job:** `.github/workflows/two-user.yml` makes the accounts, adopts
the repository's `p2-served` fixture into a desktop root at this version,
copies the book as the transport (D-31), adopts it into
`/srv/wanderbooks` behind `--isolation enforced --http`, mints a token
with the `file_keyring` test knob (a runner has no Secret Service), and
runs the test as the runner. `deploy/README.md` is the same recipe by
hand; the job does not install the systemd unit (it drives `serve` with
the same flags), so the unit's lines are checked by the test's file read
and by review, not executed as a unit.
- **Not covered:** the recipe walked on a clean VM by someone other than
its author (Done when 2), a real TLS tunnel (Tailscale Funnel or Caddy)
and a real Secret Service on a headless server. Those are the owner's
and the EM's checks; the As-built block says so.
- **Docs:** `docs/architecture.md` "Self-host", `docs/mcp-interface.md`
(`books.start`'s two `isolation` values), `docs/parity.md` (no semantic
row moved; the floor stands), the handbook's `self-host` section, and
`deploy/README.md`.
**For P12 (mutation targets, none run, D-33):** `Isolation::parse`'s
`same_user`/`advisory` and `service_user`/`enforced` arms (a wrong arm
would report the advisory truth on the enforced machine);
`Isolation::label`'s two strings; `start_out`'s `self.isolation.label()`
(it replaced a literal, and a mutant that restores a literal must fail the
two-door test); the `--http` arm's `args.peek()` guard and the default
`127.0.0.1:7411` (binding the wrong interface, or consuming the next flag,
must fail); `tunnel.is_some()` and the bind failure path (ready must still
come after the try, and a failed bind must not be fatal); `open_with`'s
`isolation` into `Served` and `BooksServer::new`'s copy (a dropped field
must fail `isolation_label_is_enforced_under_the_unit`); the host's
`server_flags` extension and `parse_args`' passthrough of `--isolation`
and its optional `--http` value.