Greenfield architecture spec set for the alknet-docker crate — a thin, single-host bollard wrapper exposing docker container/image operations as call-protocol ops on the shared alknet/call ALPN, plus a DockerTtyBackend (impl TtyBackend) behind a tty feature for interactive terminal sessions into containers over alknet/tty. Six ADRs: - 058: docker ops on alknet/call (no separate ALPN; raw-carriage handoff dissolved by alknet-tty extraction — interactive attach moved to alknet/tty via DockerTtyBackend, no carriage field on call.requested) - 059: bollard 0.21 (verified current on crates.io) + feature selection (http+pipe+time; no ssl/ssh/websocket/buildkit) - 060: container resource model (ADR-050 application) — alknet.managed/ alknet.owner labels, list owned_only flag, hosted-services operator role via static-resource fallback, handler-driven revoke with autonomous-death tolerance, resource-action vocabulary - 061: DockerTtyBackend in alknet-docker behind tty feature (attach vs exec mode; POC drive_attach_raw as reference) - 062: Docker client + OwnershipStore injection via closure capture (not Capabilities, not OperationContext — matches from_openapi pattern) - 063: exit code on terminal call.responded for non-interactive exec (call.completed stays empty, ADR-012 unchanged) Four spec docs (crates/docker/): README, overview, docker-operations, docker-tty-backend. Four deferred-scope OQs (048-051): network/volume ops, buildkit, system events subscription, create options surface. Updates the tty-backend.md Backend implementations table (DockerTtyBackend row now specced) and the architecture README (doc table, ADR table, current-state paragraph, OQ count). Grounded in the alknet-docker POC (docs/research/alknet-docker/poc-summary.md) which validated the hard parts; the remaining lifecycle ops are mechanical bollard wrapping. Reviewed by architecture-reviewer subagent; criticals (the docker_client injection model conflicting with the Capabilities contract) resolved via ADR-062/063 before commit.
14 KiB
ADR-058: alknet-docker Registers on alknet/call (No Separate ALPN)
Status
Accepted
Context
The alknet-docker POC (docs/research/alknet-docker/poc-summary.md)
left two integration questions open (its "Open Unknowns" #1 and #2):
-
Raw-carriage handoff in the dispatcher. The POC's
drive_attach_rawreads thecall.requestedframe itself, then switches the bidi stream to raw chunks for interactive attach. The POC noted this doesn't fit the call dispatcher'shandle_stream→dispatch()→DispatchResult::Stream(ResponseStream)path, which pumps a stream ofEventEnvelopes (JSON carriage). The POC offered two options: (a) branchhandle_streamon acarriagefield, handing raw streams to aRawHandler; or (b) a separate ALPN (alknet/docker-raw) that owns the whole stream. -
ALPN layout. Should docker ops register on the shared
alknet/callALPN (operations in the sharedOperationRegistry) or get their ownalknet/dockerALPN (as aProtocolHandler)? The POC leaned shared but didn't decide.
Both questions were open before alknet-tty was extracted. The
alknet-tty crate spec (ADR-052, ADR-053) resolves both by moving the one
operation that needed raw carriage — interactive attach/exec with a PTY
— out of the call protocol entirely and into its own ALPN
(alknet/tty), backed by the TtyBackend trait.
Why the raw-carriage problem dissolved
The POC's raw-carriage handoff was hard because it tried to do two
different things on one alknet/call stream:
- Structured operations (lifecycle, logs, inspect) — naturally
JSON-shaped, fit
call.responded/call.completedexactly. This is what the call protocol is for. - Interactive attach — a bidirectional byte pump (stdin/stdout/stderr)
with a control sideband (resize, signal, exit). This is not a
request/response or subscription; it's a terminal session. Forcing it
through
EventEnvelopeframing is "wasteful and lossy" (POC §"Why not JSON for everything?").
alknet-tty was created precisely to extract the second category into its
own protocol (alknet/tty) with its own wire format (ADR-052). The
docker POC's drive_attach_raw became the seed of alknet-tty's
DockerTtyBackend (ADR-061). What remains in alknet-docker's call
operations is the first category — structured operations that map cleanly
to call.requested/call.responded/call.completed.
The POC's concern ("the dispatcher would need a carriage field and a
RawHandler branch") is no longer operative: there is no raw-carriage
operation on alknet/call. The raw byte pump moved to alknet/tty; the
DockerTtyBackend implements TtyBackend and is reached through the
TtyAdapter, not the CallAdapter. See ADR-061 for the backend
placement.
Why shared alknet/call, not a separate alknet/docker ALPN
The remaining docker operations (lifecycle, logs, exec-with-exit-code, inspect, list, images) are ordinary call-protocol operations. They have:
- A structured input (JSON), a structured output (JSON), or a stream of structured events (logs, image pull progress, exec output).
- Natural
OperationSpecs with input/output JSON Schemas. AccessControldeclarations against the ADR-050 container-as-resource model (resource_type: "container",resource_id_path: "$.containerId").- Service discovery through
services/list/services/schema.
Putting them on a separate alknet/docker ALPN would mean a separate
ProtocolHandler that re-implements framing, dispatch, ACL, and service
discovery — or a thin wrapper that delegates to the call protocol's
machinery. Either way, it's a parallel dispatch surface for no benefit.
The shared registry is more composable: docker ops are callable from any
call client, including peer routing (PeerRef / from_call re-export),
which is the primary use case — a coordinator on the hub composing
docker/container/exec on a worker spoke.
A separate ALPN is warranted when the protocol's wire format is
incompatible with EventEnvelope framing (alknet-tty, alknet-ssh). The
docker operations remaining on alknet/call are all
EventEnvelope-shaped. The one that wasn't (raw attach) moved to
alknet-tty. See ADR-052 §"Why not JSON for everything?" for the boundary
criterion.
Logs and exec streaming — still JSON carriage, still alknet/call
Two operations look like they might need raw carriage but don't:
docker/container/logs— aSubscriptionoperation. bollard'slogs()returns aStream<LogOutput>; eachLogOutputbecomes acall.respondedcarrying{ "stream": "stdout"|"stderr", "text": "..." }; stream end →call.completed. This is theStreamingHandlershape (ADR-049). The POC validated this path (docker_logs_subscription_pumps_frames_and_completes). No raw carriage needed — each log line is naturally JSON-shaped.docker/container/exec(non-interactive, no TTY) — aSubscriptionoperation. bollard'sstart_execreturnsStartExecResults::Attached { output, input }; the output stream is pumped ascall.respondedframes; after the stream ends,inspect_exec()gives the exit code, which rides on a finalcall.respondedwith{ "exitCode": N, "terminal": true }beforecall.completed. The POC validated this (docker_exec_streams_output_and_exit_code). This is the exec path fortty: false— a captured command with separate stdout/stderr and an exit code, not an interactive terminal.
The interactive exec path (tty: true, bidirectional, resize/signal) is
not a call operation — it's a DockerTtyBackend session on
alknet/tty. ADR-061 covers that. The split is: non-interactive exec is
a call Subscription; interactive exec is a tty session. The tty: bool field in the operation input selects the path; the caller picks.
Decision
1. alknet-docker registers its operations on the shared alknet/call ALPN
alknet-docker does not register a ProtocolHandler. It constructs an
OperationRegistry (or a DockerOps registration bundle consumed by
the assembly layer's OperationRegistryBuilder) and the existing
CallAdapter dispatches docker operations through the shared
OperationRegistry::invoke() / invoke_streaming() paths. There is no
alknet/docker ALPN, no DockerProtocolHandler, and no parallel
dispatch surface.
This makes docker operations first-class citizens of the call protocol:
they appear in services/list, they have OperationSpecs with JSON
Schemas, they go through the standard AccessControl::check, and they
compose with from_call re-export and peer routing (ADR-029) like any
other operation.
2. There is no raw-carriage operation on alknet/call
The carriage field the POC proposed for call.requested is not added.
The call protocol's EventEnvelope framing is the only carriage on
alknet/call. Interactive attach/exec (the one operation that needed
raw carriage) is reached through alknet/tty via DockerTtyBackend
(ADR-061), not through a call operation. The POC's open question #1
(raw-carriage handoff in the dispatcher) is resolved by removing the
requirement, not by adding a branch.
3. The operation taxonomy
| Operation | Call op type | bollard method | Carriage |
|---|---|---|---|
docker/container/list |
Query | list_containers |
JSON |
docker/container/inspect |
Query | inspect_container |
JSON |
docker/container/create |
Mutation | create_container |
JSON |
docker/container/start |
Mutation | start_container |
JSON |
docker/container/stop |
Mutation | stop_container |
JSON |
docker/container/remove |
Mutation | remove_container |
JSON |
docker/container/restart |
Mutation | restart_container |
JSON |
docker/container/logs |
Subscription | logs |
JSON (StreamingHandler) |
docker/container/exec (tty:false) |
Subscription | create_exec + start_exec + inspect_exec |
JSON (StreamingHandler) |
docker/image/list |
Query | list_images |
JSON |
docker/image/pull |
Subscription | create_image |
JSON (StreamingHandler) |
docker/image/inspect |
Query | inspect_image |
JSON |
Interactive exec (tty: true) and interactive attach are not in this
table — they are alknet/tty sessions via DockerTtyBackend
(ADR-061). A docker/container/exec call operation with tty: true in
the input returns an call.error with code INVALID_INPUT directing
the caller to use alknet/tty; the operation only accepts tty: false
(or absent). This keeps the one-operation-per-carriage invariant clean:
the call operation is captured output; the tty session is interactive.
The full operation surface (including which are in scope for v1 and which are deferred) is in docker-operations.md.
4. The assembly layer composes the registry
alknet-docker exports a DockerOps construct (a set of
HandlerRegistration bundles keyed by operation name) or a
register_docker_ops(&mut builder, docker_client, ownership, labels)
function. The assembly layer (the CLI binary or a hub binary) calls this
to add docker operations to the shared OperationRegistryBuilder
alongside other operations (services discovery, agent ops, etc.). The
Docker bollard client, the OwnershipStore, and the label namespace
config are injected by the assembly layer. See
overview.md §"Assembly Layer Wiring".
Consequences
Positive:
- Docker operations inherit all call-protocol machinery for free:
service discovery, JSON Schema validation,
AccessControl,from_callre-export, peer routing,forwarded_formetadata, abort cascade. No parallel dispatch surface. - The POC's hardest open question (raw-carriage handoff) is resolved by
removal — the operation that needed it moved to alknet-tty. No
dispatcher change, no
carriagefield, noRawHandlertrait. - The operation taxonomy is clean:
Query/Mutationfor lifecycle,Subscription(ADR-049StreamingHandler) for logs/exec/pull. The exit-code-on-final-call.respondedpattern the POC validated (POC target 3) works through the existingStreamingHandler→pump_streampath with no protocol change. - A hub that wants to manage docker on worker spokes composes
docker/container/*throughfrom_call(ADR-017) + peer routing (ADR-029) — the proxy pattern from ADR-050. This is the primary use case and it works by construction.
Negative:
- The
docker/container/execoperation has attyfield whose value constrains the dispatch path:tty: false→ callSubscription;tty: true→ reject, point toalknet/tty. This is a minor impedance — a caller wanting interactive exec must use a different protocol (alknet/tty) than a caller wanting captured exec. This is the correct split (interactive terminal ≠ captured command), but it means the "exec" concept spans two protocols. TheDockerTtyBackend(ADR-061) and thedocker/container/execcall operation share the underlyingbollard::execAPI but diverge at the wire format. This is the same divergence as "SSH exec" (call op) vs "SSH PTY session" (alknet-tty) — the boundary is principled, not accidental. - A deployment that wants docker operations must wire the
OperationRegistry(the assembly layer's job). This is not a downside — it's the same wiring every other operation set requires — but it means alknet-docker is not a "drop-inProtocolHandler" the way alknet-tty is. The trade is composability (shared registry, peer routing) for a slightly more involved assembly step.
Door type
One-way. The decision to register on alknet/call rather than a
separate alknet/docker ALPN is a structural commitment: the operation
names (docker/container/*), the OperationSpecs, the
AccessControl shapes, and the from_call re-export surface all depend
on docker ops being call-protocol operations. Reversing this — moving
docker ops to their own ALPN — would be a rewrite of the operation
surface and a break for any client that calls docker/container/*
through the call protocol.
The decision not to add a carriage field to call.requested is
also one-way: the call protocol's wire format (ADR-012) stays
EventEnvelope-only on alknet/call. If a future operation needs raw
carriage on alknet/call, it would require a wire-format change (new
ALPN per ADR-006, or a call.requested field addition). The expected
path for raw-carriage operations is a separate ALPN (as alknet-tty did),
not a call.requested extension.
References
docs/research/alknet-docker/poc-summary.md§"Open Unknowns" #1 and #2 (the two questions this ADR resolves)- ADR-012 — the call protocol wire
format this ADR keeps unchanged (no
carriagefield) - ADR-017 —
from_callre-export, the proxy pattern's mechanism - ADR-024 — the registry layering docker ops register into
- ADR-029 — peer routing, the head→worker docker management path
- ADR-049 —
StreamingHandler, the dispatch path for logs/exec/pull - ADR-050
— containers as resources, the
AccessControlshape docker ops declare - ADR-052 — alknet-tty's wire format, where the raw-carriage operation moved
- ADR-053 —
TtyBackend, the traitDockerTtyBackendimplements (ADR-061) - ADR-061 —
DockerTtyBackendplacement in alknet-docker - Spec documents: overview.md, docker-operations.md