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.
This commit is contained in:
glm-5.2 committed 2026-07-08 09:29:22 +00:00
1 parent 5873fa22d6
commit 1ce11717d8
17 files changed
+3196 -9

No files matched your search

@@ -0,0 +1,234 @@
# 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:
```rust
// 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):
```rust
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](014-secret-material-flow-and-capability-injection.md) —
the `Capabilities` contract this ADR keeps clean (secret material
only; the `Docker` handle is not secret)
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) — the
`from_openapi`/`from_call` adapter pattern this ADR follows
(non-secret shared state via closure capture)
- [ADR-022](022-handler-registration-provenance-and-composition-authority.md)
— `HandlerRegistration`, the bundle the `register_docker_ops`
builder populates
- [ADR-050](050-dynamic-resource-ownership-for-runtime-spawned-resources.md)
— the `OwnershipStore` trait the `create`/`remove` handlers capture
- [ADR-058](058-alknet-docker-on-alknet-call.md) — 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](../crates/docker/docker-operations.md)
§"Handler injection" (the section this ADR adds, replacing the
earlier `ctx.docker_client()` sketches)