ADR-0009: the published set is closed at three, and a test decides any fourth
Status
Accepted (2026-07-27). Extends ADR-0008, which
established that billet publishes Workspace identity as tmux user options but did not bound
what. Decision only: no behavior changes, and — uniquely among the ADRs here — no
implementation is expected to follow. The outcome is that TmuxStatusEngine stays as
ADR-0008 left it.
Context
ADR-0008 rule 1 names three published options — @billet_workspace, @billet_host,
@billet_color — and describes them as "identity". It does not say what else qualifies, and
the namespace is free to extend: set @foo always succeeds, costs one more command in an
already-chained prelude, and breaks nothing. A contract that is free to widen and never says
where it stops will widen.
Six candidates were raised when ADR-0008 was written and deferred without evaluation: Host power state, Host public IP, container running state, repo branch / dirty state, the resolved devcontainer service name, and a "cold-provisioned-this-session" marker.
What the connect path actually holds
connect (src/billet/cli/workspace_commands.py:363-378) resolves exactly three things
before building the argv: the WorkspaceSpec, the HostSpec, and the DevcontainerFacts
read live from the Host. It makes no cloud-provider call — _remote_via_alias
(workspace_commands.py:124-126) reaches the Host through its ssh-config alias precisely so
that no az round-trip is needed. connect is the only Workspace lifecycle command with
that property, and it is why attaching is fast.
Everything on HostStatus — power_state, public_ip, vm_size — is therefore not in
hand. Publishing any of it means adding a provider call to the one path that has none.
Separately, billet is stateless between invocations (workspace_commands.py:132-136: "billet
is stateless: it never writes config.toml"), so connect cannot know anything that only
start observed.
How the channel behaves — measured on tmux 3.7b
The prelude is leading: tmux <prelude> new-session -A -s <session>. That position was
chosen in ADR-0008 to survive the re-attach short-circuit, and it has a consequence that ADR
did not draw out — the prelude re-runs on every billet connect, so published values are
refreshed per connect, not fixed for the life of the session. Verified against an
already-existing session:
$ tmux -L s set -g @billet_x first
$ tmux -L s set -g @billet_x second \; new-session -A -d -s ws-alpha
$ tmux -L s display -p '#{@billet_x}'
second
The staleness window is therefore "between two connects" — which is bounded for an operator who reconnects daily and unbounded for a session left attached for a week.
Three mechanisms could carry state that changes faster than that, and all three are closed:
- billet pushing updates. An external
tmux set -g @optdoes update a live session immediately, verified: pushingalphathenbetato@billet_workspaceon an attached session re-renderedstatus-leftasws=alphathenws=betawith no reattach. But billet is not alive to push.connectends in_execvp(argv)(workspace_commands.py:378), replacing the billet process withssh; there is no daemon and no return. - An in-container billet agent doing the pushing. This would require writing into the container, which ADR-0008 already rejected for the config-fragment alternative ("it would require writing files into the container, which the connect path deliberately does not do"), and it would hand billet a supervised process lifecycle it does not currently own.
#()shell formats on the consumer side. This works and is the operator's to use. Its cost, measured: one fork per attached client perstatus-interval— 6 invocations in ~6 s atstatus-interval 1, and 0 in ~8 s atstatus-interval 15. Bounded and predictable, but paid in the container, by the theme, on every tick, forever.
So the boundary is not a preference. Anything mutating faster than the reconnect cadence cannot reach a user option at all, and the only live channel available runs on the far side of the ownership line ADR-0008 drew.
Decision
The published set is closed at @billet_workspace, @billet_host, and @billet_color.
None of the six candidates is published. A fourth option is admitted only by passing all four
criteria below.
- In hand. Available on the connect path with no new I/O — no provider call, no extra
ssh round-trip.
connect's zero-cloud-call property is a feature and is not for sale. - Stable across connects. It cannot change between two
billet connectinvocations, or it changes only via an event that itself forces a reconnect (so the republish is self-healing and the stale value is never observed). - Not derivable in-session. A consumer inside the container cannot compute it more cheaply and more accurately than billet can hand it over.
- Identity, not telemetry. It answers "which Workspace is this?" (ADR-0008 rule 1). The user-option namespace is an identity channel, not a metrics bus.
Against those, the six candidates and the other values connect happens to hold:
| Candidate | 1 in hand | 2 stable | 3 not derivable | 4 identity | Verdict |
|---|---|---|---|---|---|
| Host power state | no — needs az |
yes | no — tautology | no | reject |
| Host public IP | no — needs az |
yes, self-healing | yes | no | reject |
| Container running state | yes | yes | no — tautology | no | reject |
| Repo branch / dirty | no | no | no — #() |
no | reject |
| Devcontainer service name | yes | yes | marginal | no | reject |
| Cold-provisioned marker | no | no | n/a | no | reject |
workspace_folder, remote_user, container_alias, container_ssh_port |
yes | yes | no | no | reject |
Four of these deserve their reasoning stated rather than tabulated:
- Power state and container-running are tautologies. At the instant the prelude runs, the
operator has an SSH session through a running Host into a running container. Both values
are the constant
true, bought — in the Host's case — with a cloud round-trip. Publishing a constant spends contract surface to convey nothing, which is rule 4 of ADR-0008 applied to data instead of glyphs. - Public IP is the closest thing to a real miss. It is genuinely stable (a deallocate/start
cycle that changes the IP also destroys the container and the tmux session, forcing a
reconnect that republishes) and genuinely not derivable from inside. It fails on cost and on
criterion 4: it is a routing fact, not an identity, it already lives in
~/.ssh/config.d/billet.conf, andbillet lsalready reports it. A status bar is not where an IP is needed. - Branch and dirty state is the strongest thing an operator will actually want, and it is
the clearest non-candidate. It changes many times an hour, so no per-connect publication
can be honest about it. It is also the cheapest thing to compute in-session. This is not
billet declining to provide a feature; it is the feature belonging in the theme, as
#()runninggit -C … rev-parse --abbrev-ref HEAD, at the cost measured above. - The devcontainer service name is the genuine judgment call. It passes criteria 1 and 2
cleanly and is the only rejected candidate that does. It fails 4: a service name is a repo
fact, and ADR-0002 already drew that exact line —
src/billet/contracts/workspace.py:1-9keeps container facts out ofWorkspaceSpecon the grounds that they "change for different reasons, on different cadences, authored by different people". Publishingfacts.serviceunder a@billet_prefix would re-cross a boundary the project drew deliberately, and it would do so to disambiguate multi-service stacks that the shell prompt and hostname already distinguish. A reasonable reader could decide this row the other way; if the multi-service case ever bites in practice, this is the row to revisit.
Consequences
- The question is closed with a test rather than a list. A seventh candidate gets adjudicated in four lines instead of relitigated, and the answer is auditable after the fact.
TmuxStatusEngine.render_preludekeeps its three-value signature, its golden-string tests stay exhaustive, and the prelude stays short enough to read in apsline.connectkeeps its zero-cloud-call property. That is now a stated invariant with a rule defending it, not an accident of how the feature happened to be built.- Live state is explicitly relocated, not refused:
docs/adopting-a-repo.mdshould carry the#()guidance and the measured per-tick cost alongside the ADR-0008 consuming snippet, so an operator who wants branch-on-the-bar knows exactly where it goes and what it costs. - The cost is the one ADR-0008 already accepted, now applied to a wider surface: everything interesting an operator might want on the bar lives in a second repo with no CI and no tests. This ADR makes that permanent for live state specifically.
- Criterion 4 is the softest of the four — "identity" is a judgment, not a predicate — and the service-name row is where that softness bites. Criteria 1–3 are mechanical; 4 is where a future disagreement will land.
Alternatives considered
- Publish everything already in hand (service, workspace folder, remote user, container
alias, port) on the reasoning that each is one more
setin a chain that already exists. Rejected: free to write is not free to own. Every published name is contract surface a second, untested repo may start consuming, and ADR-0008 already records that neither repository can validate the other. Widening the untestable half to save a few keystrokes in the tested half is the wrong direction. - Add a provider probe to
connectsopublic_ip,power_state, andvm_sizebecome publishable. Rejected: it buys status-bar decoration with anazround-trip on the most latency-sensitive command billet has, and it does so on every attach, including the many attaches to an already-running session. - An in-container agent pushing live options via
tmux set -g. Verified to work — this is the only mechanism that would make branch/dirty publishable — and rejected on ownership: it requires writing into the container, and it makes billet responsible for a long-running process's lifecycle inside a container it otherwise only visits. - A generic passthrough —
--publish k=v, or a[workspaces.<key>.publish]table letting the operator publish arbitrary values. Rejected: it does not answer the boundary question, it relocates it to the operator one Workspace at a time, and every value they would plausibly reach for is one of the rows above — so it would mostly serve as a supported way to publish stale data. Worth reconsidering only for a concrete need that passes criteria 1–3 and fails only 4. - Say nothing and decide case by case. Rejected: that is the status quo that produced six unevaluated candidates and a namespace with no stated edge. The cheapest moment to bound a contract is before anything consumes it.