docs(prd): 0070 per-host orchestrator service #352

Open
didericis-claude wants to merge 12 commits from prd-orchestrator into main
Collaborator

Per-host orchestrator PRD (#351): fold the per-bottle sidecar bundle into one persistent per-host service that runs the sidecar functions, coordinates with the console, and brokers agent launches. Virtualized from the start, backend-native (fc VM / apple ctr / docker ctr), behind a single backend-agnostic contract; per-backend variation on BottleBackend (single Orchestrator, no parallel hierarchy).

Leads with the security review — secret concentration, shared fate, the launch-broker-as-new-privileged-core, and the source-IP attribution invariant each backend must enforce. Proposes one SQLite DB (orchestrator-owned) for runtime state, kept distinct from build-time constants and declarative user config.

Supersedes 0069's Stage-1/4 sidecar framing (banner added to 0069); depends on 0069's nix-built fixed images.

🤖 Generated with Claude Code

Per-host **orchestrator** PRD (#351): fold the per-bottle sidecar bundle into one persistent per-host service that runs the sidecar functions, coordinates with the console, and brokers agent launches. Virtualized from the start, backend-native (fc VM / apple ctr / docker ctr), behind a single backend-agnostic contract; per-backend variation on `BottleBackend` (single `Orchestrator`, no parallel hierarchy). Leads with the **security review** — secret concentration, shared fate, the launch-broker-as-new-privileged-core, and the source-IP **attribution invariant** each backend must enforce. Proposes one SQLite DB (orchestrator-owned) for runtime state, kept distinct from build-time constants and declarative user config. Supersedes 0069's Stage-1/4 sidecar framing (banner added to 0069); depends on 0069's nix-built fixed images. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
didericis added 2 commits 2026-07-13 12:14:30 -04:00
Fold the per-bottle sidecar bundle into a single persistent per-host
orchestrator: runs the sidecar functions (egress/git-gate/supervise),
coordinates with the console, and brokers agent launches. Virtualized
from the start with backend-native isolation (fc VM / apple ctr / docker
ctr), fronted by a single backend-agnostic contract; per-backend
variation lives on BottleBackend, not a parallel Orchestrator hierarchy.

Leads with the security review (secret concentration, shared fate, the
launch-broker-as-new-privileged-core, and the source-IP attribution
invariant each backend must enforce). Proposes one SQLite DB owned by the
orchestrator for runtime state (slot leases, approvals, registry) —
distinct from build-time constants (flat .env) and user config
(declarative ~/.bot-bottle). Sequences docker -> firecracker -> macos,
developing the service as a plain-process dev-harness before the VM.

Supersedes 0069's Stage-1/4 sidecar framing; depends on 0069's nix-built
fixed images. Tracking issue #351.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Add a "Secret handling — FUTURE pattern (not v1)" subsection: vault as a
separate trust domain holding long-lived roots, deriving short-lived
scoped creds where the upstream allows (with the honest limit that a
compromised proxy can still abuse currently-authorized access). The
mechanism is a generic, user-extensible SecretProvider that generalizes
PRD 0048's DeployKeyProvisioner and drops into the manifest wherever a raw
token is accepted — discovered from ~/.bot-bottle/contrib like user
AgentProviders. Marked explicitly as not required for the initial
orchestrator; tracked as #355.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
didericis force-pushed prd-orchestrator from adc03e0e26 to 095896817c 2026-07-13 12:14:30 -04:00 Compare
didericis reviewed 2026-07-13 12:19:18 -04:00
@@ -0,0 +301,4 @@
- **Egress sharing tradeoff:** is the secret-concentration blast radius of
one shared mitmproxy worth the resource win, or share only supervise
(near-zero secrets) and keep egress + git-gate per-bottle initially?
Owner

It’s worth it, should be a minimal surface for a malicious agent to exploit/we should be able to harden it and keep it secure.

Also spec-ed out more mitigations via short lived vault tokens (link the relevant issue in reply to this comment)

It’s worth it, should be a minimal surface for a malicious agent to exploit/we should be able to harden it and keep it secure. Also spec-ed out more mitigations via short lived vault tokens (link the relevant issue in reply to this comment)
didericis marked this conversation as resolved
didericis reviewed 2026-07-13 12:20:36 -04:00
@@ -0,0 +303,4 @@
one shared mitmproxy worth the resource win, or share only supervise
(near-zero secrets) and keep egress + git-gate per-bottle initially?
- **Control-plane shape:** RPC transport (unix socket / vsock / HTTP over
the TAP) and the live-reload protocol for per-bottle policy.
Owner

Whatever is most universal/reliable on every host machine (probably http?)

Whatever is most universal/reliable on every host machine (probably http?)
didericis marked this conversation as resolved
didericis reviewed 2026-07-13 12:26:37 -04:00
@@ -0,0 +306,4 @@
the TAP) and the live-reload protocol for per-bottle policy.
- **State re-adoption:** exact scheme for an orchestrator restart to
re-adopt running agent VMs from the SQLite registry without racing
in-flight launches.
Owner

Can you elaborate racing in flight launches? You mean possible parallel orchestrator relaunches on the same host, or an or orchestrator possibly restarting an agent before readopting it?

Regardless (unless I’m missing something), I think the procedure should be fairly straightforward:

  1. every orchestrator launch requires there to be no preexisting, healthy orchestrator launch. If a healthy or non healthy orchestrator is found, it must be fully removed/shut down first
  2. readoption requires waiting for the new orchestrator to be healthy
  3. once the new orchestrator is healthy, the orchestrator must search to find all agents needing adoption (via both sqlite state and process inspection) before handling any other requests
Can you elaborate racing in flight launches? You mean possible parallel orchestrator relaunches on the same host, or an or orchestrator possibly restarting an agent before readopting it? Regardless (unless I’m missing something), I think the procedure should be fairly straightforward: 1) every orchestrator launch requires there to be no preexisting, healthy orchestrator launch. If a healthy or non healthy orchestrator is found, it must be fully removed/shut down first 2) readoption requires waiting for the new orchestrator to be healthy 3) once the new orchestrator is healthy, the orchestrator must search to find all agents needing adoption (via both sqlite state and process inspection) before handling any other requests
didericis marked this conversation as resolved
didericis reviewed 2026-07-13 12:28:01 -04:00
@@ -0,0 +308,4 @@
re-adopt running agent VMs from the SQLite registry without racing
in-flight launches.
- **VM-to-VM routing:** the nft forward rules + addressing for a
per-host orchestrator VM on its own TAP.
Owner

