ADR-0002: The Workspace subsystem — devcontainer.json as the data contract
Status
Accepted (2026-06-30). Builds on ADR-0001
and governs the Workspace subsystem (slice 5): billet add | ls | start | stop | connect |
ssh-config | rm.
Amended (2026-09-14): ADR-0012 names the runtime surface the
Workspace templates publish the Berth and gives it a version; §1's rule — the repo owns its
.devcontainer/, billet reads five fields of devcontainer.json and writes none of it — is
unchanged, and ADR-0014 restates it as the
definition/state boundary. ADR-0015 proposes widening §1 so a
doctor verb may open the compose files billet already resolves by name. That grant is not yet
in effect: nothing in billet opens a compose file, and the amendment takes effect only when
doctor lands (ADR-0014 item 4).
Amended (2026-09-29): doctor has landed for the Berth-file checks. Its read of the four Berth
files a consumer copied into .devcontainer/ (cat from the Host checkout) is in effect from
this release. The compose-file read is still not in effect: no doctor check opens a compose
file yet.
Context
The Host subsystem (ADR-0001) makes a cloud VM exist, reachable, and adopted. The Workspace
subsystem is what runs a repo's devcontainer on that Host and connects the operator to it:
clone the repo, docker compose up, run the devcontainer bootstrap, render an ssh-config, and
drop the operator into a tmux session — reproducing, in Python, the connect path the lifted
scripts/devbox/*.sh provided.
Several decisions here are load-bearing and were deliberately revisited from the first cut the Host slice shipped. They share one theme: decompose by volatility, and let each thing be owned by the actor who actually changes it.
Decisions
1. WorkspaceSpec carries only billet intent; container facts come from devcontainer.json
The first cut (slice 4) pinned service / compose_file / workspace_folder /
container_user / a bootstrap command onto WorkspaceSpec. Slice 5 removes them. The slim
WorkspaceSpec is billet's operator intent only: host, repo_url, repo_dir, the
loopback container_ssh_port, host_alias, container_alias, tmux_session,
agent_teams_flag, host_bootstrap_cmd, verify_cmd.
The repo's container facts — the compose service, the dockerComposeFile(s), the
workspaceFolder, the remoteUser, and the postCreateCommand — are read live from the
repo's own .devcontainer/devcontainer.json into a DevcontainerFacts value object by
ContainerAccess.
Why (volatility + DDD): those facts change for the repo's reasons, on the repo's cadence,
authored by the repo owner — a different volatility axis and a different actor than billet's
operator intent. Löwy says encapsulate each volatility behind its own boundary; DDD frames
devcontainer.json as the repo's published contract, which billet reads through an
anti-corruption boundary (ContainerAccess → DevcontainerFacts) rather than re-declaring.
Carrying those facts in billet's config.toml would split one source of truth into two that
drift (the repo renames its service or bumps workspaceFolder; billet's copy goes stale).
This also realizes ADR-0001 §6 ("devcontainer.json is a read-only data contract").
Consequence: connect and ssh-config read workspaceFolder / remoteUser from facts. The
cost is JSONC parsing (below) and an SSH read of the file on the Host — a bounded, acceptable
price for a single source of truth. connect/ls/stop read it through the ssh-config
host alias, so they need no az call; ssh-config resolves the live IP (az) because the
config it writes must contain a literal HostName.
2. JSONC is parsed in-repo, dependency-free
devcontainer.json is JSONC (comments + trailing commas), which json.loads rejects. We
strip both — string-aware, so a // or ,] inside a value survives — then use the stdlib
parser (billet/shared/jsonc.py), keeping billet's runtime dependency surface at exactly one
package (typer). The strip is the seam: if real-world files ever defeat it, swap the body of
jsonc.loads for pyjson5 with no other change. Golden-tested against gswa's real file.
3. ssh-config moves from an in-place block to an Include file
The lifted install-ssh-config.sh wrote a marker-delimited block into ~/.ssh/config.
Slice 5 evolves this to a tool-owned ~/.ssh/config.d/billet.conf plus exactly one Include
line near the top of ~/.ssh/config. billet overwrites billet.conf wholesale (it owns it)
and otherwise never touches the operator's hand-maintained config. Each Workspace renders a
container entry with ProxyJump + a stable HostKeyAlias (keying known-hosts on a
collision-free name, not the volatile 127.0.0.1:<port> — needed once multiple Workspaces
share a Host in slice 6) and no RemoteCommand (IDE Remote-SSH needs a plain login shell;
tmux is connect's job). The rendered text is golden-tested (SshConfigEngine). Tooling
decision: inline expected strings, no snapshot library — the block is small and stable.
4. billet add validates and prints; it never writes config.toml
tomllib is read-only by design (PEP 680). Rather than add a TOML writer, billet add
validates the operator-authored [workspaces.<key>] block (the host exists, the loopback port
is unique across Workspaces on that host) and echoes the canonical block. It does not edit
config.toml.
Why (volatility + DDD): statelessness is a load-bearing invariant (ADR-0001 §5), not a
convenience — operator intent lives in one operator-authored file; everything else is derived
from Azure + tags. A writer would take on a mutation concern (round-trip, comment/format
preservation, partial-write recovery) that is pure accidental complexity against billet's
mission, and it would cross the ownership boundary between "operator authors intent" and "tool
executes intent" (the same reason terraform/kubectl don't rewrite your files). An in-place
writer is a YAGNI deferral to slice 6, if hand-editing many Workspaces ever proves painful.
5. Host orchestration for start lives at the CLI, not inside WorkspaceManager
ADR-0001 §1 noted workspace sits above host because start needs the Host up. Slice 5
realizes that dependency at the CLI client, not as a WorkspaceManager → HostManager
call: billet start runs the Host plan (with its billable cold-create gate) and then the
Workspace plan. This keeps the cost gate where the human is (ADR-0001 §4) — burying it inside a
manager would force the manager to prompt, which managers must not do — and keeps each manager
single-responsibility. The layer ordering (workspace may call host) is unchanged and still
valid; it is simply not exercised by a manager-to-manager call in this slice.
6. VM adoption is an idempotent HostProvider.ensure_tags step
To "adopt the live VM," billet stamps managed-by=billet + billet-host=<key> on a VM it did
not create. Because tagging is backend-specific, it lives behind the HostProvider seam
(ensure_tags) and is scheduled as an idempotent, non-billable ENSURE_TAGS step on the
resume/running branches of HostManager.plan_up — so any host up (and therefore any
workspace start) adopts the VM. Tags-as-truth (ADR-0001 §5) stays real for slice-6 discovery.
7. connect returns an argv the client execs
WorkspaceManager.connect_target returns the ssh -t … tmux argv; the CLI hands the terminal
off via os.execvp. The manager never replaces the process, so it stays unit-testable (assert
the argv; never exec in a test). Mirrors the lifted connect.sh.
Consequences
- Container facts have one home (the repo). billet config shrinks and cannot drift from the repo's container definition.
- billet stays stateless and dependency-light (still only
typerat runtime). - The Workspace seams (
SourceAccess,ContainerAccess,SshConfigAccess) are testable with Protocol-typed fakes; access is tested by spying the exactssh/docker composeargv. start/ssh-configrequire the repo to be cloned and the Host reachable to read facts — the natural order isstart→ssh-config→connect, which the CLI messages reinforce.- The
postCreateCommandobject form (named parallel commands) is not yet supported; a string or array is. This is an explicit, surfaced limitation (a clear error), not a silent gap.
Alternatives considered
- Keep container facts on
WorkspaceSpec. Rejected: duplicates the repo's owndevcontainer.json, drifts, and contradicts ADR-0001 §6. - A TOML writer for
billet add(tomlkitor hand-rolled). Rejected for slice 5: breaks statelessness and the intent-ownership boundary for a convenience YAGNI until multi-workspace. pyjson5for JSONC. Deferred: the in-repo strip keeps the dependency surface minimal and is golden-tested; the function is the swap-in seam if needed.WorkspaceManager.startcallsHostManager.up. Rejected: would bury the billable gate inside a manager (ADR-0001 §4); the CLI orchestrates both phases instead.- A snapshot library (
syrupy) for ssh-config golden tests. Deferred: inline strings are zero-dependency and the rendered block is small and stable.