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

+52 -2
View File
@@ -74,6 +74,46 @@ pub struct BooksServer {
exports: ledger_store::exports::Exports,
/// The books' backups (backup.rs).
backups: Arc<crate::backup::Backups>,
/// How these books are kept from agents (plan 3.8, P9a): what
/// `books.start` reports.
isolation: Isolation,
}
/// How the books are kept from agents (plan 3.8, 2.1; D-32, D-51).
///
/// Locally the server runs as the human and the books are hidden, not
/// locked: `SameUser`. On a machine of the owner's the server runs as a
/// dedicated account and agents have no account there: `ServiceUser`.
/// The server never guesses which; `--isolation enforced` says so
/// (`serve --isolation enforced` under the system unit, P9a).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Isolation {
/// Hidden from the agent in the owner's own account, not locked.
SameUser,
/// A separate account on another machine; the agent has none there.
ServiceUser,
}
impl Isolation {
/// What `books.start` says (the strings plan 3.8 names).
pub fn label(self) -> &'static str {
match self {
Self::SameUser => "same_user (advisory)",
Self::ServiceUser => "service_user (enforced)",
}
}
/// What `--isolation` takes: `same_user`, `advisory`, `service_user`
/// or `enforced`.
pub fn parse(text: &str) -> Result<Self, String> {
match text {
"same_user" | "advisory" => Ok(Self::SameUser),
"service_user" | "enforced" => Ok(Self::ServiceUser),
other => Err(format!(
"--isolation takes same_user or enforced, not {other:?}"
)),
}
}
}
/// One book as the books server serves it (local.rs), shared by every
@@ -88,15 +128,23 @@ pub(crate) struct Served {
pub(crate) uploads: ledger_store::uploads::Uploads,
pub(crate) exports: ledger_store::exports::Exports,
pub(crate) backups: Arc<crate::backup::Backups>,
pub(crate) isolation: Isolation,
}
impl Served {
/// [`Self::open_with`] for books served the local way
/// ([`Isolation::SameUser`]): how the server's own tests open them.
#[cfg(test)]
pub(crate) fn open(store: Store) -> Result<Self, String> {
Self::open_with(store, Isolation::SameUser)
}
/// What serving the books `store` holds needs, opened beside them:
/// the settings in force (the books' own), the access log, the uploads
/// and exports, the viewer's side channel, and the backups. The books
/// server opens every book this way (local.rs), the only way books are
/// served (plan D-44).
pub(crate) fn open(store: Store) -> Result<Self, String> {
/// served (plan D-44), saying how the server runs (plan 3.8, P9a).
pub(crate) fn open_with(store: Store, isolation: Isolation) -> Result<Self, String> {
let mut live = Live::own();
live.sync(store.ledger().settings(), store.base_currency().code());
let access = Arc::new(
@@ -130,6 +178,7 @@ impl Served {
uploads,
exports,
backups,
isolation,
})
}
}
@@ -498,6 +547,7 @@ impl BooksServer {
uploads: served.uploads.clone(),
exports: served.exports.clone(),
backups: served.backups.clone(),
isolation: served.isolation,
}
}