Files
alknet/docs/architecture/decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md
glm-5.2 f6ddd37433 docs(arch): add ADR-050 — dynamic resource ownership for runtime-spawned resources
Writes OQ-42's five decisions into ADR format:

1. Storage: reuse the repo/adapter pattern (ADR-033, fourth instance
   alongside IdentityProvider/IdentityStore/CredentialStore). New traits:
   OwnershipProvider (sync read, hot-path) + OwnershipStore (async write,
   handler lifecycle). In-memory default; persistence adapter additive.
2. Integration: AccessControl::check consults the ownership provider
   directly (Option 2). OperationSpec gains resource_id_path (JSON pointer
   into the input). Backward-compatible — ownership=None falls back to
   the static Identity.resources path.
3. Access pattern: proxy-only. Spawner owns, proxy to share via from_call
   + forwarded_for (ADR-032), teardown revokes. No grant mechanism in
   core. Future grant is additive (new trait method), stated as
   reversal-cost classification, not deferral.
4. Four edge specifics: list = scope-gate + result-filter; teardown =
   automatic, handler-driven; fleet = per-node ownership, downstream app
   tracks 'who is this for'; composition = two orthogonal checks,
   ADR-015/022 unchanged.

Reviewed: zero critical issues. Two warnings fixed (None-handling in the
check sketch, missing ADR-004 cross-ref). One suggestion applied
('v1 mechanism' → 'initial mechanism' to avoid hedging misread).
2026-07-04 16:08:04 +00:00

31 KiB

ADR-050: Dynamic Resource Ownership for Runtime-Spawned Resources

Status

Accepted (resolves OQ-42; amends the AccessControl::check signature and adds OperationSpec.resource_id_path. Does not amend ADR-015 or ADR-022 — specific #4 confirms the composition authority stays static. Blocks lifted: the alknet-docker, alknet-tty, opencode-runner wrapper, and alknet-container (fleet normalization) crate specs can declare their AccessControl shapes against this model.)

Context

The alknet-docker POC (docs/research/alknet-docker/poc-summary.md) surfaced a class of resource that the existing auth model doesn't handle: runtime-spawned resources with derived ownership. A coordinator starts a container and exposes docker operations over the call protocol; the question "is this peer allowed to docker/container/exec against container C?" is not answerable by the static Identity.resources model — Identity.resources is config-sourced (from PeerEntry on the fingerprint path, from CompositionAuthority on the composition path) and set at registration or connection time. The container didn't exist then. Its ownership was derived at spawn time: whoever started it owns it.

This generalizes beyond docker. Every "spawn a thing at runtime and expose it over the call protocol" crate has the same shape:

  • alknet-docker — containers as AccessControl resources.
  • alknet-tty — terminal sessions as resources (who owns this TTY?).
  • opencode-runner wrapper — workspace containers / processes as resources.
  • alknet-container (fleet normalization) — the fleet layer that normalizes container operations across hosts.

None of those crate specs can declare their AccessControl shapes until the core model answers: how does AccessControl::check learn whether identity X owns runtime-spawned resource R?

OQ-42 tracked this. The OQ resolved five sub-questions:

  1. Storage shape — reuse the repo/adapter pattern (ADR-033).
  2. Integration point — AccessControl::check consults an ownership provider directly (Option 2), with OperationSpec gaining a resource_id_path JSON pointer.
  3. Access pattern — proxy-only (spawner owns, proxy to share, teardown revokes; no grant mechanism in core).
  4. Four edge specifics — the list case, teardown coupling, fleet representation, composition interaction.

This ADR writes those decisions into ADR format.

Why the static model breaks

The current AccessControl::check is a pure function of (ACL, Identity):

// operation-registry.md (current)
pub struct AccessControl {
    pub required_scopes: Vec<String>,
    pub required_scopes_any: Option<Vec<String>>,
    pub resource_type: Option<String>,          // e.g., "service"
    pub resource_action: Option<String>,        // e.g., "read"
}

impl AccessControl {
    pub fn check(&self, identity: Option<&Identity>) -> bool {
        // scope check: identity.scopes ⊇ required_scopes
        // resource check: identity.resources[resource_type] ∋ resource_action
    }
}

Identity.resources is a HashMap<String, Vec<String>> — named resource lists, populated from PeerEntry.resources (fingerprint path, ADR-030) or CompositionAuthority.resources (composition path, ADR-022). Both are static: PeerEntry is config-sourced; CompositionAuthority is set at registration. Neither grows at runtime.

For a static resource set ("alice can access services gitea and registry"), this works: the config lists the resources, the identity carries them, check matches them. For a runtime-spawned resource ("alice can exec into container C which didn't exist when the config was written"), there's nowhere to record that alice owns C. The identity was resolved at connection time, before C was spawned. The ownership exists — the coordinator that started C knows alice asked for it — but the auth model has no path from that knowledge to check.

The options considered

Three integration points were considered (see OQ-42 for the full reasoning):

  • Option 1 — augment Identity.resources with a per-request snapshot. The dispatcher would pull owned resources into a per-request identity snapshot before calling check, so check looks unchanged while reading state that was never part of the static identity. Rejected: the purity was always theatrical (the question "can X exec into C" was never purely a function of identity; it just looked that way because the resource set was static). Option 1 hides the impurity in a snapshot pretending to be static identity.

  • Option 2 — check consults the ownership provider directly. AccessControl::check grows a parameter (or reads one from OperationContext) for the ownership provider, and consults it for resource_type/resource_action checks against runtime-spawned resources. Accepted. This 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.

  • Option 3 — handler-level ownership check, AccessControl gates only scope. Some resources statically checked, some handler-checked. Rejected: it splits the ACL story — the kind of inconsistency that creates the "figure out how it fits with what is there" cleanup this ADR exists to prevent.

The two access patterns

Walking through the concrete use cases (the agent-workspace case in particular) surfaced two patterns for how a downstream consumer reaches a runtime-spawned resource:

  • Proxy pattern (the common case). 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"). 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.

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. No described use case requires the grant pattern. This ADR commits to proxy-only (Decision 3).

Decision

1. Storage: reuse the repo/adapter pattern (fourth instance)

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). A trait in alknet-core with an in-memory default adapter:

// alknet-core

/// Read side: consulted by AccessControl::check on the dispatch hot path.
/// Sync — called in the accept/dispatch loop, no .await.
pub trait OwnershipProvider: Send + Sync + 'static {
    /// Does `identity` own `resource_type/resource_id` with `action`?
    /// Called when AccessControl has resource_type + resource_action set
    /// and the dispatcher has extracted resource_id from the input via
    /// OperationSpec.resource_id_path.
    fn owns(
        &self,
        identity: &Identity,
        resource_type: &str,
        resource_id: &str,
        action: &str,
    ) -> bool;

    /// What resources of `resource_type` does `identity` own?
    /// Called for the `list` case (resource_type set, resource_id_path
    /// absent) — the result-filter path. Returns the set of resource IDs
    /// the caller owns, for the handler to filter against.
    fn owned_resources(
        &self,
        identity: &Identity,
        resource_type: &str,
    ) -> Vec<String>;

    /// Does `identity` own *any* resource of `resource_type`?
    /// Called for the `list` case — the scope-gate path. Cheap boolean
    /// for the "allow if scoped" default.
    fn owns_any(
        &self,
        identity: &Identity,
        resource_type: &str,
    ) -> bool;
}

/// Write side: called by the handler that manages the resource lifecycle.
/// Async — not on the dispatch hot path.
#[async_trait]
pub trait OwnershipStore: Send + Sync + 'static {
    /// Record that `identity` spawned `resource_type/resource_id`.
    /// Called by the docker handler after `docker/container/create`
    /// succeeds.
    async fn record(
        &mut self,
        identity: &Identity,
        resource_type: &str,
        resource_id: &str,
    ) -> Result<(), OwnershipError>;

    /// Revoke ownership of `resource_type/resource_id`.
    /// Called by the docker handler on container exit / removal
    /// (specific #2 — handler-driven teardown).
    async fn revoke(
        &mut self,
        resource_type: &str,
        resource_id: &str,
    ) -> Result<(), OwnershipError>;
}

/// In-memory default adapter. 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).
pub struct InMemoryOwnershipStore { /* HashMap<...> */ }

The read trait (OwnershipProvider) is sync — called from AccessControl::check on the dispatch hot path, no .await. The write trait (OwnershipStore) is async — called by handlers off the hot path.

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 (ADR-035). The in-memory default carries no persistence; ownership is runtime state. A persistence adapter would cache in memory and use honker NOTIFY for invalidation — same ArcSwap-backed full-reload pattern as ConfigIdentityProvider (ADR-035).

The read/write split mirrors ADR-035. OwnershipProvider (read, sync) is the trait the dispatch path depends on. OwnershipStore (write, async) is the trait the handler lifecycle calls. The in-memory default implements both. A persistence adapter would implement both with an in-memory read cache backed by SQLite, same as SqliteIdentityProvider implements IdentityProvider (sync, cached) + IdentityStore (async write).

2. Integration: AccessControl::check consults the ownership provider

