Files
alknet/tasks/core/ownership-store-trait.md
glm-5.2 de536b82e1 tasks: add ADR-050 implementation tasks — ownership store, resource_id_path, check signature, dispatch wiring
Four tasks forming a DAG for the dynamic resource ownership model (ADR-050):

1. core/ownership-store-trait (no deps) — OwnershipProvider (sync read) +
   OwnershipStore (async write) traits + InMemoryOwnershipStore + OwnershipError
   in alknet-core; fourth instance of the repo/adapter pattern (ADR-033)
2. call/registry/operation-spec-resource-id-path (no deps) — add
   resource_id_path: Option<String> to OperationSpec (JSON pointer into
   input for resource ID extraction)
3. call/registry/access-control-ownership-check (depends on 1) — update
   AccessControl::check signature to accept resource_id + OwnershipProvider;
   backward compatible (ownership=None falls back to static Identity.resources)
4. call/registry/dispatch-resource-id-extraction (depends on 2, 3) — wire
   dispatch path to extract resource_id from input via spec.resource_id_path
   and thread OwnershipProvider to check(); OperationContext gains ownership
   field

Tasks 1 and 2 can run in parallel (different crates, no deps). Task 3
depends on 1. Task 4 depends on 2 and 3. Validated: no cycles, 90 tasks total.
2026-07-05 11:12:44 +00:00

199 lines
8.3 KiB
Markdown

---
id: core/ownership-store-trait
name: Add OwnershipProvider (sync read) + OwnershipStore (async write) traits and InMemoryOwnershipStore (ADR-050)
status: pending
depends_on: []
scope: moderate
risk: low
impact: component
level: 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)
```rust
/// 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)
```rust
/// 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
```rust
#[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
```rust
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.