Files
alknet/tasks/core/ownership-store-trait.md
glm-5.2 73724b8a1d feat(core,call): implement ADR-050 dynamic resource ownership — ownership store, resource_id_path, check signature, dispatch wiring
Implements the 4-task DAG for runtime-spawned resource ownership so
AccessControl::check can answer "does this identity own this specific
container/TTY/process" instead of relying only on static
Identity.resources grants.

1. core/ownership-store-trait: OwnershipProvider (sync read) +
   OwnershipStore (async write) traits + InMemoryOwnershipStore +
   OwnershipError in alknet-core. Fourth instance of the repo/adapter
   pattern (ADR-033), mirroring CredentialStore/store.rs.

2. call/registry/operation-spec-resource-id-path: resource_id_path
   field on OperationSpec — JSON pointer into input for resource ID
   extraction. Single field addition, all ~40 construction sites across
   alknet-call + alknet-http updated to pass None (no semantic change).

3. call/registry/access-control-ownership-check: check() signature
   gains resource_id + Option<&dyn OwnershipProvider>. 3-case decision
   tree: ownership Some + resource_id Some -> owns(); ownership Some +
   resource_id None -> owns_any() (list scope-gate); ownership None ->
   static Identity.resources fallback (backward compat). 7 call sites
   updated to (None, None) — including a 7th in alknet-http/gateway_routes
   not listed in the original spec.

4. call/registry/dispatch-resource-id-extraction: wire the dispatch
   path. OperationContext gains ownership field; Dispatcher/CallAdapter
   gain with_ownership_provider builders; invoke/invoke_streaming/
   invoke_with_policy extract resource_id via spec.resource_id_path
   and thread context.ownership to check(). extract_json_pointer helper
   handles $.field syntax (graceful None on missing/non-string). 20
   OperationContext literals updated across both crates.

Backward compatibility is load-bearing throughout: ownership=None
falls back to the existing static resource-check path. Deployments
without runtime-spawned resources wire nothing and behave identically.

821 tests pass workspace-wide (was ~770); clippy clean; fmt clean.
Task specs marked done.
2026-07-05 12:57:33 +00:00

8.3 KiB

id, name, status, depends_on, scope, risk, impact, level
id name status depends_on scope risk impact level
core/ownership-store-trait Add OwnershipProvider (sync read) + OwnershipStore (async write) traits and InMemoryOwnershipStore (ADR-050) done
moderate low component implementation

Description

Add the OwnershipProvider (sync read trait) and OwnershipStore (async write trait) for runtime-spawned resource ownership, plus an InMemoryOwnershipStore default adapter. This is the fourth instance of the repo/adapter pattern (ADR-033), alongside IdentityProvider (ADR-004), IdentityStore (ADR-035), and CredentialStore (ADR-031). Per ADR-050.

Why this exists

Runtime-spawned resources (containers, TTYs, workspace processes) have derived ownership: whoever spawned the resource owns it. The static Identity.resources model can't represent this — the resource didn't exist when the identity was resolved. AccessControl::check (in alknet-call) will consult OwnershipProvider at check time to answer "does this identity own this specific resource?"

Module placement

Create a new ownership.rs module in alknet-core/src/, re-export from lib.rs. Follow the same pattern as store.rs (which holds CredentialStore + InMemoryCredentialStore).

OwnershipProvider trait (read side, sync)

/// Read side: consulted by AccessControl::check on the dispatch hot path.
/// Sync — called in the 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 (ADR-050 §2a).
    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 (ADR-050 §4a). 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 (ADR-050 §4a).
    fn owns_any(
        &self,
        identity: &Identity,
        resource_type: &str,
    ) -> bool;
}

OwnershipStore trait (write side, async)

/// Write side: called by the handler that manages the resource lifecycle.
/// Async — not on the dispatch hot path. The handler calls `record` on
/// spawn and `revoke` on teardown (ADR-050 §4b — handler-driven, not a
/// reaper).
#[async_trait]
pub trait OwnershipStore: Send + Sync + 'static {
    /// Record that `identity` spawned `resource_type/resource_id`.
    async fn record(
        &self,
        identity: &Identity,
        resource_type: &str,
        resource_id: &str,
    ) -> Result<(), OwnershipError>;

    /// Revoke ownership of `resource_type/resource_id`.
    /// Called by the handler on resource teardown (ADR-050 §4b).
    async fn revoke(
        &self,
        resource_type: &str,
        resource_id: &str,
    ) -> Result<(), OwnershipError>;
}

