Files
alknet/docs/architecture/questions/042-dynamic-resource-ownership-for-runtime-spawned-resources.md
T
glm-5.2 1baa619ce9 docs(arch): decompose open-questions.md into per-OQ files under questions/
The monolithic open-questions.md (1310 lines, 47 OQs) was large enough to be
unmanageable, with high size variance (OQ-42 at 220 lines next to OQ-06 at 8).
Decomposed into one file per OQ under docs/architecture/questions/ (NNN-slug.md,
mirroring the ADR convention), with open-questions.md retained as the index:
theme-grouped tables plus a cross-theme Deferred/Blocked section that surfaces
the 6 deferred OQs with their Blocked-on conditions inline (the safe-exit
visibility surface). Per-OQ content moved verbatim; all 62 inbound links stay
valid (none used anchors). README's curated OQ summary dropped (now redundant
with the index tables).

Also seeds tasks/architecture/ with this task plus two follow-ups found during
the decompose: OQ-09/10 missing structured Blocked-on fields, and the
tasks/architecture/ blocker-task half of the Safe Exit protocol being
unenforced.
2026-07-06 16:07:59 +00:00

14 KiB

OQ-42: Dynamic Resource Ownership for Runtime-Spawned Resources

  • Origin: alknet-docker POC summary §"Open Unknowns" #3 (container-as-resource identity model); generalized during the Phase 1 review pass triggered by that research finding.

  • Status: resolved — all five sub-questions are decided (storage shape, integration point, proxy-vs-grant, and the four clarifications). The ADR will write these into decision text; the dependent crate specs can declare their AccessControl shapes against this model.

  • Door type: One-way (the AccessControl::check signature change and the OperationSpec.resource_id_path addition in core/call), two-way (the ownership provider mechanism, per the established repo/adapter pattern)

  • Priority: high — blocks the alknet-docker, alknet-tty, opencode-runner wrapper, and alknet-container (fleet normalization) crate specs. None of those specs can declare their AccessControl shapes until this is resolved, because the model available to them determines what ACL declarations are even expressible. Permitting "docker picks a per-crate default and the others follow" is the door-type-as-deferral anti-pattern (ADR-009 §"What this framework is NOT"): each crate bakes in an ACL shape and downstream crates build on whatever default was picked, making the "cheap reversal" expensive.

  • Resolution.

    Decision 1 — storage side: reuse the repo/adapter pattern. The ownership store is a fourth instance of the established repo/adapter pattern (ADR-033), alongside IdentityProvider (ADR-004), IdentityStore (ADR-035), and CredentialStore (ADR-031). Concretely: a trait in alknet-core (read method: "does identity X own resource R with action A?" / "what resources of type T does X own?"; write method: "record X spawned R", "revoke R on teardown"), with an in-memory default adapter in core. The in-memory default carries the docker/runner cases with no backend dependency — ownership is runtime state, meaningless across restarts (a container ID from a previous process doesn't exist), so the default case has no persistence requirement. A persistence adapter (e.g. sqlite/honker-backed, for a hub that wants fleet ownership to survive restarts) is separable and built when a concrete use case forces it, same as alknet-store-sqlite for peer/credential persistence. The read stays sync (called from AccessControl::check on the dispatch hot path, no .await), with persistence adapters caching in memory and using honker NOTIFY for invalidation — same ArcSwap-backed full-reload pattern as ConfigIdentityProvider (ADR-035). No new shape invented on the storage side; no Phase 0 needed for it.

    Decision 2 — integration point: Option 2, check consults the ownership provider directly. AccessControl::check grows a parameter for the ownership provider (or reads one carried on OperationContext), and consults it for resource_type/resource_action checks against runtime-spawned resources. The alternative considered and rejected — Option 1, augmenting Identity.resources with a per-request snapshot before calling check — preserves check's purity by moving the impurity one frame up the stack: the dispatcher would pull owned resources into a per-request identity snapshot so check looks unchanged while reading state that was never part of the static identity. The purity was always theatrical (the question "can X exec into container C" was never purely a function of identity; it just looked that way because the resource set was static). Option 2 makes check's signature honest about what ACL checking is in the presence of dynamic resources: a function of (ACL, Identity, current-ownership- state). The impurity is real either way; Option 2 puts it in the signature where it's visible, rather than hiding it in a per-request snapshot pretending to be static identity. Option 3 (handler-level ownership check, AccessControl gates only scope) was rejected because it splits the ACL story — some resources statically checked, some handler-checked — which is the kind of inconsistency that creates the "figure out how it fits with what is there" cleanup this OQ exists to prevent.

    The cost of Option 2 is a check signature change — a one-way door, every call site and test updates. Per the project's decision principle (implementation workload is a non-issue relative to semantic correctness and long-term clarity; "path of least resistance compounded over many decisions is strictly dominated"), this is implementation cost, not a semantic cost, and does not bias the choice.

    Refinement that makes Option 2 work cleanly: OperationSpec declares where the resource ID lives in the input. OperationSpec gains a resource_id_path: Option<String> — a JSON pointer into the operation input, e.g. "$.containerId" for docker/container/exec. The dispatcher extracts the resource ID from the input using the spec-declared path, passes it to check, and check asks the provider "does this identity own <resource_type>/<resource_id> with action <resource_action>?" — a single targeted lookup, not a whole-resource-set pull. The fit with JSON Schema is load-bearing, not incidental: OperationSpec.input_schema is already a JSON Schema, so resource_id_path is a pointer within an existing schema on the same spec. The OperationSpec becomes fully self-describing for authorization — what resource type, what action, and which input field drives the resource lookup. No per-namespace conventions, no handler-level knowledge, no "the dispatcher just knows." The contract is on the spec, where it belongs.

  • Resolved specifics (the four questions the ADR must write into decision text). The decisions above settle the storage shape and the integration point. The four specifics below settle how the model behaves at the edges:

    1. No-specific-resource operations (the list case) — scope-gate + result-filter, composing. Operations with resource_type set but resource_id_path absent — e.g. docker/container/list, which doesn't reference a specific container. When a coordinator lists containers it owns, it should see only its own — not every container on the host. That's not just scope-gating ("can you call container/list at all?") and not just result-filtering ("return only owned") — it's both: scope-gate the call (does the peer have the container:list scope), then filter the result to owned resources. The default is "allow if scoped, filter to owned." list is the case that forces this; exec/inspect/stop against a specific container are the clean case (single targeted ownership lookup via resource_id_path). The ADR states the default and how a spec declares which it wants.

    2. Teardown coupling — automatic, handler-driven. The ownership store's write path (revoke on teardown) is coupled to the spawned resource's lifecycle. The "burn it and start over" capability depends on ownership state tracking the lifecycle correctly. When a container dies or is destroyed, the ownership entry is revoked by the handler that managed the lifecycle (the docker handler calls revoke on container exit), not by an operator workflow or a background reaper. The burn-and-start-over pattern is: destroy container → ownership revoked automatically → spawn new container → new ownership recorded. If teardown weren't automatic, stale ownership entries would accumulate and the "burn" path would leave dangling ACL state. The architectural commitment is: handler-driven revoke on lifecycle end, not a reaper. The coupling mechanism (explicit handler call vs. lifecycle-hook abstraction) is two-way-door implementation work.

    3. Fleet representation (spoke resources on the hub) — per-node ownership, downstream app tracks "who is this for." Under the proxy pattern (Decision 3 below), the docker node records "coordinator owns C" in its local ownership store. The coordinator's "I started C for agent Y" mapping lives in the coordinator's own downstream-app state, not in the core ownership store. The ownership store is per-node (each docker node records its local ownership); the hub's agent-to-workspace mapping is app state. There is no cross-node ownership propagation in the base model — the spoke sees the hub as the owner, and the hub's "who is this for" is its own concern. The proxy pattern keeps ownership local, which is why this question is less consequential than originally framed.

    4. Composition interaction — two separate checks, no change to CompositionAuthority. In the proxy pattern, the coordinator composes docker/container/exec on behalf of an agent. Two checks must pass: (a) the coordinator's CompositionAuthority has the container:exec scope (static, ADR-015/022 unchanged), and (b) the coordinator owns this specific container (dynamic, ownership store). The composition authority stays static — it doesn't grow a dynamic path. The ownership store handles the dynamic resource-level check. Both must pass; they're orthogonal. ADR-015/022 don't need amendment — the composition authority is unchanged, and the ownership store is an additional check, not a modification to the existing one.

  • Decision 3 — access pattern: proxy-only as the base model. The base model is "spawner owns, proxy to share, teardown revokes" — with no grant/transfer mechanism in the core ownership store. Two patterns for how a downstream consumer reaches a runtime-spawned resource were identified:

    • Proxy pattern (the common case, and the only one the core model supports). A coordinator starts a container and manages its lifecycle; the end user never talks to docker directly. The coordinator re-exports the docker operations it wants to expose (via from_call — the adapter that imports a peer's operations and re-registers them locally, ADR-017 — or by composing them in its own handlers), and when the end user invokes one, the coordinator is the direct caller to the docker endpoint. Docker's ownership store sees the coordinator as the owner and as the caller — the check passes. The end user's identity rides as forwarded_for metadata (ADR-032), and the coordinator does whatever end-user-level ACL it wants at its own layer. This is the kernel/user-land + forwarded-for model: the hub's authority is used, forwarded_for is metadata, the hub handles its own ACL.

    • Grant pattern ("poking holes") — not in the core model. A downstream app wants to give an end user direct call-protocol access to the docker endpoint for specific containers — the end user calls docker/container/exec themselves, not through a proxy. Docker's ownership store would need a record that the end user has access to that container, even though the downstream app spawned it. No described use case requires this. The agent-workspace case — the concrete one — is entirely the proxy pattern: the coordinator starts the workspace container; the agent interacts with what's inside the container (a TTY, an opencode instance's API surface), not with docker operations on the container. Docker-level operations (stop, remove, inspect) are the coordinator's job.

    "Poking holes" is a downstream-app concern — the app that owns the resources re-exports the operations it wants to share via from_call with its own ACL layer, rather than the core ownership store growing a grant API. The ADR commits to proxy-only and explicitly states that "poking holes" is a downstream app's job.

    A future grant mechanism is additive, not a one-way door closure. If a use case forces the grant pattern, it's a new method on the ownership store trait (grant(identity, resource) / revoke_grant(...)). AccessControl::check already consults the ownership provider; a grant-aware provider would answer "yes" for grantees in addition to owners, without a trait-shape change. The two-way-door classification (additive) is stated here as reversal-cost classification, not as a reason to defer the decision — the decision is made (proxy-only), and the cost of reversing it if a future use case forces it is low. If the grant pattern is later admitted, specifics 3 and 4 above are revisited: cross-node ownership propagation returns to the table (3), and composition under a grant would need CompositionAuthority to grow a dynamic path, amending ADR-015/022 (4).

  • Cross-references: ADR-009 (door-type-as-deferral anti-pattern), ADR-015, ADR-022 (the static CompositionAuthority.resources model this extends — see open question 4), ADR-030, ADR-032 (forwarded_for metadata — the proxy pattern's end-user-identity carrier), ADR-033 (repo/adapter pattern — reused for the ownership store), ADR-035 (IdentityStore — administrative peer mutations, a different concern from runtime resource ownership, but the sync-read + ArcSwap + honker-NOTIFY shape is reused), auth.md (Identity.resources, AccessControl::check interaction — both under edit by this decision), operation-registry.md (AccessControl, OperationSpec — resource_id_path addition), alknet-docker POC summary §"Open Unknowns" #3