feat(orchestrator): registry co-tenants the shared bot-bottle.db (#352)
lint / lint (push) Successful in 1m57s
test / unit (pull_request) Successful in 59s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Successful in 1m5s

Per review: use one shared bot-bottle.db for all runtime state including
the registry (DbStore namespaces by schema_key), so it's one queryable
file for backup/console. default_db_path() -> host_db_path(). Drop the
unilateral WAL flip — WAL on the shared DB affects supervise/audit and is
finicky over guest shares, so it's a deliberate future change; keep a
busy_timeout for lock contention.

PRD State section updated: integrity now by SOLE ownership (only the
orchestrator opens bot-bottle.db; data plane + console reach state via the
control-plane RPC, never a file handle) rather than ro/rw mount-splitting,
which one shared file can't do. Notes the transitional caveat that the
supervise sidecar currently rw-mounts bot-bottle.db.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
2026-07-13 13:26:03 -04:00
parent ed7307e0e3
commit b174442c60
2 changed files with 46 additions and 33 deletions
+16 -9
View File
@@ -1,10 +1,15 @@
"""Orchestrator bottle registry — the per-host runtime-state store (PRD 0070). """Orchestrator bottle registry — the per-host runtime-state store (PRD 0070).
SQLite-backed (WAL) registry mapping each live bottle to its source IP and SQLite-backed registry mapping each live bottle to its source IP and
per-bottle identity token, plus the **fail-closed attribution** the data per-bottle identity token, plus the **fail-closed attribution** the data
plane relies on: a request is attributed to a bottle only when its source plane relies on: a request is attributed to a bottle only when its source
IP *and* its identity token both match a single active record. IP *and* its identity token both match a single active record.
The registry co-tenants the shared host `bot-bottle.db` (the `DbStore`
framework namespaces each store by `schema_key`), so all bot-bottle
runtime state lives in one queryable file — one place to back up, inspect,
and integrate a console against.
This is the "runtime state" tier of PRD 0070 (leases / approvals / This is the "runtime state" tier of PRD 0070 (leases / approvals /
registry) — deliberately NOT config (which stays declarative under registry) — deliberately NOT config (which stays declarative under
`~/.bot-bottle/`) and NOT the build-time constants (a flat file). It is the `~/.bot-bottle/`) and NOT the build-time constants (a flat file). It is the
@@ -32,6 +37,7 @@ from pathlib import Path
from ..db_store import DbStore from ..db_store import DbStore
from ..migrations import TableMigrations from ..migrations import TableMigrations
from ..supervise_types import host_db_path
# 256 bits of urandom, URL-safe — unguessable per-bottle identity token. # 256 bits of urandom, URL-safe — unguessable per-bottle identity token.
IDENTITY_TOKEN_BYTES = 32 IDENTITY_TOKEN_BYTES = 32
@@ -43,9 +49,10 @@ def new_identity_token() -> str:
def default_db_path() -> Path: def default_db_path() -> Path:
"""Host location for the orchestrator's runtime-state DB. Runtime state """The shared host state DB (`bot-bottle.db`) — all bot-bottle runtime
(not config), so it lives under the cache dir like the pool state.""" state in one file, co-tenanted via schema_key. Host-resident so it
return Path.home() / ".cache" / "bot-bottle" / "orchestrator" / "registry.db" survives orchestrator restarts (re-adoption sweeps it)."""
return host_db_path()
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -113,12 +120,12 @@ class RegistryStore(DbStore):
super().__init__(db_path or default_db_path(), _MIGRATIONS) super().__init__(db_path or default_db_path(), _MIGRATIONS)
def _connect(self) -> sqlite3.Connection: def _connect(self) -> sqlite3.Connection:
"""Open a WAL connection (concurrent data-plane reads + writer).""" """Open a connection with a busy timeout for the shared DB."""
conn = super()._connect() conn = super()._connect()
# WAL lets the data-plane attribution reads run concurrently with the # The registry co-tenants the shared bot-bottle.db, so a busy_timeout
# control-plane writer without blocking; busy_timeout avoids spurious # rides out brief lock contention with the other stores. WAL for the
# "database is locked" under that concurrency (PRD 0070 state tier). # shared DB is a deliberate future change (it affects supervise/audit
conn.execute("PRAGMA journal_mode=WAL") # and is finicky over guest shares) — not flipped here.
conn.execute("PRAGMA busy_timeout=5000") conn.execute("PRAGMA busy_timeout=5000")
return conn return conn
+30 -24
View File
@@ -261,7 +261,7 @@ piling methods on — the same discipline, recursed.
The orchestrator is the natural owner of per-host **runtime state**: The orchestrator is the natural owner of per-host **runtime state**:
- pool **slot leases** (which bottle holds slot *i*) — replaces today's - pool **slot leases** (which bottle holds slot *i*) — replaces today's
`fcntl`-locked files with WAL-mode transactions; `fcntl`-locked files with SQLite transactions;
- the **supervise approval queue** + remembered approvals; - the **supervise approval queue** + remembered approvals;
- the **live bottle registry** (source IP → bottle → policy/secrets refs), - the **live bottle registry** (source IP → bottle → policy/secrets refs),
the lookup table the attribution invariant reads. the lookup table the attribution invariant reads.
@@ -273,38 +273,44 @@ Config splits into three tiers with different homes:
|---|---|---| |---|---|---|
| Build-time constants | pool size, IP base, nft table | flat `.env` (PR #350) — must be readable by Nix eval + root bash, zero runtime | | Build-time constants | pool size, IP base, nft table | flat `.env` (PR #350) — must be readable by Nix eval + root bash, zero runtime |
| User-authored config | bottle manifests, egress routes, secret refs | declarative files under `~/.bot-bottle/` — trust boundary at `$HOME`, git-trackable, "unknown keys die at load" | | User-authored config | bottle manifests, egress routes, secret refs | declarative files under `~/.bot-bottle/` — trust boundary at `$HOME`, git-trackable, "unknown keys die at load" |
| Runtime state | slot leases, approvals, registry | **SQLite**, owned by the orchestrator | | Runtime state | slot leases, approvals, registry | one shared **`bot-bottle.db`**, solely owned by the orchestrator |
SQLite is right for the runtime tier (mutable, concurrent, queried) and SQLite is right for the runtime tier (mutable, concurrent, queried) and
wrong for the other two (Nix can't read it at eval time; it fights the wrong for the other two (Nix can't read it at eval time; it fights the
declarative manifest trust model). Keep the tiers separate. declarative manifest trust model). Keep the tiers separate.
**The DB file is host-resident, not owned inside the orchestrator unit** **One shared `bot-bottle.db` for all runtime state** (decided in review).
(decided in review). Two reasons: The registry co-tenants the existing host `bot-bottle.db` — the `DbStore`
framework already namespaces each store by `schema_key`, so slot
leases / approvals / registry share one file. One place to query, back up,
and integrate a console against.
- **Durability across restarts.** Re-adoption sweeps the registry after an - **Host-resident, for durability.** Re-adoption sweeps the registry after
orchestrator restart, so the state *must* outlive the orchestrator an orchestrator restart, so state *must* outlive the orchestrator
instance's lifecycle. A host-resident file (the slice-1 default under instance. The file lives on the host (`bot_bottle_root()/db/bot-bottle.db`);
`~/.cache/bot-bottle/orchestrator/`) is the simplest durable store; the the orchestrator unit reaches it, it doesn't carry it.
orchestrator VM/container mounts it rather than carrying it. - **Integrity by sole ownership, not mount permissions.** Agents can't
- **Integrity via access-scoping, not location.** Agents can't touch the touch the DB directly wherever it lives (network-isolated in their
DB directly regardless of where it lives — they're network-isolated in bottles). The risk is a *compromised agent-facing data-plane service*
their bottles and only ever speak to the control/data plane. The real (egress/git-gate, which parse hostile bytes) writing the registry and
risk is a *compromised agent-facing data-plane service* (egress/git-gate, forging attribution. Because it's now one shared file, coarse `ro`/`rw`
which parse hostile bytes) writing the registry and forging attribution. mount-splitting no longer isolates the registry — so the rule is stronger
The control is a **read/write split**: writes (register/deregister) come and simpler: **only the orchestrator (control plane) opens `bot-bottle.db`;
only from the **control plane**; the **data plane gets the DB read-only** the data plane and the console reach state through the control-plane RPC,
for attribution lookups. A host-resident file makes that split never a direct file handle.** No agent-facing component gets the file, so
enforceable (mount `ro` into the data plane, `rw` into the control none can forge attribution. (This supersedes the earlier `ro`-mount idea.)
plane) — which owning it "entirely inside" a monolithic orchestrator - *Transitional caveat:* today the per-bottle **supervise sidecar
would not. rw-bind-mounts `bot-bottle.db`** to write proposals — exactly the
pattern the orchestrator removes (supervise consolidates into the
orchestrator; sidecar writes become RPC calls). Until that lands, don't
put the attribution registry behind a data-plane-writable mount.
Implementation note for the VM slices: SQLite **WAL** over a guest share Implementation note for the VM slices: SQLite **WAL** over a guest share
(virtiofs/9p) is finicky (the `-shm`/`-wal` files need real mmap/locking), (virtiofs/9p) is finicky (the `-shm`/`-wal` files need real mmap/locking),
so the durable-DB-on-host may want a small **host-side owner** the which is a second reason the DB wants a **host-side owner** the orchestrator
orchestrator reaches over the control-plane RPC, rather than a shared reaches over the RPC rather than a shared mount into the VM. WAL on the
mount. Decide this at the Firecracker slice; `sqlite3` itself is stdlib, so shared DB is therefore a deliberate, tested future change — not enabled ad
"the host needs SQLite" is a non-cost. hoc. `sqlite3` itself is stdlib, so "the host needs SQLite" is a non-cost.
## Sequencing ## Sequencing