- Board renamed docs/plan/00-kanban.md -> docs/00-status.md and rebuilt: ▶ NEXT PLAN pointer (iteration 4 — emitter, corpus, `woc build`) then six buckets — stories, in progress, done, pending, discarded, learnings. It covered only the Rust runtime before, so the whole OOP track was invisible. All 16 inbound refs repointed; `Kanban:` banners renamed to `Status:`. - New discarded.md (settled rejections with reasons: inheritance, `abstract`, Money/SKU/Float, Dynamic/cast/macro/extern, AOT-to-C, Menhir, shared engine state) and learnings.md (plumbed≠enforced, vacuous goldens, exit-0-wrong- output, malloc-path ASan trick, deferred checks that never reach the VM). - RECOVERED docs/plan/exploration/blue-green-vm/00-vision.md — gone from disk, never committed (gitignored path), cited by five docs incl. principle 12. Root cause was broader: all seven forward-roadmap plans in docs/superpowers/plans/ were untracked and ignored, on one disk only. Dropped the docs ignore rules with a do-not-re-add note; added __pycache__/*.pyc. - Repaired broken links across docs/, 270 -> 36: fixes a regression from the earlier reference/ -> .dev/reference/ move (relative paths at ../../ and deeper were skipped), plus depth and reorg drift. The 36 residual point at content that does not exist and need decisions, not paths. - New spec docs/superpowers/specs/2026-08-10-logwatcher-gap-closure-design.md, applied: `and`/`or` verdict row; Part 3 gains `env` (six modules), swaps time.mono for iso/local, adds 22 bare core builtins; throw/time.mono/is cut (0 uses in the sample). Plan 8: Task 2 gains and/or, Task 5 drops throw, abstract+`is` task deleted, 8/9 renumber to 7/8. Plan 9 gains core builtins. Plan 10 gains the 307 -> 0 diagnostic gate. WO-E205 re-filed unreachable-by- design. types.ml header drops its false satisfaction-set claim. 00-code- review.md reduced to a stub — its rival Phase 1-4 roadmap retired.
7 KiB
06 — Shared database daemon (wo db serve)
Context sources: ./00-overview.md §§ "Goal" (L23), "Design decisions" 3 (L32), "Non-scope" (L201–203), ./03-client-runtime.md (the wire frames this daemon emits), ./05-per-app-binaries.md (the apps that connect), 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 (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)
- Daemon =
crates/dbthin entrypoint +crates/engine+ the wire acceptor. No HTTP, no.htmlx, no##ui. The shared DB process knows nothing about the UI layer. - API-key table is in-memory, env-seeded. On startup the daemon reads
WO_DB_KEY_<APP>=<hex>for each app declared in the workspace and builds anAuthTable: HashMap<ApiKey, Principal>. A--keys <file>flag is accepted but treated as a future hook. - Generalise
wo-subfromSubscription::ByTitle/ByTag/AlltoSubscription::ByPredicate(TypeRef, Expr, SortKey). The v1 variants stay as legacy aliases (ByTitle(t)⇒ByPredicate(Article, sys_title == t, _)) for the blog regression test. Anchored inreference/crates/wo-sub/src/lib.rsL8–17. - Connection scope =
Principal { app, roles }stored on the connection. Every query evaluator reads it; phase 07 wires it into policy AND-composition. - One data dir, one engine, many connections. Snapshot isolation by default (per
[database].isolation = "snapshot"in the workspacewo.toml). - Foreground-only this phase. No daemonisation, no PID file, no signal handling beyond
SIGTERMgraceful shutdown. A future ops doc can addwo 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 (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:
[[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="<hex>"
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)
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
cargo build -p db -p subgreen;wo-dbbinary produced undertarget/release/.- 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-testruns foreground and acceptsSIGTERM. - Two principals. Two clients connect, one with each API key; each receives a distinct
Principalin theWELCOMEframe. - Predicate subscription. Client registers
Subscription::ByPredicate(Order, "status != Cancelled", "placed_at desc"); the manager returns a freshsubscription_id; on a stubOrderinsert, the matching client receives aninsertframe. - v1 regression. A connection running the legacy
Subscription::ByTitle("hello-world")against the blog corpus still produces notifications via the legacy alias. 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
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.