Files
alknet/tasks/call/registry/dispatch-resource-id-extraction.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

8.7 KiB

id, name, status, depends_on, scope, risk, impact, level
id name status depends_on scope risk impact level
call/registry/dispatch-resource-id-extraction Wire dispatch path to extract resource_id from input and thread OwnershipProvider to AccessControl::check (ADR-050 §2a, §4a) pending
call/registry/operation-spec-resource-id-path
call/registry/access-control-ownership-check
moderate medium component implementation

Description

Wire the dispatch path to: (a) extract resource_id from the operation input using spec.resource_id_path, and (b) thread an OwnershipProvider to AccessControl::check. This is the task that makes the dynamic ownership model actually work end-to-end. Per ADR-050 §2a and §4a.

Where the wiring happens

The dispatch flow is:

dispatch()                           // protocol/dispatch.rs:220
  → extract input from payload       // line 238
  → build_root_context(...)          // line 246
  → registry.invoke(name, input, context)  // line 261
      → look up registration         // registration.rs:123
      → check visibility             // registration.rs:128
      → check ACL: acl.check(...)   // registration.rs:142  ← THIS IS WHERE resource_id + ownership go
      → call handler

registry.invoke() (in registration.rs:116) has access to both input and registration.spec (which now has resource_id_path). The resource ID extraction and ownership provider threading happen here.

resource_id extraction (ADR-050 §2a)

In invoke() and invoke_streaming(), before the check() call:

// Extract resource_id from input using spec.resource_id_path
let resource_id = registration.spec.resource_id_path
    .as_ref()
    .and_then(|path| extract_json_pointer(&input, path));

extract_json_pointer parses a JSON pointer like "$.containerId" and extracts the value at that path from the input Value. If the pointer is None, or the path doesn't exist in the input, resource_id is None.

JSON pointer parsing: the resource_id_path uses the $.field syntax (JSON Pointer with a leading $). Use the serde_json pointer API or a small helper. serde_json::Value::pointer() takes a /field path — strip the leading $. and replace with /, or use a simple helper that handles the $. prefix. If the path is malformed or the field is missing, resource_id is None (graceful — the check will deny if the spec requires a resource_type but no ID is available).

OwnershipProvider threading

The OwnershipProvider needs to be available in invoke(). Two options:

Option A: thread it through OperationContext. Add an ownership: Option<Arc<dyn OwnershipProvider>> field to OperationContext. build_root_context populates it from the CallAdapter (which holds Arc<dyn OwnershipProvider>). invoke() reads context.ownership.as_deref() and passes it to check().

Option B: hold it on the OperationRegistry. The registry holds Option<Arc<dyn OwnershipProvider>> and invoke() reads it directly.

Recommendation: Option A (OperationContext). It's consistent with how identity, capabilities, and env are threaded — all on OperationContext. The registry stays stateless (no new field); the context carries the ownership provider the same way it carries the identity provider's output. The assembly layer wires the ownership provider into the CallAdapter at construction, and build_root_context threads it onto the context.

What changes

  1. OperationContext gains ownership: Option<Arc<dyn OwnershipProvider>> (in operation-registry.md / operation-context.md / the struct in registry/operation_context.rs or wherever OperationContext is defined). None when no ownership provider is wired (backward compat — check falls back to static path).

  2. build_root_context (in protocol/dispatch.rs:148) populates ownership from self.ownership_provider (a new field on Dispatcher/CallAdapter, set at construction by the assembly layer).

  3. invoke() and invoke_streaming() (in registration.rs) extract resource_id from input via spec.resource_id_path, then call acl.check(identity.as_ref(), resource_id.as_deref(), context.ownership.as_deref()).

  4. invoke_with_policy() in connection.rs:311 — the composition path (internal calls via OperationEnv::invoke). Same pattern: extract resource_id from input using the child op's resource_id_path, thread the ownership provider from the parent context.

  5. Dispatcher / CallAdapter gains an ownership_provider: Option<Arc<dyn OwnershipProvider>> field, set at construction. None by default (backward compat — deployments without runtime-spawned resources don't wire one).

What this task does NOT do

  • Does NOT build a persistence adapter — the in-memory InMemoryOwnershipStore from core/ownership-store-trait is the default.
  • Does NOT build any handler that calls record/revoke — that's downstream crate work (alknet-docker, etc.).
  • Does NOT change services/list / services/schema / services/list-peers — those stay on the (None, None) path (scope-gating only, no per-resource ownership check at discovery time).

Tests

  • Integration test: an operation with resource_type + resource_id_path
    • an OwnershipProvider wired → check consults the provider → Allowed when the provider says owns.
  • Integration test: same setup but provider says not owns → Forbidden.
  • Integration test: resource_id_path points to a field missing from input → resource_id is None → list path (owns_any) or Forbidden (if resource_type requires a specific ID).
  • Integration test: no ownership provider wired (ownership: None on context) → static Identity.resources fallback (backward compat).
  • Unit test: extract_json_pointer extracts $.containerId from {"containerId": "abc123"}Some("abc123").
  • Unit test: extract_json_pointer on missing field → None.
  • Unit test: resource_id_path: Noneresource_id: None.

Acceptance Criteria

  • OperationContext has ownership: Option<Arc<dyn OwnershipProvider>> field
  • Dispatcher / CallAdapter holds ownership_provider: Option<Arc<dyn OwnershipProvider>>, set at construction
  • build_root_context populates context.ownership from the dispatcher's provider
  • invoke() extracts resource_id from input via spec.resource_id_path, passes (resource_id, context.ownership.as_deref()) to check()
  • invoke_streaming() does the same
  • invoke_with_policy() in connection.rs does the same for the composition path
  • JSON pointer extraction helper handles $.field syntax
  • Missing field in input → resource_id: None (graceful, not a panic)
  • resource_id_path: Noneresource_id: None
  • No ownership provider wired → check falls back to static path (backward compat)
  • Integration test: provider says owns → Allowed
  • Integration test: provider says not owns → Forbidden
  • Integration test: missing field → list path or Forbidden
  • cargo test -p alknet-call succeeds
  • cargo clippy -p alknet-call succeeds with no warnings

References

  • docs/architecture/crates/call/operation-registry.md — AccessControl (dispatch flow with resource_id extraction)
  • docs/architecture/decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md — ADR-050 §2a, §4a, §4d
  • crates/alknet-call/src/protocol/dispatch.rs — dispatch() and build_root_context()
  • crates/alknet-call/src/registry/registration.rs — invoke() and invoke_streaming() (the check() call sites)
  • crates/alknet-call/src/protocol/connection.rs — invoke_with_policy() (composition path)
  • tasks/call/registry/operation-spec-resource-id-path.md — the resource_id_path field (dependency)
  • tasks/call/registry/access-control-ownership-check.md — the check() signature change (dependency)
  • tasks/core/ownership-store-trait.md — the OwnershipProvider trait (dependency)

Notes

This is the wiring task — it connects the pieces from the three dependency tasks into a working end-to-end flow. The key design choice is threading the ownership provider via OperationContext (consistent with how identity, capabilities, and env are threaded), not via the registry (which stays stateless). The assembly layer wires Option<Arc<dyn OwnershipProvider>> into the CallAdapter at construction; None is the default for deployments without runtime-spawned resources (backward compat). The JSON pointer extraction is a small helper — $.containerId → look up containerId in the input Value. Missing fields are graceful (None), not errors — the check denies if the spec requires a resource_type but no ID is available.