ADR-0001: Closed-architecture decomposition of billet
Status
Accepted (2026-06-30). Establishes the architecture the Host subsystem (slice 4) and every later slice build on.
Context
billet lifts the cloud-devbox orchestration out of gswa-backend into a standalone tool
that provisions/manages Azure Hosts and starts/stops/connects multiple repos'
devcontainer Workspaces on them. The lifted shell (scripts/devbox/*.sh) conflates two
concerns that change for different reasons:
- Host — the cloud VM lifecycle (create / start / deallocate / pin the inbound SSH rule / install the base supply chain). This is where the backend volatility lives: an Azure VM today; DevPod or Microsoft Dev Box later.
- Workspace — a repo's devcontainer on a Host (clone, compose up, bootstrap, connect, ssh-config). This changes per-repo and per-connectivity-decision, not per-cloud-backend.
We decompose by volatility (Juval Löwy's IDesign method / closed architecture) rather than by function or by the lifted scripts' accidental structure. The goal is a tool that scales to new backends and many workspaces without reshaping the core.
Decision
1. Layered closed architecture, calls go down only
Components are ordered by volatility; a higher layer may call any lower layer, never the
reverse. The import graph is enforced in CI by import-linter (a layers contract):
billet.cli ── Typer client / composition root (wires concretes to Protocols)
billet.workspace ── Workspace subsystem (contracts/engine/manager) [slice 5+]
billet.host ── Host subsystem (manager)
billet.access ── ResourceAccess: AzureVmHostProvider, RegistryAccess, …
billet.contracts ── data contracts + service Protocols (the seam) ← new this slice
billet.infrastructure ── side-effecting primitives (az, ssh, process)
billet.shared ── cross-cutting utilities (paths, logging, errors)
workspace sits above host because WorkspaceManager.start calls HostManager.up
(slice 5). host sits above access because the manager orchestrates ResourceAccess.
2. A dedicated billet.contracts layer (the load-bearing decision)
The cross-layer data contracts — HostSpec, WorkspaceSpec, HostStatus,
HostPowerState — and the service Protocols (HostProvider) live in their own
billet.contracts layer, not under host/contracts/ as the working plan first
sketched.
Why: access sits below host in the call graph, yet AzureVmHostProvider (in
access/) must produce a HostStatus and accept a HostSpec, and the HostManager (in
host/) must depend on the HostProvider interface. If those types lived in host/, the
access-layer implementation would import upward into host/, inverting the graph and
breaking the layer contract.
This is precisely Löwy's prescription: service contracts (interfaces + the data
contracts they exchange) belong in a separate Contracts component that everything
references and that itself references nothing domain-specific. It is Dependency Inversion
realized as a module — both the high-level policy (HostManager) and the low-level detail
(AzureVmHostProvider) depend on the abstraction; the abstraction depends on nothing but
shared. contracts is placed directly above infrastructure so that infrastructure
stays pure (it cannot import domain contracts — that would be an upward import), while
access, host, workspace, and cli may all depend on contracts downward.
Contracts are pure data + interface declarations (no behavior, no I/O), so modelling them as the bottom-most callable-free layer is faithful: a layer that everything references and that calls nothing.
3. HostProvider is the volatility seam; the backend is fully encapsulated
HostProvider (a typing.Protocol in contracts/) is the single seam that absorbs
cloud-backend volatility. Its surface is the minimal set of operations a backend must
provide to make a Host usable:
class HostProvider(Protocol):
def status(self, spec: HostSpec) -> HostStatus: ... # power state + public IP
def create(self, spec: HostSpec) -> None: ... # cold provision (BILLABLE)
def start(self, spec: HostSpec) -> None: ... # resume a deallocated host
def deallocate(self, spec: HostSpec) -> None: ... # stop compute billing
def pin_inbound(self, spec: HostSpec) -> str: ... # re-pin SSH NSG to operator /32
def wait_until_reachable(self, spec: HostSpec) -> None: ... # block until SSH answers
def ensure_supply_chain(self, spec: HostSpec) -> None: ... # install Docker (idempotent)
create and start are deliberately separate (not a single ensure_up) so the
billable cold-create is an explicit, gateable step in the plan. wait_until_reachable
and ensure_supply_chain are on the provider — not called directly by the manager over
infrastructure.ssh — because what it takes to make a host usable is itself
backend-specific: an Azure VM needs Docker apt-installed over SSH; a Dev Box ships with it.
The manager stays backend-agnostic; a future DevPodHostProvider / DevBoxHostProvider is
a new adapter in access/ implementing the same Protocol, with zero changes to host/
or contracts/.
AzureVmHostProvider (in access/host/) implements the Protocol over
infrastructure/{az,ssh,process}.py. The composition root (cli) constructs the concrete
provider and injects it into HostManager; the manager only ever sees the Protocol.
4. dry-run / confirm live at the client/plan layer
The manager builds a Plan (an ordered, typed list of PlanSteps) from the HostSpec
and the live HostStatus; the client (cli) either renders it (--dry-run) or
executes it. access stays purely side-effecting — it never decides whether to run.
- A read of current status (a non-mutating
azquery) happens during planning even under--dry-run, so the rendered plan is accurate (mirrors the lifted bash, which queries VM state under--dry-runbut gates mutations). - The billable cold-create gate is a confirm prompt in
cli, fired when the plan contains aCREATEstep, bypassable with--yes, and skipped under--dry-run(nothing executes). This keeps the cost gate where the human is.
5. Stateless; operator intent in one config.toml; tags as truth
There is no local state file. Operator intent lives in a single config.toml
(~/.config/billet/config.toml by default; --config / BILLET_CONFIG override).
RegistryAccess is the sole reader of that file and parses it into HostSpec /
WorkspaceSpec. Everything else — power state, public IP, host↔workspace mapping — is
derived live from Azure and resource tags. On create, the VM is stamped
managed-by=billet and billet-host=<host-key> so later discovery (slice 5+) reads truth
from Azure rather than a cache that can drift.
6. devcontainer.json is a read-only data contract, not a lifecycle engine
Each repo keeps owning its .devcontainer/. The Workspace subsystem (slice 5) reads
devcontainer.json (service, compose file, workspace folder, postCreate) as facts and
drives compose + postCreate over SSH itself. We do not adopt @devcontainers/cli as
the lifecycle engine — billet owns the VM lifecycle and connectivity; the repo owns its
container definition; devcontainer.json is the contract between them.
Consequences
- The
HostProviderProtocol is testable with a single in-memory fake typed as the Protocol; pyright-strict verifies the concreteAzureVmHostProviderconforms structurally. Managers are tested against the fake; access is tested by spying on the exactaz/sshargv. - Adding a cloud backend is additive: one new adapter, no core change.
- The billable cold-create can never run without passing through the
cliconfirm gate; the inbound SSH rule is always pinned to a single/32(never0.0.0.0/0) — both are enforced by security-analog unit tests. - Slight indirection cost: contributors must learn that contracts live in
contracts/, not beside the subsystem that "owns" them. The CI layer contract makes violations loud. - This ADR refines the working plan's
host/contracts/placement and its singleensure_upprovider verb;docs/contributing/and the plan should be read through this ADR.
Alternatives considered
- Contracts under
host/contracts/(per the initial plan), withimport-linterexceptions foraccess → host. Rejected: normalizes upward imports and punches holes in the layer contract; the inversion would only spread as more access adapters land. - Protocols in
host/, DTOs inshared/(structural typing letsaccessskip importing the Protocol). Workable but splits the contract across two homes and overloadsshared(paths/logging/errors) with domain data; less discoverable than one named layer. - A single
ensure_upprovider method. Rejected: it hides the cold/resume distinction and the billable boundary from the manager, defeating the plan-layer cost gate. @devcontainers/clias the container lifecycle engine. Rejected: it would own a concern (per-repo container lifecycle) that belongs to each repo, and pull a Node toolchain into a Python tool whose job is the host and connectivity.- A local state/cache file for host↔workspace mapping and IPs. Rejected: drifts from reality; Azure + tags are the single source of truth.