Guessing this will have to vary per backend, but think we’ve solved this already? Had spikes related to putting sidecars in vms where I think we figured out how to do this

Guessing this will have to vary per backend, but think we’ve solved this already? Had spikes related to putting sidecars in vms where I think we figured out how to do this
didericis marked this conversation as resolved
didericis reviewed 2026-07-13 12:30:54 -04:00
@@ -0,0 +310,4 @@
- **VM-to-VM routing:** the nft forward rules + addressing for a
per-host orchestrator VM on its own TAP.
- **Broker request schema:** the exact structured contract that stays
auditable and can't be coerced into launching arbitrary payloads.
Owner

human readable json with static flags and ids makes sense for all communication with the side, I think. Might be missing cases where that doesn’t work, but we’ll tackle them as we go/make that the ideal. Would also be worth making it a signed jwt for provenance

human readable json with static flags and ids makes sense for all communication with the side, I think. Might be missing cases where that doesn’t work, but we’ll tackle them as we go/make that the ideal. Would also be worth making it a signed jwt for provenance
didericis marked this conversation as resolved
didericis reviewed 2026-07-13 12:32:50 -04:00
@@ -0,0 +105,4 @@
responsibility** and part of the contract:
> **Invariant:** a packet's source address, as seen by the orchestrator,
> *provably* identifies the originating bottle.
Owner

Given all the different ways to do ip spoofing, would it also be worth adding an outbound proxy to the agent that injects a token to every outbound request to the sidecar so an agent can prove who it is? I think we’ve already added that proxy/should be trivial to add a token, but let me know if that didn’t land

Given all the different ways to do ip spoofing, would it also be worth adding an outbound proxy to the agent that injects a token to every outbound request to the sidecar so an agent can prove who it is? I think we’ve already added that proxy/should be trivial to add a token, but let me know if that didn’t land
didericis marked this conversation as resolved
didericis added 1 commit 2026-07-13 12:39:10 -04:00
Resolve open questions from review: consolidate egress (hardened minimal
surface + identity token + vault mitigations); HTTP control-plane
transport; broker schema = signed-JWT JSON of static flags+ids (provenance
+ un-coercible); state re-adoption procedure (singleton orchestrator →
wait-healthy → adopt via SQLite + process inspection before serving, with
write-ahead intent to close the in-flight-launch race). Add a per-bottle
identity-token defense-in-depth layer on the attribution invariant.
Remaining open: VM-to-VM routing (per-backend wire(), pending spike link),
live-reload protocol, identity-token delivery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
didericis-claude reviewed 2026-07-13 12:39:34 -04:00
didericis-claude left a comment
Author
Collaborator

Responses to the inline comments — all folded into the PRD in commit 73a7582 (new Decisions (review 2026-07-13) section + inline edits). Per-line threading isn't reliable through the API here, so consolidating:

1. Egress sharing tradeoff — Agreed, consolidating egress; recorded the decision as "worth it, minimal hardened surface" and paired it with the identity token + the short-lived-vault-token mitigations, which are tracked in #355 (generic SecretProvider) and written up in this PRD's Secret handling section.

2. Control-plane transport — Agreed: HTTP as the wire contract (universal/reliable everywhere; unix socket left as an optional local optimization). Folded into the contract section.

3. State re-adoption / "racing in-flight launches" — I meant the second thing you named: not parallel orchestrators (your step 1 rules that out), but a launch that had started (VM booting / slot claimed) yet hadn't committed to SQLite when the old orchestrator died — a SQLite-only sweep would miss it. Your 3-step procedure is exactly right and I've adopted it verbatim; step 3's "SQLite and process inspection" is precisely what catches that not-yet-committed launch. Added one thing: launches write an intent record before committing resources so the sweep can reconcile intent vs. actual.

4. VM-to-VM routing — Right, it's per-backend — in the design it's literally BottleBackend.wire() (DNAT+forward for fc, shared-net for docker/apple), not orchestrator logic. If the earlier "sidecars in VMs" spike already solved the fc forward rules, this drops from open-question to "implement per the spike." Can you point me at that spike (branch/notes)? I'll reference it and close the question.

