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.
8.5 KiB
ADR-063: Exit Code on a Terminal call.responded for Non-Interactive Exec
Status
Accepted
Context
The alknet-docker POC (docs/research/alknet-docker/poc-summary.md
§"POC Target 3") validated a completion shape for streaming exec:
the exit code rides on a final call.responded frame before
call.completed. This keeps call.completed's payload empty
(matching ADR-012's wire format — no core protocol change). The POC
used { "exitCode": N } on the final call.responded.
The docker spec (docker-operations.md §"docker/container/exec")
carries this forward and adds a "terminal": true field to mark the
exit-code call.responded as the final value before completion. The
terminal flag is a docker-operation convention, not a call-protocol
wire-format change — it's a field in the call.responded payload
(the Value), not a new event type or a call.completed payload.
A reader asked: "why terminal: true and not a dedicated call.exit
event or an exit code on call.completed?" This ADR records the
decision.
Why not an exit code on call.completed
ADR-012 defines call.completed with an empty payload ({}). The
POC's completion-shape decision (POC §"The completion-shape decision
this validates") chose to keep call.completed empty and put the
exit code on the preceding call.responded. Changing call.completed
to carry a payload would be a wire-format change (ADR-012 amendment,
new ALPN per ADR-006) — a one-way door that affects every
call.completed consumer, not just docker. The POC deliberately
avoided this.
Why not a dedicated call.exit event
A new event type (call.exit) would be a wire-format addition (new
EventEnvelope variant, ADR-012 amendment). It would also duplicate
the "terminal value before completion" semantics that
call.completed already provides — call.completed is the
"stream end" signal; an exit code is a "final value" that rides
before the stream end. A EventEnvelope variant conflates these
two: call.exit would be both the final value and the stream-end
signal, but call.completed already handles stream end. Keeping
the exit code as a call.responded (a normal value) and letting
call.completed be the stream end preserves the
one-value-per-call.responded invariant: the exit code is just the
last value.
Why terminal: true
A client consuming a docker/container/exec stream sees a sequence
of call.responded frames (stdout/stderr lines) and then a final
call.responded with { "exitCode": N, "terminal": true }, then
call.completed. The terminal: true flag tells the client "this
is the last value; the next event is call.completed." Without it,
the client would have to infer "this call.responded has an
exitCode field, so it's the exit" — a content-sniffing heuristic
that breaks if a future stdout line happens to carry an exitCode
field.
The terminal flag is a docker-operation convention — a field in
the call.responded payload, not a protocol-level marker. It's
declared in the docker/container/exec output_schema (the exit
frame is a documented part of the output stream). Other
Subscription operations that produce a "terminal result before
completion" can adopt the same convention (a terminal: true field
on their final call.responded); it's not docker-specific, but
docker is the first operation to need it.
Decision
1. The exec exit code rides on a final call.responded with terminal: true
docker/container/exec (non-interactive, tty: false) emits:
- Zero or more
call.respondedframes with stdout/stderr output ({ "stream": "stdout"|"stderr", "text": "..." }). - A final
call.respondedwith{ "exitCode": N, "terminal": true }. call.completed(empty payload, per ADR-012).
The terminal: true field marks the exit-code frame as the final
value before completion. The exitCode is i32 (matching
ExecInspectResponse.exit_code; negative for signal-terminated,
though docker exec exit codes are typically non-negative).
2. call.completed stays empty
No call.completed payload change. ADR-012's wire format is
unchanged. The exit code is on the preceding call.responded, not
on call.completed.
3. terminal: true is an operation-level convention, not a protocol field
The terminal flag is a field in the call.responded payload (the
Value), declared in the operation's output_schema. It is not a
new EventEnvelope field, not a new event type, and not a
call.completed payload. The call protocol's wire format is
unchanged. Other Subscription operations that produce a terminal
result before completion may adopt the same convention; it's not
docker-specific, but docker is the first to use it.
The OperationSpec.output_schema for docker/container/exec
documents the two response shapes: the streaming output frames
({ "stream": ..., "text": ... }) and the terminal exit frame
({ "exitCode": N, "terminal": true }). A client reading the schema
knows to expect the terminal frame.
4. The exit-code frame is the last call.responded before call.completed
The handler ensures the exit-code call.responded is the last one
the stream produces. The StreamingHandler (ADR-049) pumps the
output stream, then emits the exit-code frame, then the stream ends
(the dispatcher writes call.completed on natural stream end). The
ordering is the handler's responsibility (the handler controls the
stream's item order); the dispatcher's pump_stream writes them in
order. This mirrors the POC's validated pattern.
Consequences
Positive:
- The exit code propagates through the streaming completion path
without a wire-format change.
call.completedstays empty (ADR-012 unchanged); the exit code is a normalcall.respondedvalue that happens to be the last one. - The
terminal: trueflag gives clients a deterministic "this is the exit" signal without content-sniffing theexitCodefield. A client can stop reading output frames when it seesterminal: trueand read the exit code. - The convention is reusable: any
Subscriptionoperation with a terminal result before completion can useterminal: trueon its finalcall.responded. No protocol change; just an operation-level field.
Negative:
- The
terminalflag is a convention, not enforced by the wire format. ASubscriptionoperation that emits a terminal result withoutterminal: truewould be non-conformant but not protocol-violating. Theoutput_schemadocuments the convention; clients that read the schema know to expect it. - A client that doesn't check
terminal: trueand instead content-sniffsexitCodewould work for docker exec but break on a hypothetical stdout line that carries anexitCodefield. The flag is the robust path; content-sniffing is the fragile path. The spec recommends the flag; the schema documents it.
Door type
One-way. The exit-code-on-call.responded-before-call.completed
shape is the completion contract docker/container/exec commits to.
Clients consuming the exec stream depend on this ordering; changing
it (moving the exit code to call.completed, or adding a call.exit
event) would be a break for those clients.
The terminal: true field name is two-way-door within the one-way
shape — it could be renamed (e.g., final: true) without a wire-
format change, since it's a payload field, not a protocol marker.
But the presence of a terminal marker on the final call.responded
is the one-way commitment: clients depend on "the last
call.responded before call.completed is marked as terminal and
carries the exit code."
References
docs/research/alknet-docker/poc-summary.md§"POC Target 3" (exec with exit code — the validated pattern this ADR formalizes)docs/research/alknet-docker/poc-summary.md§"The completion-shape decision this validates" (the POC's reasoning for exit-on-call.responded, not oncall.completed)- ADR-012 — the call protocol
wire format this ADR keeps unchanged (
call.completedstays empty) - ADR-049 —
StreamingHandler, the dispatch path that pumps thecall.respondedstream and writescall.completedon stream end - ADR-023 —
output_schemadocuments the terminal frame shape - ADR-058 — the operation
taxonomy (exec is a
Subscription) - Spec: docker-operations.md §"docker/container/exec" (the operation this ADR's shape serves)