Files
alknet/docs/architecture/decisions/062-docker-client-injection-via-closure-capture.md
T
glm-5.2 1ce11717d8 docs(arch): draft alknet-docker architecture specs (ADRs 058-063, OQs 048-051)
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.
2026-07-08 09:29:22 +00:00

10 KiB

ADR-062: Docker Client and OwnershipStore Injection via Closure Capture

Status

Accepted

Context

The docker operation handlers need access to two non-secret, shared runtime handles:

  1. The bollard Docker client — a Clone-able handle (internally an Arc) to the docker daemon connection, constructed once at assembly-layer startup.
  2. The OwnershipStore — the ADR-050 write-side trait, used by docker/container/create (to record) and docker/container/remove (to revoke).

An earlier draft of the docker spec (docker-operations.md) showed handlers calling ctx.docker_client() and speculated the handle lived on "a DockerOpsExt extension trait on OperationContext, or a DockerClient capability in ctx.capabilities." This was an inline open question with a model conflict:

Why Capabilities is the wrong channel

Capabilities (ADR-014, operation-registry.md §"Capability Injection") is the type for outbound secret material — decrypted API keys, signing keys, vault-derived credentials. Its contract (core-types.md): non-Serialize, zeroized on drop, populated from the vault at registration time. A bollard Docker handle to a local unix socket is not secret material; it has no zeroizing semantics; it must not be smuggled through the secret-injection channel. Putting a Docker handle in Capabilities would be a category error against the one-way Capabilities contract (ADR-014).

The same applies to the OwnershipStore: it's a shared state handle (shared Arc<dyn OwnershipStore>), not secret material.

Why OperationContext extension is the wrong channel

OperationContext (operation-registry.md:230) is a concrete struct with a fixed field set, constructed by the dispatch path per call. Adding a docker_client field (or an extension trait that reads one) would either (a) make every non-docker handler pay for a field they don't use, or (b) require a typed-map / downcast pattern that violates the "context is concrete, not a bag" design. The established pattern for per-handler-set shared state is not a context field — it's closure capture.

The established pattern: closure capture at registration

The from_openapi adapter (ADR-017, client-and-adapters.md, operation-registry.md:819) captures its reqwest::Client in each forwarding handler's closure at registration time:

// operation-registry.md:819 (the established pattern)
.with_local(vastai_listMachines_spec(), Arc::new(vastai_handler),
            CompositionAuthority::new("vastai", ["vastai:query"]),
            ScopedOperationEnv::new([...]),
            Capabilities::new().with_api_key("vastai", vastai_token))

The vastai_handler closure captures its reqwest client (and its auth token via Capabilities, which is secret material). The non-secret shared handle (reqwest client) is captured in the closure; the secret (the API key) goes through Capabilities. This is the correct split: secret material through Capabilities, non-secret shared state through closure capture.

The docker ops follow the same split. The Docker client and the OwnershipStore are non-secret shared state — closure-captured. There are no secret capabilities (local bollard needs no API key; the docker daemon is local).

Decision

1. register_docker_ops captures the Docker client and OwnershipStore in each handler closure

The register_docker_ops function (or the DockerOps builder) takes Arc<Docker> and Arc<dyn OwnershipStore> (plus the label config and the CompositionAuthority) as arguments, and constructs each Handler / StreamingHandler closure with those handles captured by reference (Arc::clone per handler):

pub fn register_docker_ops(
    builder: &mut OperationRegistryBuilder,
    docker: Arc<bollard::Docker>,
    ownership_store: Arc<dyn OwnershipStore>,
    labels: &DockerLabels,
    authority: CompositionAuthority,
) {
    let docker_clone = docker.clone();
    let ownership_clone = ownership_store.clone();
    let labels_clone = labels.clone();
    builder.with_local(
        container_inspect_spec(),
        Arc::new(move |input, ctx| {
            let docker = docker_clone.clone();
            Box::pin(async move {
                let container_id = input["containerId"].as_str()
                    .ok_or_else(|| /* INVALID_INPUT */)?;
                match docker.inspect_container(container_id, None::<()>).await {
                    Ok(info) => ResponseEnvelope::ok(to_json_value(info)),
                    Err(e) => /* CONTAINER_NOT_FOUND / DOCKER_ERROR */,
                }
            })
        }),
        authority.clone(),
        ScopedOperationEnv::empty(),
        Capabilities::new(),  // no secret caps — local bollard
    );
    // ... same pattern for each operation ...
}