5. Broker request schema — Agreed and adopted: human-readable JSON of static flags + ids only (no free-form paths/argv — that's what keeps it un-coercible into arbitrary launches), wrapped as a signed JWT so the broker verifies provenance (request came from the real orchestrator, not a forged one from a compromised co-located component). Same shape for all orchestrator↔broker/sidecar comms.

6. Identity token vs IP spoofing — Good defense-in-depth; added it as an app-layer layer on the attribution invariant. Honest nuance captured in the PRD: on Firecracker the /31 + nft make source IP unspoofable by construction, so there it's belt-and-suspenders (cheap insurance vs. a misconfigured invariant); on weaker backends (Docker) it's genuinely load-bearing. Requirements noted: per-bottle, unguessable, non-cross-leakable (a bottle can only ever prove it's itself), provisioned at launch, required by the sidecar. One caveat it does not fix: it's app-layer, so a compromised orchestrator still sees every token — that's the concentration problem, handled under Secret handling, not here. And yes — the agent already egresses through the sidecar proxy, so adding the header is trivial, as you said.

Responses to the inline comments — all folded into the PRD in commit 73a7582 (new **Decisions (review 2026-07-13)** section + inline edits). Per-line threading isn't reliable through the API here, so consolidating: **1. Egress sharing tradeoff** — Agreed, consolidating egress; recorded the decision as "worth it, minimal hardened surface" and paired it with the identity token + the short-lived-vault-token mitigations, which are tracked in #355 (generic `SecretProvider`) and written up in this PRD's *Secret handling* section. **2. Control-plane transport** — Agreed: **HTTP** as the wire contract (universal/reliable everywhere; unix socket left as an optional local optimization). Folded into the contract section. **3. State re-adoption / "racing in-flight launches"** — I meant the second thing you named: not parallel orchestrators (your step 1 rules that out), but a launch that had *started* (VM booting / slot claimed) yet hadn't committed to SQLite when the old orchestrator died — a SQLite-only sweep would miss it. Your 3-step procedure is exactly right and I've adopted it verbatim; step 3's "SQLite **and** process inspection" is precisely what catches that not-yet-committed launch. Added one thing: launches write an **intent record before committing resources** so the sweep can reconcile intent vs. actual. **4. VM-to-VM routing** — Right, it's per-backend — in the design it's literally `BottleBackend.wire()` (DNAT+forward for fc, shared-net for docker/apple), not orchestrator logic. If the earlier "sidecars in VMs" spike already solved the fc forward rules, this drops from open-question to "implement per the spike." **Can you point me at that spike (branch/notes)?** I'll reference it and close the question. **5. Broker request schema** — Agreed and adopted: human-readable JSON of **static flags + ids only** (no free-form paths/argv — that's what keeps it un-coercible into arbitrary launches), wrapped as a **signed JWT** so the broker verifies provenance (request came from the real orchestrator, not a forged one from a compromised co-located component). Same shape for all orchestrator↔broker/sidecar comms. **6. Identity token vs IP spoofing** — Good defense-in-depth; added it as an app-layer layer on the attribution invariant. Honest nuance captured in the PRD: on Firecracker the `/31` + nft make source IP unspoofable *by construction*, so there it's belt-and-suspenders (cheap insurance vs. a misconfigured invariant); on weaker backends (Docker) it's genuinely load-bearing. Requirements noted: per-bottle, unguessable, non-cross-leakable (a bottle can only ever prove it's *itself*), provisioned at launch, required by the sidecar. One caveat it does **not** fix: it's app-layer, so a compromised orchestrator still sees every token — that's the concentration problem, handled under Secret handling, not here. And yes — the agent already egresses through the sidecar proxy, so adding the header is trivial, as you said.
Owner

@didericis-claude RE 4, don’t know where/when we did it, can search when implementing. But should be possible/not a blocker, even if it requires modifying the host a bit (like it does on nixos).

Can start implementing now. Can do it in this branch/PR.

@didericis-claude RE 4, don’t know where/when we did it, can search when implementing. But should be possible/not a blocker, even if it requires modifying the host a bit (like it does on nixos). Can start implementing now. Can do it in this branch/PR.
didericis approved these changes 2026-07-13 12:43:48 -04:00
didericis added 1 commit 2026-07-13 12:46:11 -04:00
Per review: resolvable at implementation time (host tweak acceptable, as
the pool setup already needs on NixOS); the earlier sidecars-in-VMs spike
showed feasibility. No need to hunt the spike now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Author
Collaborator

RE 4 — sounds good. Marked it not a blocker in the PRD (commit 8d54fc3): resolvable at implementation time, host tweak acceptable (same as the pool setup already needs on NixOS), spike showed feasibility. No need to hunt it down now.

On starting implementation — agreed it's unblocked. One suggestion on where: land this PRD PR as-is (docs-only) so the design is a stable reference, then implement in a dedicated branch — keeps "approve the design" separate from "review the code" and avoids one giant mixed diff. But I'm happy to build right here in this branch if you'd rather iterate design + code together; your call.

Per the PRD sequencing, the natural first slice is:

  1. Orchestrator service as a plain-process dev-harness — so the consolidation logic (source-IP + identity-token attribution, the HTTP control plane, live-reload) is testable with fast iteration, decoupled from any VM packaging.
  2. Wire it as the Docker orchestrator — cheapest backend to prove the BottleBackend seam (the sidecar bundle is already containers; collapse N into one persistent container).

Firecracker (the shim + wire()) comes after that's proven. Want me to start on slice 1? If so, just confirm: land the PRD first + new branch, or build in this branch.

RE 4 — sounds good. Marked it **not a blocker** in the PRD (commit 8d54fc3): resolvable at implementation time, host tweak acceptable (same as the pool setup already needs on NixOS), spike showed feasibility. No need to hunt it down now. On starting implementation — agreed it's unblocked. One suggestion on *where*: land this PRD PR as-is (docs-only) so the design is a stable reference, then implement in a dedicated branch — keeps "approve the design" separate from "review the code" and avoids one giant mixed diff. But I'm happy to build right here in this branch if you'd rather iterate design + code together; your call. Per the PRD sequencing, the natural first slice is: 1. **Orchestrator service as a plain-process dev-harness** — so the consolidation logic (source-IP + identity-token attribution, the HTTP control plane, live-reload) is testable with fast iteration, decoupled from any VM packaging. 2. **Wire it as the Docker orchestrator** — cheapest backend to prove the `BottleBackend` seam (the sidecar bundle is already containers; collapse N into one persistent container). Firecracker (the shim + `wire()`) comes after that's proven. Want me to start on slice 1? If so, just confirm: **land the PRD first + new branch**, or **build in this branch**.
Owner

@didericis-claude do in this branch incase the prd changes as we implement it

@didericis-claude do in this branch incase the prd changes as we implement it
Author
Collaborator

Sounds good — implementing in this branch so the PRD and code evolve together.

Starting with slice 1: the orchestrator service as a plain-process dev-harness — the consolidation core that's independent of any VM packaging:

  • bot_bottle/orchestrator/registry.py — SQLite (WAL) bottle registry on the existing DbStore/TableMigrations base; the runtime-state tier from the PRD (source IP + per-bottle identity token), with fail-closed attribution (source IP and identity token must both match one active record).
  • bot_bottle/orchestrator/control_plane.py — the HTTP control plane (register/deregister/list/attribute/health); routing is a pure dispatch() so it's socket-free testable, with a thin stdlib handler/server adapter. register/deregister are the live-reload path.
  • bot_bottle/orchestrator/__main__.pypython -m bot_bottle.orchestrator runnable harness.
  • unit tests for the registry (attribution matrix, persistence) and the control plane (dispatch + one real-socket round-trip).

Launch/teardown + the broker + the actual egress/git/supervise data plane come in later slices once this core is proven. Pushing shortly.

Sounds good — implementing in this branch so the PRD and code evolve together. Starting with **slice 1: the orchestrator service as a plain-process dev-harness** — the consolidation core that's independent of any VM packaging: - `bot_bottle/orchestrator/registry.py` — SQLite (WAL) bottle registry on the existing `DbStore`/`TableMigrations` base; the runtime-state tier from the PRD (source IP + per-bottle identity token), with **fail-closed attribution** (source IP *and* identity token must both match one active record). - `bot_bottle/orchestrator/control_plane.py` — the **HTTP** control plane (register/deregister/list/attribute/health); routing is a pure `dispatch()` so it's socket-free testable, with a thin stdlib handler/server adapter. register/deregister are the live-reload path. - `bot_bottle/orchestrator/__main__.py` — `python -m bot_bottle.orchestrator` runnable harness. - unit tests for the registry (attribution matrix, persistence) and the control plane (dispatch + one real-socket round-trip). Launch/teardown + the broker + the actual egress/git/supervise data plane come in later slices once this core is proven. Pushing shortly.
didericis added 1 commit 2026-07-13 13:00:07 -04:00
feat(orchestrator): slice 1 — registry + attribution + HTTP control plane (#352)
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Successful in 56s
test / integration (pull_request) Successful in 17s
test / coverage (pull_request) Successful in 1m2s
1702664b81
First implementation slice of PRD 0070, the backend-neutral consolidation
core as a plain-process dev-harness (no VM packaging yet):

  * orchestrator/registry.py — SQLite (WAL) runtime-state store on the
    existing DbStore/TableMigrations base. Live bottle registry keyed by
    source IP + per-bottle identity token, with fail-closed attribution:
    a request resolves to a bottle only when its source IP AND identity
    token both match exactly one active record (unknown/ambiguous IP,
    empty token, or token mismatch all deny). Tokens are 256-bit urandom.
  * orchestrator/control_plane.py — the HTTP control plane (the universal
    transport chosen in 0070): register / deregister / list / attribute /
    health. Routing is a pure dispatch() so it is socket-free testable;
    Handler/ControlPlaneServer/make_server are a thin stdlib adapter.
    register/deregister are the live-reload path; listing redacts tokens.
  * orchestrator/__main__.py — `python -m bot_bottle.orchestrator` harness.

Launch/teardown, the launch broker, and the egress/git/supervise data
plane come in later slices. 24 unit tests (attribution matrix, persistence,
dispatch, one real-socket round-trip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Author
Collaborator

Slice 1 pushed in 1702664 — CI green (lint incl. pyright-strict, unit, integration, coverage).

Landed: bot_bottle/orchestrator/{registry,control_plane,__main__}.py — the SQLite (WAL) registry + fail-closed attribution (source IP and identity token), the HTTP control plane (register/deregister/list/attribute/health, pure dispatch() + thin stdlib adapter), and the python -m bot_bottle.orchestrator dev-harness. 24 unit tests (attribution matrix, persistence, dispatch, one real-socket round-trip).

Next slices: launch/teardown + the signed-JWT broker, then wiring it as the Docker orchestrator (cheapest backend to prove the BottleBackend seam).

Slice 1 pushed in `1702664` — CI green (lint incl. pyright-strict, unit, integration, coverage). Landed: `bot_bottle/orchestrator/{registry,control_plane,__main__}.py` — the SQLite (WAL) registry + fail-closed attribution (source IP **and** identity token), the HTTP control plane (register/deregister/list/attribute/health, pure `dispatch()` + thin stdlib adapter), and the `python -m bot_bottle.orchestrator` dev-harness. 24 unit tests (attribution matrix, persistence, dispatch, one real-socket round-trip). Next slices: launch/teardown + the signed-JWT broker, then wiring it as the Docker orchestrator (cheapest backend to prove the `BottleBackend` seam).
didericis reviewed 2026-07-13 13:14:36 -04:00
@@ -0,0 +3,4 @@
The backend-agnostic control-plane RPC (CLI / console -> orchestrator) over
**HTTP** the universal transport chosen in 0070 (works on every host; no
vsock / unix-socket portability caveats). Endpoints mutate the live
registry, so register/deregister are the *live-reload* path no restart:
Owner

Debating whether orchestrator should own db entirely so the host doesn’t need sqlite… am thinking this is probably better, as that potentially opens up agents to mutate the db, and sqlite is universal enough that keeping it on the host is fine. Keeping it on the host also means it survives orchestrator reboots.

Debating whether orchestrator should own db entirely so the host doesn’t need sqlite… am thinking this is probably better, as that potentially opens up agents to mutate the db, and sqlite is universal enough that keeping it on the host is fine. Keeping it on the host also means it survives orchestrator reboots.
didericis added 1 commit 2026-07-13 13:17:09 -04:00
docs(prd): 0070 registry DB is host-resident with rw/ro access split (#352)
test / unit (pull_request) Successful in 55s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m1s
ed7307e0e3
Per review: the SQLite runtime-state DB lives on the host, not owned
inside the orchestrator unit. Durability (re-adoption must survive an
orchestrator restart) + integrity via access-scoping (control-plane rw,
data-plane ro) rather than location. Note the WAL-over-guest-share wrinkle
for the VM slices (may want a host-side DB owner reached over the RPC).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Author
Collaborator

RE the DB-ownership comment on control_plane.py — agree with keeping it host-resident, and it's already the slice-1 default (default_db_path()~/.cache/bot-bottle/orchestrator/registry.db, on the host). Folded the decision into the PRD's State section (commit ed7307e). Two refinements though:

Durability is actually a hard requirement, not just a nice-to-have. The restart re-adoption we specced sweeps the registry to re-adopt running bottles, so the state must outlive the orchestrator instance's lifecycle. Host-resident file is the simplest way; the orchestrator VM/container mounts it rather than carrying it.

On "opens agents up to mutating the db" — the real control is access-scoping, not location. Agents can't touch the DB directly wherever it lives — they're network-isolated in their bottles and only ever speak to the control/data plane. The risk is a compromised agent-facing data-plane service (egress/git-gate, which parse hostile bytes) writing the registry and forging attribution. So the fix is a read/write split: writes (register/deregister) only from the control plane; the data plane gets the DB read-only for attribution lookups. A host-resident file is what makes that split enforceable (mount ro into the data plane, rw into the control plane) — so location helps because it enables the split + durability, more than because it blocks agents (they were already blocked).

One implementation wrinkle for the VM slices: SQLite WAL over a guest share (virtiofs/9p) is finicky — the -wal/-shm files need real mmap/locking. So the durable-DB-on-host may want a small host-side owner the orchestrator reaches over the control-plane RPC, rather than a shared mount into the VM. No need to decide now (slice 1 is a host process, so the file is local); flagged it for the Firecracker slice. And yes — sqlite3 is stdlib, so "the host needs sqlite" is a non-cost.

RE the DB-ownership comment on `control_plane.py` — agree with keeping it **host-resident**, and it's already the slice-1 default (`default_db_path()` → `~/.cache/bot-bottle/orchestrator/registry.db`, on the host). Folded the decision into the PRD's State section (commit ed7307e). Two refinements though: **Durability is actually a hard requirement, not just a nice-to-have.** The restart re-adoption we specced sweeps the registry to re-adopt running bottles, so the state *must* outlive the orchestrator instance's lifecycle. Host-resident file is the simplest way; the orchestrator VM/container mounts it rather than carrying it. **On "opens agents up to mutating the db" — the real control is access-scoping, not location.** Agents can't touch the DB directly wherever it lives — they're network-isolated in their bottles and only ever speak to the control/data plane. The risk is a *compromised agent-facing data-plane service* (egress/git-gate, which parse hostile bytes) writing the registry and forging attribution. So the fix is a **read/write split**: writes (register/deregister) only from the **control plane**; the **data plane gets the DB read-only** for attribution lookups. A host-resident file is what makes that split enforceable (mount `ro` into the data plane, `rw` into the control plane) — so location helps *because* it enables the split + durability, more than because it blocks agents (they were already blocked). **One implementation wrinkle for the VM slices:** SQLite **WAL** over a guest share (virtiofs/9p) is finicky — the `-wal`/`-shm` files need real mmap/locking. So the durable-DB-on-host may want a small **host-side owner** the orchestrator reaches over the control-plane RPC, rather than a shared mount into the VM. No need to decide now (slice 1 is a host process, so the file is local); flagged it for the Firecracker slice. And yes — `sqlite3` is stdlib, so "the host needs sqlite" is a non-cost.
didericis reviewed 2026-07-13 13:20:35 -04:00
@@ -0,0 +45,4 @@
def default_db_path() -> Path:
"""Host location for the orchestrator's runtime-state DB. Runtime state
(not config), so it lives under the cache dir like the pool state."""
return Path.home() / ".cache" / "bot-bottle" / "orchestrator" / "registry.db"
Owner

@didericis-claude Think we should use the same “bot-bottle.db” for all bot-bottle state, including the registry. Makes querying and future console related integration easier. Thoughts?

@didericis-claude Think we should use the same “bot-bottle.db” for all bot-bottle state, including the registry. Makes querying and future console related integration easier. Thoughts?
didericis added 1 commit 2026-07-13 13:26:07 -04:00
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
b174442c60
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
Author
Collaborator

Agreed — done in b174442. default_db_path() now returns host_db_path(), so the registry co-tenants the existing shared bot-bottle.db. This is architecturally clean: the DbStore/TableMigrations base already namespaces each store by schema_key, so slot leases / supervise queue / registry share one file with separate migration tracks — one place to query and point a console at, as you said.

Two things I want to flag though, because they change how we integrate the console and enforce security:

1. The console should read state via the orchestrator's RPC, not the DB file directly. One shared DB makes ad-hoc querying easy for us (debugging/analytics), but if the console integrates by opening bot-bottle.db it bypasses the policy the orchestrator enforces and can mutate state. Cleaner: the console talks to the control-plane API (which we're building), and the shared DB is the convenience for offline inspection. So "one DB" helps ops/debugging more than it should drive the console integration.

2. Unifying actually strengthens the attribution-integrity rule — but only if the orchestrator is the sole DB opener. Last thread we said "data plane gets the registry read-only." With one shared file, coarse ro/rw mount-splitting can't isolate just the registry, so the rule gets simpler and stronger: only the orchestrator (control plane) opens bot-bottle.db; the data plane reaches attribution + writes proposals via RPC, never a file handle. No agent-facing component touches the file, so none can forge attribution.

Concrete caveat I hit while wiring this up: bot-bottle.db is today rw-bind-mounted into the per-bottle supervise sidecar (so it can write proposals — see host_db_path()'s "share this one file with a sidecar" note). That's an agent-facing component with write access to the file the registry now lives in — i.e. exactly the forge-attribution risk. It's fine because it's the pattern the orchestrator removes (supervise consolidates into the orchestrator; sidecar writes become RPC calls), but until that lands we must not expose the unified DB through a data-plane-writable mount. Captured all of this in the PRD State section.

Also dropped the WAL pragma I'd added — flipping the shared bot-bottle.db to WAL would affect supervise/audit and is finicky over guest shares, so it's a deliberate future change, not something to sneak in here; kept a busy_timeout for contention. Tests still green.

Agreed — done in `b174442`. `default_db_path()` now returns `host_db_path()`, so the registry co-tenants the existing shared `bot-bottle.db`. This is architecturally clean: the `DbStore`/`TableMigrations` base already namespaces each store by `schema_key`, so slot leases / supervise queue / registry share one file with separate migration tracks — one place to query and point a console at, as you said. Two things I want to flag though, because they change *how* we integrate the console and enforce security: **1. The console should read state via the orchestrator's RPC, not the DB file directly.** One shared DB makes ad-hoc querying easy for *us* (debugging/analytics), but if the console integrates by opening `bot-bottle.db` it bypasses the policy the orchestrator enforces and can mutate state. Cleaner: the console talks to the control-plane API (which we're building), and the shared DB is the convenience for offline inspection. So "one DB" helps ops/debugging more than it should drive the console integration. **2. Unifying actually *strengthens* the attribution-integrity rule — but only if the orchestrator is the sole DB opener.** Last thread we said "data plane gets the registry read-only." With one shared file, coarse `ro`/`rw` mount-splitting can't isolate just the registry, so the rule gets simpler and stronger: **only the orchestrator (control plane) opens `bot-bottle.db`; the data plane reaches attribution + writes proposals via RPC, never a file handle.** No agent-facing component touches the file, so none can forge attribution. **Concrete caveat I hit while wiring this up:** `bot-bottle.db` is *today* rw-bind-mounted into the per-bottle **supervise sidecar** (so it can write proposals — see `host_db_path()`'s "share this one file with a sidecar" note). That's an agent-facing component with write access to the file the registry now lives in — i.e. exactly the forge-attribution risk. It's fine because it's the pattern the orchestrator *removes* (supervise consolidates into the orchestrator; sidecar writes become RPC calls), but until that lands we must not expose the unified DB through a data-plane-writable mount. Captured all of this in the PRD State section. Also dropped the WAL pragma I'd added — flipping the shared `bot-bottle.db` to WAL would affect supervise/audit and is finicky over guest shares, so it's a deliberate future change, not something to sneak in here; kept a `busy_timeout` for contention. Tests still green.
didericis reviewed 2026-07-13 13:29:58 -04:00
@@ -0,0 +37,4 @@
from ..db_store import DbStore
from ..migrations import TableMigrations
from ..supervise_types import host_db_path
Owner

this should be moved to db_store

this should be moved to db_store
didericis added 1 commit 2026-07-13 13:36:23 -04:00
refactor: move host_db_path/HOST_DB_FILENAME to db_store (#352)
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m0s
b4b4e08f62
host_db_path is shared DB infrastructure, not supervise-specific, so its
canonical home is db_store (alongside DbStore). It resolves bot_bottle_root
from supervise_types lazily inside the function — no load-time cycle, and a
monkey-patch of supervise_types.bot_bottle_root still propagates.
supervise_types re-exports both names for the historical import path
(queue_store/audit_store unchanged); the orchestrator registry now imports
from db_store. Drops the now-unused `import sys` from supervise_types.

Behavior-preserving: full unit suite unchanged (only the pre-existing
/bin/sleep sidecar-init errors remain); monkeypatch propagation verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Author
Collaborator

Done in b4b4e08. host_db_path + HOST_DB_FILENAME now live canonically in db_store (alongside DbStore), and the registry imports from there.

Two things I preserved carefully so nothing broke:

  • No load-time cycle. db_store.host_db_path resolves bot_bottle_root from supervise_types via a lazy import inside the function, so db_store doesn't import supervise_types at module load (which would cycle, since supervise_types now imports db_store).
  • Monkeypatch semantics intact. Patching supervise_types.bot_bottle_root still propagates to the DB path (verified). supervise_types re-exports both names for the historical import path, so queue_store/audit_store are unchanged.

Full unit suite is behavior-preserving (only the pre-existing /bin/sleep sidecar-init errors remain). Also dropped the now-unused import sys from supervise_types.

One thing I left alone: supervise.py still has its own host_db_path duplicate (a comment there literally says "must stay in sync" with the other copy) — it uses supervise.bot_bottle_root's patch namespace, which several tests rely on, so consolidating it too is a separate, riskier change. Happy to tackle that dedup separately if you want it gone.

Done in `b4b4e08`. `host_db_path` + `HOST_DB_FILENAME` now live canonically in `db_store` (alongside `DbStore`), and the registry imports from there. Two things I preserved carefully so nothing broke: - **No load-time cycle.** `db_store.host_db_path` resolves `bot_bottle_root` from `supervise_types` via a *lazy* import inside the function, so `db_store` doesn't import `supervise_types` at module load (which would cycle, since `supervise_types` now imports `db_store`). - **Monkeypatch semantics intact.** Patching `supervise_types.bot_bottle_root` still propagates to the DB path (verified). `supervise_types` re-exports both names for the historical import path, so `queue_store`/`audit_store` are unchanged. Full unit suite is behavior-preserving (only the pre-existing `/bin/sleep` sidecar-init errors remain). Also dropped the now-unused `import sys` from `supervise_types`. One thing I left alone: `supervise.py` still has its *own* `host_db_path` duplicate (a comment there literally says "must stay in sync" with the other copy) — it uses `supervise.bot_bottle_root`'s patch namespace, which several tests rely on, so consolidating it too is a separate, riskier change. Happy to tackle that dedup separately if you want it gone.
didericis added 1 commit 2026-07-13 14:04:27 -04:00
refactor: move bot_bottle_root/host_db_path to paths; kill the monkeypatch (#352)
lint / lint (push) Successful in 1m57s
test / unit (pull_request) Successful in 55s
test / integration (pull_request) Successful in 15s
test / coverage (pull_request) Successful in 1m1s
421d31c32f
Create bot_bottle/paths.py as the canonical home for the app-root path
helpers (bot_bottle_root, host_db_path, HOST_DB_FILENAME) — foundational,
not supervise- or db-specific. `bot_bottle_root()` now honours a
BOT_BOTTLE_ROOT env override.

Repoint every consumer (supervise, supervise_types, db_store, queue_store,
audit_store, store_manager, bottle_state, cli/supervise, docker/cleanup,
orchestrator/registry) at paths; remove the definitions (and supervise's
duplicate host_db_path) and the now-dead `import sys`. Add paths.py to the
sidecar bundle (Dockerfile.sidecars) for the flat-import copies.

Tests: replace ~12 files' monkeypatching of supervise.bot_bottle_root (and
the flat/pkg/supervise_types triple-patch dance) with a single
`use_bottle_root()` helper that sets BOT_BOTTLE_ROOT — every module and
flat/package copy reads the same env var, so one override covers them all.
Net -97 lines. Behaviour-preserving: full unit suite unchanged (only the
pre-existing /bin/sleep sidecar-init errors remain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Owner

@didericis-claude looks good, let's do slice 2. Ok to do in the same PR.

@didericis-claude looks good, let's do slice 2. Ok to do in the same PR.
didericis added 1 commit 2026-07-13 14:30:15 -04:00
refactor: drop the vestigial bot_bottle_root/host_db_path re-exports (#352)
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Successful in 1m0s
test / integration (pull_request) Successful in 19s
test / coverage (pull_request) Successful in 1m6s
6847fdf0ab
paths is the single home now, so stop re-exporting the path helpers from
supervise: remove host_db_path from supervise's imports + __all__ (it was
re-export-only) and drop bot_bottle_root from __all__ (kept as an import,
still used by audit_dir). supervise_types was already clean. Repoint the
last readers (test_supervise imports host_db_path from paths;
test_supervise_edge calls paths.bot_bottle_root) and refresh the doc
mentions. No supervise.bot_bottle_root / supervise.host_db_path references
remain.

Behavior-preserving: full unit suite unchanged (only the pre-existing
/bin/sleep sidecar-init errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Author
Collaborator

Great — slice 2 in this PR it is. (Also stripped the vestigial bot_bottle_root/host_db_path re-exports first, in 6847fdfpaths is now the sole home; no supervise.bot_bottle_root/.host_db_path references remain.)

Slice 2 — launch lifecycle + the launch-broker contract (still backend-neutral / dev-harness, so it stays fast to test before the Docker wiring):

  • Control-plane launch_bottle / teardown_bottle — extend the HTTP RPC beyond register/deregister: launch_bottle mints the bottle (registry: source IP + identity token), issues a broker request, and returns the bottle handle + token; teardown_bottle deregisters + issues the teardown request.
  • The launch broker — the LaunchBroker interface plus the signed-JWT request envelope we agreed on: static flags + ids only (no free-form paths/argv), signed so the broker verifies provenance (came from the real orchestrator). Build + verify + a schema guard, with a stub/local broker for the harness (records requests; no real launch yet).
  • Unit tests: launch→registry→attribute round-trip, teardown, JWT sign/verify + tampering rejection, schema rejects non-structured payloads.

Slice 3 is then the real Docker orchestrator — a concrete LaunchBroker/BottleBackend.launch_unit that actually starts the sidecar-bundle container, proving the seam on the cheapest backend.

Starting on slice 2.

Great — slice 2 in this PR it is. (Also stripped the vestigial `bot_bottle_root`/`host_db_path` re-exports first, in `6847fdf` — `paths` is now the sole home; no `supervise.bot_bottle_root`/`.host_db_path` references remain.) **Slice 2 — launch lifecycle + the launch-broker contract** (still backend-neutral / dev-harness, so it stays fast to test before the Docker wiring): - **Control-plane `launch_bottle` / `teardown_bottle`** — extend the HTTP RPC beyond register/deregister: `launch_bottle` mints the bottle (registry: source IP + identity token), issues a broker request, and returns the bottle handle + token; `teardown_bottle` deregisters + issues the teardown request. - **The launch broker** — the `LaunchBroker` interface plus the **signed-JWT request envelope** we agreed on: static flags + ids only (no free-form paths/argv), signed so the broker verifies provenance (came from the real orchestrator). Build + verify + a schema guard, with a **stub/local broker** for the harness (records requests; no real launch yet). - Unit tests: launch→registry→attribute round-trip, teardown, JWT sign/verify + tampering rejection, schema rejects non-structured payloads. Slice 3 is then the real **Docker orchestrator** — a concrete `LaunchBroker`/`BottleBackend.launch_unit` that actually starts the sidecar-bundle container, proving the seam on the cheapest backend. Starting on slice 2.
Owner

@didericis-claude Actually let's branch off this PR for slice 2. Go for it.

@didericis-claude Actually let's branch off this PR for slice 2. Go for it.
didericis added 1 commit 2026-07-13 15:30:10 -04:00
docs(prd): 0070 add the slice roadmap incl. consolidated sidecar (#352)
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m2s
8e567edde4
Enumerate the implementation slices in the Sequencing section: dev-harness
core, launch+broker, docker broker, consolidated per-host sidecar (new),
then firecracker and macOS. Slices 1-4 implemented (#352/#356/#357/#358).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Author
Collaborator

Updated slice roadmap (now includes the consolidated sidecar)

Also written into the PRD's Sequencing section (8e567ed). Built as a stack of small PRs off this one:

  1. Dev-harness core — SQLite registry + fail-closed attribution (source IP + identity token) + HTTP control plane. (this PR, #352)
  2. Launch lifecycle + broker contract — launch/teardown + the signed, structured LaunchBroker request (HS256 JWT, static ids/flags only, provenance-verified, fail-closed); stub broker + registry rollback. (#356)
  3. Docker launch broker — first real broker: docker run/rm per bottle from static fields; proves the backend seam. (#357)
  4. Consolidated per-host sidecar (new slice) — one persistent sidecar shared by all bottles (idempotent singleton) instead of one per bottle; source-IP-keyed via the attribution invariant — the core consolidation win. (#358)
  5. Firecracker broker + sidecar — the real work: shim + VM-to-VM routing.
  6. macOS (Apple container) — last.

Stack merge order: #352#356#357#358.

### Updated slice roadmap (now includes the consolidated sidecar) Also written into the PRD's Sequencing section (`8e567ed`). Built as a stack of small PRs off this one: 1. **Dev-harness core** ✅ — SQLite registry + fail-closed attribution (source IP + identity token) + HTTP control plane. *(this PR, #352)* 2. **Launch lifecycle + broker contract** ✅ — launch/teardown + the signed, structured `LaunchBroker` request (HS256 JWT, static ids/flags only, provenance-verified, fail-closed); stub broker + registry rollback. *(#356)* 3. **Docker launch broker** ✅ — first real broker: `docker run`/`rm` per bottle from static fields; proves the backend seam. *(#357)* 4. **Consolidated per-host sidecar** ✅ *(new slice)* — one persistent sidecar shared by all bottles (idempotent singleton) instead of one per bottle; source-IP-keyed via the attribution invariant — the core consolidation win. *(#358)* 5. **Firecracker broker + sidecar** — the real work: shim + VM-to-VM routing. 6. **macOS (Apple container)** — last. Stack merge order: #352 → #356 → #357 → #358.
didericis added 1 commit 2026-07-13 17:01:31 -04:00
docs(prd): 0070 expand the consolidated-sidecar arc in the roadmap (#352)
test / unit (pull_request) Successful in 1m1s
test / integration (pull_request) Successful in 21s
test / coverage (pull_request) Successful in 1m2s
32e9bb95e0
Split the sidecar work into explicit slices: lifecycle (done), build the
real bundle image (done), source-IP-keyed multi-tenant config +
registration/reload (the functional core, next), and routing agent bottles
through it. Renumber firecracker/macos to 8/9. Slices 1-5 implemented
(#352/#356/#357/#358/#360).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Author
Collaborator

Roadmap update — consolidated-sidecar arc expanded (32e9bb9)

The "make the consolidated sidecar real" work is genuinely a multi-slice arc; split it out in the PRD Sequencing:

  • 4. Sidecar — lifecycle idempotent singleton (#358)
  • 5. Sidecar — build the real bundle image build bot-bottle-sidecars from Dockerfile.sidecars and launch that singleton (#360)
  • 6. Sidecar — source-IP-keyed multi-tenant config — the functional core: per-bottle CA/routes/keys/policy become multi-tenant, selected by verified source IP, with per-bottle add/remove (live reload). One shared instance can't serve multiple bottles' distinct policies until this lands.
  • 7. Sidecar — route agent bottles through it — wire each bottle's egress/git to the shared sidecar instead of a per-bottle bundle.

Then 8. Firecracker and 9. macOS. Slices 1–5 implemented (#352/#356/#357/#358/#360).

### Roadmap update — consolidated-sidecar arc expanded (`32e9bb9`) The "make the consolidated sidecar real" work is genuinely a multi-slice arc; split it out in the PRD Sequencing: - **4. Sidecar — lifecycle** ✅ idempotent singleton *(#358)* - **5. Sidecar — build the real bundle image** ✅ build `bot-bottle-sidecars` from `Dockerfile.sidecars` and launch *that* singleton *(#360)* - **6. Sidecar — source-IP-keyed multi-tenant config** — the functional core: per-bottle CA/routes/keys/policy become multi-tenant, selected by verified source IP, with per-bottle add/remove (live reload). *One shared instance can't serve multiple bottles' distinct policies until this lands.* - **7. Sidecar — route agent bottles through it** — wire each bottle's egress/git to the shared sidecar instead of a per-bottle bundle. Then **8. Firecracker** and **9. macOS**. Slices 1–5 implemented (#352/#356/#357/#358/#360).
Some checks are pending
test / unit (pull_request) Successful in 1m1s
test / integration (pull_request) Successful in 21s
test / coverage (pull_request) Successful in 1m2s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin prd-orchestrator:prd-orchestrator
git checkout prd-orchestrator
Sign in to join this conversation.