Note: the ADR-050 sketch shows record/revoke taking &mut self. The in-memory implementation should use interior mutability (RwLock or Mutex, same as InMemoryCredentialStore which takes &self for its async methods). The trait takes &self so it can be shared as Arc<dyn OwnershipStore>.

OwnershipError

#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum OwnershipError {
    #[error("backend error: {message}")]
    Backend { message: String },
    #[error("not found: {entity}")]
    NotFound { entity: String },
}

Follow the same shape as StoreError in store.rs. #[non_exhaustive] so future variants (e.g., concurrency conflict) can be added without breaking consumers.

InMemoryOwnershipStore

pub struct InMemoryOwnershipStore {
    // Map: (resource_type, resource_id) → owner Identity
    inner: RwLock<HashMap<(String, String), Identity>>,
}
  • owns: look up (resource_type, resource_id), compare owner's id to identity.id, check that the action is... actually, the ownership store records who owns a resource, not what actions they can perform. The action parameter in owns is the resource_action from AccessControl — it's passed for future use (per-action ownership grants), but the base model is "owner can do anything they own." For now, owns returns true if the identity owns the resource, regardless of action. The action parameter is accepted but not gated on — this preserves the door for per-action grants without building them now.
  • owned_resources: iterate the map, return IDs where the owner's id matches.
  • owns_any: iterate the map, return true if any entry matches the identity + resource_type.
  • record: insert (resource_type, resource_id) → identity.
  • revoke: remove (resource_type, resource_id).

What this task does NOT do

  • Does NOT change AccessControl::check — that's call/registry/access-control-ownership-check, which depends on this task.
  • Does NOT add resource_id_path to OperationSpec — that's call/registry/operation-spec-resource-id-path, which is independent.
  • Does NOT build a persistence adapter (SQLite/honker-backed) — that's additive, built when a concrete use case forces it (ADR-050 §1).
  • Does NOT wire the ownership provider into the dispatch path — that's call/registry/dispatch-resource-id-extraction, which depends on this task and the two call tasks.

Acceptance Criteria

  • OwnershipProvider trait with owns, owned_resources, owns_any (all sync)
  • OwnershipStore trait with record, revoke (both async, #[async_trait])
  • OwnershipError enum (#[non_exhaustive], thiserror::Error) with Backend + NotFound variants
  • InMemoryOwnershipStore implements both OwnershipProvider + OwnershipStore
  • InMemoryOwnershipStore uses interior mutability (RwLock, same as InMemoryCredentialStore)
  • owns returns true if identity owns the resource (action accepted but not gated — base model is owner-can-do-anything)
  • owned_resources returns the set of resource IDs the identity owns for a given type
  • owns_any returns true if identity owns at least one resource of the type
  • record inserts ownership; revoke removes it
  • New ownership.rs module in alknet-core/src/
  • Re-exported from lib.rs: pub use ownership::{OwnershipProvider, OwnershipStore, InMemoryOwnershipStore, OwnershipError};
  • Unit tests: record → owns → revoke → not owns round-trip
  • Unit test: owned_resources returns correct set for an owner with multiple resources
  • Unit test: owns_any returns false for an owner with no resources of that type
  • Unit test: revoke on non-existent resource is a no-op (or NotFound — pick one and document)
  • cargo test -p alknet-core succeeds
  • cargo clippy -p alknet-core succeeds with no warnings

References

  • docs/architecture/crates/core/auth.md — "Ownership Provider and Store (ADR-050)" section
  • docs/architecture/decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md — ADR-050 (the full decision)
  • docs/architecture/decisions/033-storage-boundary-and-repo-adapter-pattern.md — ADR-033 (the pattern)
  • crates/alknet-core/src/store.rs — CredentialStore + InMemoryCredentialStore (the pattern to follow)

Notes

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. The owns method accepts an action parameter but doesn't gate on it — the base model is "owner can do anything they own." Per-action grants are a future extension (additive, not needed now). The action parameter preserves the door without building the mechanism. A persistence adapter (SQLite/honker-backed) is not built in this sync — ownership is runtime state, meaningless across restarts; the in-memory default is sufficient for the docker/runner cases.