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.
This commit is contained in:
1 parent
e4e6ccfb14
commit
1baa619ce9
52 files changed
+1504
-1306
No files matched your search
+219
@@ -0,0 +1,219 @@
|
||||
# OQ-42: Dynamic Resource Ownership for Runtime-Spawned Resources
|
||||
|
||||
- **Origin**: [alknet-docker POC summary](../../research/alknet-docker/poc-summary.md)
|
||||
§"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](crates/core/auth.md) (`Identity.resources`,
|
||||
`AccessControl::check` interaction — both under edit by this decision),
|
||||
[operation-registry.md](crates/call/operation-registry.md)
|
||||
(`AccessControl`, `OperationSpec` — `resource_id_path` addition),
|
||||
[alknet-docker POC summary](../../research/alknet-docker/poc-summary.md)
|
||||
§"Open Unknowns" #3
|
||||
Reference in new issue
Block a user