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

+29
View File
@@ -0,0 +1,29 @@
# On a machine of the owner's own
These books may live on a machine the owner controls, not on the computer
an agent runs on. The server there runs as a separate account, and the
agent has no account on that machine: `books.start` says
`isolation: "service_user (enforced)"`. Locally it says
`same_user (advisory)`, which is honest: hidden, not locked.
What that means for you:
- Reach the books only through these tools. The machine they live on is
not yours to inspect, and you have no way to reach it except through
the server.
- The owner moves the books there with a verified copy or a backup, and
the move is confirmed at the head the preview read. If `books.verify`
or `books.start` reports a head the owner does not expect, say so and
stop; do not touch the books until the owner has looked.
- Each client has its own token. Never ask the owner for one, never print
one, and never put one in a note, an entry, a receipt or a file in the
books. If a tool says the token was refused, ask the owner to make a
new one for that client.
- Everything else is the same as on the owner's computer: the receipt
rule, the preview before a post, the books' own settings and
conventions. The machine changes where the books are, not how they are
kept.
If the owner asks how the machine is set up, point them at the deployment
notes that came with the app; do not invent steps for them, and do not
try to run any yourself.
+1
View File
@@ -86,6 +86,7 @@ pub const SECTIONS: &[(&str, &str)] = &[
),
("viewer", include_str!("../handbook/viewer.md")),
("backups", include_str!("../handbook/backups.md")),
("self-host", include_str!("../handbook/self-host.md")),
(
"format-upgrade",
include_str!("../handbook/format-upgrade.md"),
+73 -5
View File
@@ -60,7 +60,7 @@
//! ```
use crate::admin::{self, Request, Response};
use crate::server::{BooksServer, Served, SharedStore};
use crate::server::{BooksServer, Isolation, Served, SharedStore};
use ledger_store::Store;
use ledger_store::access::{Access, AccessLog, Outcome, Principal};
use ledger_store::auth::{Principals, Scope, Token};
@@ -187,6 +187,9 @@ struct Books {
drill_first: Duration,
/// Held through a restore: one at a time.
restoring: Mutex<()>,
/// How these books are kept from agents (plan 3.8, P9a): what
/// `books.start` reports (`--isolation`).
isolation: Isolation,
}
impl Books {
@@ -294,7 +297,7 @@ impl Books {
}
let waiting = Arc::new(Mutex::new(store.journal_format().upgrade()));
let database = (store.database_file(), store.other_databases());
let served = Served::open(store).map_err(|e| self.hide(e))?;
let served = Served::open_with(store, self.isolation).map_err(|e| self.hide(e))?;
let config = StreamableHttpServerConfig::default()
.with_json_response(true)
.with_legacy_session_mode(true)
@@ -1853,9 +1856,13 @@ fn bind(run: &Path, name: &str) -> std::io::Result<tokio::net::UnixListener> {
}
fn print_usage(prog: &str) {
eprintln!("usage: {prog} [--root DIR]");
eprintln!("usage: {prog} [--root DIR] [--isolation LEVEL] [--http [ADDR]]");
eprintln!(" The books server for the data root: started by the host, not by hand.");
eprintln!(" It runs until its standard input ends, or SIGTERM, SIGINT or SIGHUP.");
eprintln!(" --isolation same_user | enforced: what books.start says of how these books");
eprintln!(" are kept from agents (enforced on the owner's own machine: P9a).");
eprintln!(" --http [ADDR]: also serve the MCP door on 127.0.0.1:7411 (or ADDR), for a");
eprintln!(" TLS proxy or a tunnel in front of it. Off unless given.");
}
/// `wanderbooks server`: serve every book in the data root until told to
@@ -1867,10 +1874,34 @@ pub fn run(
let mut root: Option<PathBuf> = None;
let mut backup_quiet = BACKUP_QUIET;
let mut drill_first = DRILL_FIRST;
let mut args = args.into_iter();
let mut isolation = Isolation::SameUser;
let mut tunnel: Option<std::net::SocketAddr> = None;
let mut args = args.into_iter().peekable();
while let Some(arg) = args.next() {
match arg.as_str() {
"--root" => root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?)),
// How the books are kept from agents (plan 3.8, P9a): the
// system unit on the owner's own machine passes `enforced`;
// the server never guesses.
"--isolation" => {
let value = args
.next()
.ok_or("--isolation needs same_user or enforced")?;
isolation = Isolation::parse(&value)?;
}
// The tunnel's door (plan 3.9, P9a): the same MCP service on
// 127.0.0.1, for a TLS proxy or tunnel in front of it. Off
// unless asked for; the bearer token still guards every call.
"--http" => {
let addr = match args.peek() {
Some(v) if !v.starts_with('-') => args.next().unwrap_or_default(),
_ => "127.0.0.1:7411".to_string(),
};
tunnel =
Some(addr.parse().map_err(|e| {
format!("--http takes an address like 127.0.0.1:7411: {e}")
})?);
}
// How long the books stay unwritten before a backup (tests).
"--backup-quiet" => {
let ms: u64 = args
@@ -1967,6 +1998,7 @@ pub fn run(
backup_quiet,
drill_first,
restoring: Mutex::new(()),
isolation,
});
{
let loaded = books.load_principals();
@@ -1984,7 +2016,7 @@ pub fn run(
eprintln!("wanderbooks server: told to close while opening: closed");
Ok(())
} else {
rt.block_on(serve(books.clone(), &run, &stops))
rt.block_on(serve(books.clone(), &run, &stops, tunnel))
};
// No request is running now (serve waited for each); the stores go
// before the lock, and the process exit would release both.
@@ -2024,9 +2056,26 @@ async fn serve(
books: Arc<Books>,
run: &Path,
stops: &Stops,
tunnel: Option<std::net::SocketAddr>,
) -> Result<(), Box<dyn std::error::Error>> {
let mcp_listener = OwnerOnly(bind(run, MCP_SOCKET)?);
let mut admin_listener = OwnerOnly(bind(run, ADMIN_SOCKET)?);
// The tunnel's door (plan 3.9, P9a): the same service on 127.0.0.1,
// for the TLS proxy or tunnel in front of it. Bound before ready, so
// what starts beside the server finds it; a bind that fails is said
// and is not fatal (3.9: the books' own sockets still serve).
let tunnel_listener = match tunnel {
Some(addr) => match tokio::net::TcpListener::bind(addr).await {
Ok(listener) => Some(listener),
Err(e) => {
eprintln!(
"wanderbooks server: the tunnel's door at {addr} can't be bound ({e}); serving the local sockets only"
);
None
}
},
None => None,
};
let stop = stops.close.clone();
{
use std::io::Write;
@@ -2083,11 +2132,21 @@ async fn serve(
// close is done: a client is never cut off mid-request by it.
let shut = CancellationToken::new();
let graceful = shut.clone();
let tunnel_app = app.clone();
let mut http = tokio::spawn(async move {
axum::serve(mcp_listener, app)
.with_graceful_shutdown(async move { graceful.cancelled().await })
.await
});
let tunnel_http = tunnel_listener.map(|listener| {
let app = tunnel_app;
let graceful = shut.clone();
tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async move { graceful.cancelled().await })
.await
})
});
let admin_jobs = TaskTracker::new();
loop {
tokio::select! {
@@ -2172,6 +2231,14 @@ async fn serve(
Ok(Err(e)) => eprintln!("wanderbooks server: {e}"),
Err(_) => http.abort(),
}
if let Some(mut tunnel) = tunnel_http {
match tokio::time::timeout(HTTP_GRACE, &mut tunnel).await {
Ok(Ok(Ok(()))) => {}
Ok(Ok(Err(e))) => eprintln!("wanderbooks server: {e}"),
Ok(Err(e)) => eprintln!("wanderbooks server: {e}"),
Err(_) => tunnel.abort(),
}
}
eprintln!("wanderbooks server: closed");
Ok(())
}
@@ -2599,6 +2666,7 @@ mod tests {
backup_quiet: BACKUP_QUIET,
drill_first: DRILL_FIRST,
restoring: Mutex::new(()),
isolation: Isolation::SameUser,
});
let loaded = books.load_principals();
*books.principals.write().unwrap() = loaded;
+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,
}
}
+6 -4
View File
@@ -266,9 +266,11 @@ pub struct StartOut {
pub format_upgrade: Option<FormatUpgradeOut>,
/// What to do next, in order.
pub next_steps: Vec<NextStepOut>,
/// How the books are kept from agents: "same_user (advisory)", hidden
/// from them in the human's own account, not locked against them
/// (plan D-32): reach them only through these tools.
/// How the books are kept from agents: "same_user (advisory)" locally
/// (hidden from them in the human's own account, not locked against
/// them: plan D-32), or "service_user (enforced)" when the books run
/// on the owner's own machine as a dedicated account (plan 3.8, P9a).
/// Either way, reach them only through these tools.
pub isolation: String,
}
@@ -921,7 +923,7 @@ impl BooksServer {
how: "only the owner, with the owner passphrase: in the app (it asks when it opens the books) or `wanderbooks books upgrade ID` at a terminal; a verified backup with the keys is made first. No tool can.".to_string(),
}),
next_steps,
isolation: "same_user (advisory)".to_string(),
isolation: self.isolation.label().to_string(),
};
// Fit (INVARIANT 4): whole lists if they fit; else summaries go,
// then the lists shorten from the end, each saying how many more.
+1 -1
View File
@@ -4719,7 +4719,7 @@ pub struct ReconciliationsOut {
/// Parameters for `books.handbook`.
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct HandbookParams {
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index.
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index.
#[serde(default)]
pub section: Option<String>,
}