# 06 — Shared database daemon (`wo db serve`) **Context sources:** [`./00-overview.md`](./00-overview.md) §§ "Goal" (L23), "Design decisions" 3 (L32), "Non-scope" (L201–203), [`./03-client-runtime.md`](./03-client-runtime.md) (the wire frames this daemon emits), [`./05-per-app-binaries.md`](./05-per-app-binaries.md) (the apps that connect), [`reference/crates/wo-sub/src/lib.rs`](../../../../.dev/reference/crates/wo-sub/src/lib.rs) (the v1 subscription registry, 470 LOC, that needs generalising past `ByTitle`/`ByTag`/`All`), [`../../runtime/database/04-client-api.md`](../../../runtime/database/04-client-api.md) (the wire-protocol owner). ## Goal Stand up a headless daemon — `wo db serve` — that runs the engine + WAL + subscription registry behind the Phase-4 native wire protocol on `127.0.0.1:5555`, with no HTTP and no template rendering. Each connection presents an API key from a static table, binds a `Principal { app, roles }` for downstream policy evaluation, and can register `Subscription::ByPredicate` against any type — a generalisation of v1's article-only subscription model that this phase ports and broadens. ## Design decisions (locked) 1. **Daemon = `crates/db` thin entrypoint + `crates/engine` + the wire acceptor.** No HTTP, no `.htmlx`, no `##ui`. The shared DB process knows nothing about the UI layer. 2. **API-key table is in-memory, env-seeded.** On startup the daemon reads `WO_DB_KEY_=` for each app declared in the workspace and builds an `AuthTable: HashMap`. A `--keys ` flag is accepted but treated as a future hook. 3. **Generalise `wo-sub`** from `Subscription::ByTitle/ByTag/All` to `Subscription::ByPredicate(TypeRef, Expr, SortKey)`. The v1 variants stay as legacy aliases (`ByTitle(t)` ⇒ `ByPredicate(Article, sys_title == t, _)`) for the blog regression test. Anchored in [`reference/crates/wo-sub/src/lib.rs`](../../../../.dev/reference/crates/wo-sub/src/lib.rs) L8–17. 4. **Connection scope = `Principal { app, roles }` stored on the connection.** Every query evaluator reads it; phase 07 wires it into policy AND-composition. 5. **One data dir, one engine, many connections.** Snapshot isolation by default (per `[database].isolation = "snapshot"` in the workspace `wo.toml`). 6. **Foreground-only this phase.** No daemonisation, no PID file, no signal handling beyond `SIGTERM` graceful shutdown. A future ops doc can add `wo db daemonize`. ## Scope ### New files | File | Responsibility | Port source | | --- | --- | --- | | `crates/db/src/main.rs` | Entrypoint, arg parsing, env-key loading | new (~100 LOC) | | `crates/db/src/server.rs` | Wire-protocol acceptor (TCP listener + per-conn handler) | new (~250 LOC) | | `crates/db/src/auth.rs` | `AuthTable`, `Principal`, key handshake | new (~120 LOC) | | `crates/sub/src/lib.rs` | Generalised subscription manager | port [`reference/crates/wo-sub/src/lib.rs`](../../../../.dev/reference/crates/wo-sub/src/lib.rs) (470 LOC) + ~150 new | | `crates/sub/src/predicate.rs` | Predicate evaluation against a row (uses `crates/ql` if available, else minimal subset) | new (~150 LOC) | Total: ~1240 LOC (470 ported + ~770 new). ### `Cargo.toml` change `crates/db` becomes a binary target: ```toml [[bin]] name = "wo-db" path = "src/main.rs" ``` Plus deps already in the workspace: `serde`, `serde_json`, optionally `libc` for the listener (matching `crates/rt`'s direction). ### Wire handshake (added to Phase 4 protocol) ``` client → server: HELLO app="storefront" api_key="" server → client: WELCOME principal={ app, roles } | ERROR "unauthorised" ``` After `WELCOME`, frames follow the Phase-4 native protocol. Subscription-registration frames carry `ByPredicate(TypeRef, Expr, SortKey)`. ## API shape (target) ```rust use db::{DbServer, AuthTable, Principal}; use sub::{SubscriptionManager, Subscription}; let mut auth = AuthTable::new(); auth.insert(ApiKey::from_env("WO_DB_KEY_STOREFRONT")?, Principal { app: "storefront".into(), roles: roles!("Customer") }); auth.insert(ApiKey::from_env("WO_DB_KEY_ADMIN")?, Principal { app: "admin".into(), roles: roles!("Admin", "Ops") }); let server = DbServer::bind("127.0.0.1:5555", Path::new("./data"), auth)?; server.run()?; // foreground; SIGTERM exits cleanly // inside a connection handler: let sub = Subscription::ByPredicate( TypeRef::new("Order"), parse_expr("status != Cancelled")?, SortKey::new("placed_at", SortDir::Desc), ); let id = subs.register(conn_fd, sub)?; ``` ## Exit criteria 1. `cargo build -p db -p sub` green; `wo-db` binary produced under `target/release/`. 2. **Daemon starts.** `WO_DB_KEY_STOREFRONT=aaaa WO_DB_KEY_ADMIN=bbbb cargo run --bin wo-db -- --listen 127.0.0.1:5555 --data-dir /tmp/wo-test` runs foreground and accepts `SIGTERM`. 3. **Two principals.** Two clients connect, one with each API key; each receives a distinct `Principal` in the `WELCOME` frame. 4. **Predicate subscription.** Client registers `Subscription::ByPredicate(Order, "status != Cancelled", "placed_at desc")`; the manager returns a fresh `subscription_id`; on a stub `Order` insert, the matching client receives an `insert` frame. 5. **v1 regression.** A connection running the legacy `Subscription::ByTitle("hello-world")` against the blog corpus still produces notifications via the legacy alias. 6. `cd reference/crates && cargo build && cargo test`. ## Non-scope - **TLS.** `wo://` is plaintext this phase. TLS is its own phase later. - **API-key rotation, revocation, expiry.** Static map only. JWT, mTLS, etc. — out of scope. - **Multi-data-dir, replication, sharding.** One process, one data dir. - **WAL changes.** Engine + WAL semantics inherit from the Stage-2 in-memory engine; persistent storage and crash recovery belong to the database series, not this phase. - **Daemonisation, PID file, systemd integration.** Foreground-only. - **Metric / structured-log emission.** Plain `eprintln!` traces only. ### Risk to flag in the doc If `crates/ql` is too thin to evaluate `status != Cancelled` end-to-end at the time this phase lands, lock a minimal predicate subset — `==`, `!=`, `>`, `<`, `&&`, `||` against scalar fields — and document the gap explicitly. Phases that need richer predicates (graph traversals, computed fields) wait for `ql` to mature. ## Verification ```bash cargo build -p db -p sub # foreground daemon + two connections WO_DB_KEY_STOREFRONT=aaaa WO_DB_KEY_ADMIN=bbbb \ cargo run --bin wo-db -- --listen 127.0.0.1:5555 --data-dir /tmp/wo-test & DB_PID=$! cargo test -p db --test multi_app_principals cargo test -p sub --test predicate_subscription kill $DB_PID # legacy v1 path cargo test -p sub --test legacy_by_title cd reference/crates && cargo build && cargo test ``` ## After this phase The wire URL contract is now stable, which unblocks phase 05 (per-app binaries) connecting to `wo://127.0.0.1:5555`. Phase 07 (`07-per-app-policies.md`) hooks its `EffectivePolicy` resolver into the query path inside this daemon — every query the daemon executes carries the connection's `Principal`, which is exactly what 07's AND-composition needs.