AccessControl::check grows a parameter for the ownership provider. The provider is carried on OperationContext (populated by the dispatch path from the registry's wiring), not threaded through every call site manually:

pub struct AccessControl {
    pub required_scopes: Vec<String>,
    pub required_scopes_any: Option<Vec<String>>,
    pub resource_type: Option<String>,
    pub resource_action: Option<String>,
}

impl AccessControl {
    /// `ownership` is None when the operation has no resource_type
    /// (pure scope check) or when no ownership provider is wired
    /// (the static `Identity.resources` path — backward compatible).
    /// `resource_id` is None for the `list` case (resource_type set,
    /// resource_id_path absent — specific #4a).
    pub fn check(
        &self,
        identity: Option<&Identity>,
        resource_id: Option<&str>,
        ownership: Option<&dyn OwnershipProvider>,
    ) -> bool {
        // 1. Scope check (unchanged): identity.scopes ⊇ required_scopes.
        //    If identity is None and scopes are required, deny here.
        // 2. Resource check (only if self.resource_type is Some):
        //    a. If resource_id is Some(id) and ownership is Some(p):
        //         → identity must be Some (owns takes &Identity, not
        //           Option); if identity is None, deny. Otherwise
        //         → p.owns(identity.unwrap(), resource_type, id, resource_action)
        //    b. If resource_id is None (the `list` case) and ownership is Some(p):
        //         → if identity is None, deny; otherwise
        //         → p.owns_any(identity.unwrap(), resource_type)  [scope-gate; see #4a]
        //    c. If ownership is None → fall back to static
        //       identity.resources[resource_type] ∋ resource_action
        //       (backward compat for non-runtime resources; identity
        //       may be None here — empty resources → deny if action required)
    }
}

The resource_id parameter is extracted by the dispatcher from the operation input using OperationSpec.resource_id_path (Decision 2a below). When the spec has no resource_id_path (the list case), the dispatcher passes resource_id: None, and check takes the scope-gate path (specific #1).

Backward compatibility. When ownership is None, check falls back to the static Identity.resources path. This means existing operations with static resource sets (no runtime spawning) work unchanged — the ownership provider is an additional check, not a replacement. The signature change is a one-way door (every call site and test updates), but the semantic change is additive: operations that don't wire an ownership provider behave exactly as before.

2a. OperationSpec gains resource_id_path

pub struct OperationSpec {
    pub name: String,
    pub namespace: String,
    pub op_type: OperationType,
    pub visibility: Visibility,
    pub input_schema: Value,
    pub output_schema: Value,
    pub error_schemas: Vec<ErrorDefinition>,
    pub access_control: AccessControl,
    /// JSON pointer into the input for the resource ID, when
    /// `access_control.resource_type` is set and the operation targets a
    /// specific runtime-spawned resource. e.g., `"$.containerId"` for
    /// `docker/container/exec`. Absent for no-specific-resource operations
    /// (the `list` case — specific #1). The dispatcher extracts the
    /// resource ID from the input using this path and passes it to
    /// `AccessControl::check`.
    pub resource_id_path: Option<String>,
}

The fit with JSON Schema is load-bearing, not incidental: 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.

3. Access pattern: proxy-only

The base model is "spawner owns, proxy to share, teardown revokes" — with no grant/transfer mechanism in the core ownership store.

When a coordinator spawns a container and wants to expose docker operations on it to an end user, the coordinator re-exports those operations via from_call (or composes them in its own handlers). The end user invokes the re-exported operation; the coordinator is the direct caller to the docker endpoint; docker's ownership store sees the coordinator as owner and caller; the check passes. The end user's identity rides as forwarded_for metadata (ADR-032), and the coordinator handles its own end-user-level ACL at its own layer.

"Poking holes" (the grant pattern) is a downstream-app concern, not a core-model 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 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).

4. The four edge specifics

4a. The list case: scope-gate + result-filter, composing

Operations with resource_type set but resource_id_path absent — e.g. docker/container/list — don'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:

  1. Scope-gate (the call): does the peer have the container:list scope? This is the static required_scopes check — unchanged. If the peer doesn't have the scope, the call is denied before the ownership provider is consulted.
  2. Result-filter (the response): the handler calls OwnershipProvider::owned_resources(identity, "container") and filters the result to only the containers the caller owns. The default is "allow if scoped, filter to owned."

The scope-gate is AccessControl::check's scope path (static, unchanged). The result-filter is a handler-level concern — the handler calls the ownership provider's owned_resources method and filters. The ADR states the default ("allow if scoped, filter to owned") and the composition (scope-gate the call, then filter the result). A spec declares it wants this by setting resource_type without resource_id_path.

exec/inspect/stop against a specific container are the clean case: resource_id_path: Some("$.containerId"), the dispatcher extracts the ID, check calls owns(identity, "container", id, "exec") — a single targeted lookup.

4b. 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 OwnershipStore::revoke on container exit), not by an operator workflow or a background reaper.