Each handler closure captures the Docker client and (where needed) the OwnershipStore by cloning the Arc. The handler does not read these from OperationContext; they're baked into the closure at registration time. The Capabilities passed to with_local is empty (Capabilities::new()) — there are no secret capabilities for local bollard operations.

2. The Docker client is shared, Clone-able, and constructed once

bollard's Docker is Clone (it holds an internal Arc to the connection). The assembly layer constructs one (Docker::connect_with_local_defaults(), ADR-059 §2), wraps it in an Arc, and passes it to register_docker_ops. Each handler clones the Arc (cheap — a refcount bump) into its closure. There is no per-handler docker client; the one client is shared across all docker operations.

3. The OwnershipStore is shared the same way

Arc<dyn OwnershipStore> is cloned into the create and remove handler closures (the two operations that write to the store). The other operations (inspect, start, stop, logs, exec, list, images) don't capture the store — they don't write to it. The create closure captures it for record; the remove closure captures it for revoke.

4. No docker_client() accessor; no OperationContext extension

The handlers do not call ctx.docker_client() or any other accessor. The Docker client and OwnershipStore are not on OperationContext and are not accessible via an extension trait. They're closure- captured, full stop. A handler that needs the docker client gets it from its closure's captured Arc<Docker>, not from the context.

This means the handler signature is the standard Fn(Value, OperationContext) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>> (ADR-049) — no new parameter, no new context field. The docker- specific state is in the closure, not the context.

Consequences

Positive:

  • The injection model matches the established from_openapi pattern (non-secret shared state via closure capture, secret material via Capabilities). No new mechanism; no OperationContext change; no Capabilities conflict.
  • The Capabilities contract (ADR-014) stays clean — it holds secret material only. A local bollard handle is not smuggled through the secret channel.
  • The handlers are self-contained — the Docker client and store are baked in at registration, not looked up at call time. This is testable (construct a Docker client in a test, register the ops, invoke the handler directly) and composable (the same handler works whether the docker client points at a local socket or a test daemon).
  • No new trait, no extension mechanism, no downcast. The injection is plain Rust closure capture — the simplest thing that works.

Negative:

  • Each handler closure captures the Docker and (for create/remove) the OwnershipStore by Arc::clone. This is a refcount bump per handler construction (at startup, once) — negligible. The per-call cost is zero (the closure already holds the Arc; calling the handler doesn't re-clone).
  • The register_docker_ops function signature is longer (takes the Docker + store + labels + authority). This is the assembly layer's wiring concern, not a handler concern — the handlers don't know where their Docker came from.
  • A test that wants to invoke a single docker handler must construct the Docker client and (if the handler uses the store) the OwnershipStore to register the op. This is the same as any handler test that needs its dependencies — not new.

Door type

One-way. The decision to use closure capture (not a context field, not a Capabilities entry) is the injection model every docker handler is written against. Reversing to a context field would be a rewrite of every handler and an OperationContext change. The specific captures (Docker + OwnershipStore) are the non-secret state the handlers depend on; changing what's captured is a handler rewrite.

The choice of Arc<Docker> vs a non-Arc shared reference is a two-way-door implementation detail (bollard's Docker is already Clone-with-Arc-internally; the outer Arc is for the trait-object OwnershipStore's sake, not the Docker's). The capture pattern (closure capture) is the one-way commitment.

References

  • ADR-014 — the Capabilities contract this ADR keeps clean (secret material only; the Docker handle is not secret)
  • ADR-017 — the from_openapi/from_call adapter pattern this ADR follows (non-secret shared state via closure capture)
  • ADR-022 — HandlerRegistration, the bundle the register_docker_ops builder populates
  • ADR-050 — the OwnershipStore trait the create/remove handlers capture
  • ADR-058 — the operation surface this injection model serves
  • crates/call/operation-registry.md §"Capability Injection" (the Capabilities semantics this ADR respects) and §"OperationContext" (the struct this ADR does not extend)
  • crates/call/client-and-adapters.md §"from_openapi forwarding" (the prior art: reqwest client closure-captured, API key in Capabilities)
  • Spec: docker-operations.md §"Handler injection" (the section this ADR adds, replacing the earlier ctx.docker_client() sketches)