The burn-and-start-over pattern is:

  1. Destroy container → handler calls revoke("container", id) → ownership revoked automatically.
  2. Spawn new container → handler calls record(identity, "container", new_id) → new ownership recorded.

If teardown weren't automatic, stale ownership entries would accumulate and the "burn" path would leave dangling ACL state — an ACL check could reference a resource that no longer exists, and a reused container ID could grant access to the wrong caller.

The architectural commitment is: handler-driven revoke on lifecycle end, not a reaper. The coupling mechanism (explicit handler call vs. a lifecycle-hook abstraction the handler framework provides) is two-way-door implementation work — the docker handler calling revoke directly is the initial mechanism (explicit handler call); a lifecycle-hook abstraction is a refinement if multiple resource-spawning crates share the pattern.

4c. Fleet representation: per-node ownership, downstream app tracks "who is this for"

Under the proxy pattern (Decision 3), 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 (the hub's Identity is what the spoke's ownership store records), and the hub's "who is this for" is its own concern, tracked in the hub's app state, carried as forwarded_for metadata on the wire (ADR-032).

This simplifies fleet representation: the proxy pattern keeps ownership local. The spoke doesn't need to know about the end user; the hub doesn't need to push ownership records to the spoke. The hub authenticates as itself (its own auth_token), the spoke records the hub as the owner, and the hub's end-user ACL is its own layer.

4d. 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:

  1. Static scope check (ADR-015/022, unchanged): the coordinator's CompositionAuthority has the container:exec scope. This is the existing CompositionAuthority.scopes check — static, set at registration, no dynamic path.
  2. Dynamic ownership check (this ADR): the coordinator owns this specific container. This is the new OwnershipProvider::owns check — dynamic, consults the 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 and ADR-022 do not need amendment.

The CompositionAuthority.resources field (ADR-022, line 180: resources: HashMap<String, Vec<String>>) continues to serve its existing purpose: static resource lists for composition authority (e.g., {"service": ["vastai", "github"]} bounds which services the handler can reach in composition). It is not involved in the dynamic ownership check — that's the ownership provider's job. The two are separate:

  • CompositionAuthority.resources — static, "what services can this handler compose," checked against the composition authority's declared resource lists.
  • OwnershipProvider::owns — dynamic, "does this identity own this specific runtime-spawned resource," checked against the ownership store.

A handler composing docker/container/exec passes both: its composition authority has container:exec in its scopes (static), and the ownership provider confirms it owns the container (dynamic).

Consequences

Positive:

  • The alknet-docker, alknet-tty, opencode-runner wrapper, and alknet-container crate specs can declare their AccessControl shapes against a single coherent model. The block on those specs is lifted.
  • The ownership store reuses the established repo/adapter pattern (ADR-033) — no new shape invented on the storage side. The in-memory default carries the docker/runner cases with no backend dependency; a persistence adapter is additive when a use case forces it.
  • OperationSpec is fully self-describing for authorization: resource type, action, and which input field drives the resource lookup are all on the spec. No per-namespace conventions, no handler-level knowledge.
  • The proxy-only model keeps the base model simple: spawner owns, proxy to share, teardown revokes. The forwarded_for metadata (ADR-032) is the end-user-identity carrier; the coordinator handles its own ACL. No grant API in the core ownership store.
  • ADR-015/022 are unchanged. The composition authority stays static; the ownership store is an additional check, not a modification to the existing one. The privilege model stays coherent with the ownership model.
  • The list case has a clean default ("allow if scoped, filter to owned") that composes scope-gating and result-filtering without conflating them.
  • Teardown is automatic and handler-driven, so the "burn it and start over" pattern leaves no dangling ACL state.

Negative:

  • AccessControl::check gains a parameter. This is 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), this is implementation cost, not semantic cost.
  • OperationSpec gains a field (resource_id_path). Spec-constructing code (tests, adapter registrations) must add the field. The field is Option<String>None for operations without runtime-spawned resources, so existing specs are unchanged in shape (the field defaults to None).
  • alknet-core gains two new traits (OwnershipProvider, OwnershipStore) and an in-memory default adapter. Each trait is a contract downstream crates depend on. The trait shapes are the one-way doors; the adapter shapes are two-way.
  • The handler that manages a resource's lifecycle has an additional responsibility: calling record on spawn and revoke on teardown. If a handler forgets to call revoke, stale ownership entries accumulate. This is the coupling requirement (specific #2) — it's the handler's job, not the framework's, and the handler framework can provide a lifecycle-hook abstraction to reduce boilerplate (two-way-door mechanism work).
  • The ownership provider is consulted on every resource-typed AccessControl::check. The in-memory default is a HashMap lookup — negligible. A persistence adapter caches in memory (sync read from cache, same ArcSwap pattern as ConfigIdentityProvider), so the hot path stays sync and fast.
  • The proxy-only decision means a downstream app that wants to give an end user direct access to a runtime-spawned resource must build its own re-export + ACL layer, rather than using a core grant mechanism. This is the intended trade — "poking holes" is the app's job, not the core model's. If a future use case forces the grant pattern, it's additive (a new trait method), not a redesign.

Assumptions

  1. The ownership store is per-node. Each node records its local ownership. There is no cross-node ownership propagation in the base model. The hub's "who is this for" mapping is app state, not core ownership state. (Specific #4c.)

  2. The proxy pattern is sufficient for all current use cases. The agent-workspace case, the docker coordinator case, and the runner wrapper case are all proxy-pattern. No described use case requires the grant pattern. If one emerges, the grant mechanism is additive (a new method on the ownership store trait), not a redesign.

  3. The ownership store's read trait is sync. It is called from AccessControl::check on the dispatch hot path, no .await. A persistence adapter caches in memory and uses honker NOTIFY for invalidation — same ArcSwap-backed full-reload pattern as ConfigIdentityProvider (ADR-035).

  4. Ownership is runtime state, meaningless across restarts. A container ID from a previous process doesn't exist. The in-memory default carries no persistence; a persistence adapter is built when a concrete use case forces it (e.g., a hub that wants fleet ownership to survive restarts).

  5. The handler that manages a resource's lifecycle is responsible for calling record and revoke. This is the coupling requirement (specific #4b). The framework can provide a lifecycle-hook abstraction to reduce boilerplate, but the responsibility is the handler's, not the framework's.

  6. CompositionAuthority.resources (ADR-022) is not involved in the dynamic ownership check. It serves its existing purpose (static resource lists for composition). The dynamic ownership check is the ownership provider's job. The two are separate and orthogonal.

  7. AccessControl::check's new ownership parameter is None for operations without runtime-spawned resources. This preserves backward compatibility — operations with static resource sets work unchanged via the Identity.resources fallback path.

References

  • OQ-42: Dynamic Resource Ownership for Runtime-Spawned Resources (resolved by this ADR — the five sub-questions this ADR writes into decision text)
  • ADR-004: Auth as Shared Core (IdentityProvider — the first instance of the repo/adapter pattern the ownership store reuses; ADR-033 makes the pattern explicit, ADR-004 is the concrete first instance)
  • ADR-009: One-Way Door Decision Framework (the door-type-as-deferral anti-pattern this ADR's proxy-only decision avoids; the reversal-cost classification of the grant pattern's additive nature)
  • ADR-015: Privilege Model and Authority Context (the static composition-authority model; this ADR adds an orthogonal dynamic ownership check alongside it — ADR-015's text is unchanged per specific #4d; the system gains a second check, not a modification to the first)
  • ADR-022: Handler Registration, Provenance, and Composition Authority (CompositionAuthority.resources — the static resource list field this ADR confirms is not involved in the dynamic ownership check — unchanged per specific #4d)
  • ADR-030: PeerEntry and Identity.id Decoupling (Identity.resources — the static resource path this ADR's ownership provider extends for runtime-spawned resources)
  • ADR-032: Forwarded-For Identity (Metadata, Not Authority) (forwarded_for — the proxy pattern's end-user-identity carrier; the proxy-only model relies on this)
  • ADR-033: Storage Boundary and Repo/Adapter Pattern (the pattern this ADR reuses for the ownership store — fourth instance alongside IdentityProvider/IdentityStore/CredentialStore)
  • ADR-035: Concrete Persistence Adapter Shapes (the sync-read + ArcSwap + honker-NOTIFY shape this ADR's persistence adapter would follow, if built; IdentityStore is the write-trait analogue)
  • ADR-017: Call Protocol Client and Adapter Contract (from_call — the adapter that imports a peer's operations and re-registers them locally; the proxy pattern's re-export mechanism)
  • auth.md (Identity.resources, AccessControl::check interaction — both under edit by this decision)
  • operation-registry.md (AccessControl, OperationSpecresource_id_path addition)
  • alknet-docker POC summary §"Open Unknowns" #3 (the research finding that surfaced this question)