Files
alknet/docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md
glm-5.2 2e34590522 docs(architecture): resolve review #003 — type/API surface completeness
Review #003 found 11 critical, 14 warning, and 6 suggestion findings
after reviews #001 (governance/security) and #002 (cross-document
consistency/two-way-door audit) were resolved. The theme: types and
APIs that were *referenced* but never *defined*, and stale ADR sketches
that didn't match the now-updated spec docs.

Critical fixes (11):

- C1: DerivedKey #[derive(Deserialize)] contradicted the custom
  Deserialize that rejects "[REDACTED]" — dropped the derive, added
  explicit manual Serialize/Deserialize impls (protocol.md).
- C2: encrypt prose said "derived at PATHS::ENCRYPTION" but the
  signature takes key_version — updated to encryption_path_for_version
  (service.md).
- C3: derive_encryption_key returned DerivedKey, derive_encryption_key
  _for_version returned EncryptionKey (same cache) — unified on
  DerivedKey, defined CachedKey (service.md).
- C4: tokio vs std::sync::RwLock contradiction — specified
  std::sync::RwLock, dropped tokio from vault deps (ADR-018, ADR-025,
  service.md).
- C5: Missing drift rows in vault README — added #9 (key_version
  ignored) and #10 (rotate not implemented).
- C6: ADR-022 build_root_context and invoke() sketches omitted
  abort_policy (9 fields vs 10) — added the field to both sketches.
- C7: Capabilities type referenced 20+ times, never defined — added
  struct definition to core-types.md with Clone+Send+Sync, Zeroize,
  sealed builder API, immutability guard.
- C8: SessionOverlaySource on CallAdapter but never defined, crate
  violation (alknet-call can't depend on alknet-agent) — defined the
  trait in alknet-call (call-protocol.md), matching the IdentityProvider
  pattern.
- C9: CompositeOperationEnv dispatch fall-through was "a two-way door"
  — added contains() to OperationEnv trait, made the composite probe
  before dispatching, eliminating the sentinel ambiguity.
- C10: No API for Layer 2 (connection overlay) registration, CallConnection
  undefined — defined CallConnection struct + register_imported() API
  (call-protocol.md).
- C11: with_local signature diverged between two examples (4 args vs 5)
  — added capabilities as the 5th arg, made both examples consistent.

Warning fixes (14):

- W1: invoke_with_policy restructured as required method, invoke gets a
  default impl delegating to it — eliminates duplication across impls.
- W2: CachedKey defined (service.md).
- W3: EncryptionKey constructor/glue specified, added to re-export list.
- W4: Secp256k1ExtendedPrivKey defined, derive_ethereum_key glue shown.
- W5: encryption_path_for_version rejects version < 2 (v1 is TS PBKDF2).
- W6: Wire payload schemas for all event types + ResponseEnvelope →
  EventEnvelope conversion table (call-protocol.md).
- W7: Timeout section — deadline on OperationContext, composed calls
  inherit parent's deadline, CallAdapter::with_timeout().
- W8: Request ID generation spec — UUID v4 for composed calls, wire ID
  vs internal ID relationship for abort cascade.
- W9: unlock_new already-unlocked behavior specified (returns
  AlreadyUnlocked).
- W10: KeyType Serialize/Deserialize justification corrected (stale
  irpc reference removed).
- W11: OperationProvenance and CompositionAuthority defined inline in
  operation-registry.md (were only in ADR-022).
- W12: encrypt/decrypt free functions marked pub(crate), relationship
  to VaultServiceHandle methods stated.
- W13: rotate signature removed from encryption.md (it's a
  VaultServiceHandle method, not a free function).
- W14: CallAdapter::new() + with_session_source() + with_timeout()
  constructors shown.

Suggestion fixes (6): Seed: Clone note, VaultServiceInner invariant,
ExtendedPrivKey accessor signatures, CURRENT_KEY_VERSION location, ADR-018
stale actor text, derivation helpers re-export note.
2026-06-23 10:56:05 +00:00

32 KiB
Raw Blame History

ADR-022: Handler Registration, Provenance, and Composition Authority

Status

Accepted

Context

ADR-015 established the privilege model: the internal flag marks composition-originated calls and switches the ACL from the caller's identity to the handler's identity. This replaces the old trusted: bool flag, which skipped ACL entirely — a privilege escalation vector. The core decision in ADR-015 is sound: internal calls switch authority, they don't skip ACL.

However, ADR-015 left three things unspecified, which the pre-implementation review (docs/reviews/001-pre-implementation-architecture-sanity-check.md, findings C1C4) identified as critical gaps:

  1. handler_identity has no registration path. ADR-015 says the handler's identity is "set at registration by the assembly layer" (Assumption 2) and that "ACL check runs against the handler's identity (set at registration)" (Decision 1). But the registration API shown in operation-registry.md — register(spec, handler) and OperationRegistryBuilder::with(spec, handler) — accepts no identity. Tracing the dispatch path reveals that build_root_context sets handler_identity: None for wire calls (correct for the root), and OperationEnv::invoke() propagates parent.handler_identity.clone() to children. Since the root's handler_identity is None, every internal call gets handler_identity: None — meaning ADR-015's "ACL runs against handler_identity for internal calls" checks against None, which is the privilege-escalation gap ADR-015 was written to close.

  2. The scoped composition env has no registration/construction path. ADR-015 says the OperationEnv given to a handler is "scoped — it can only invoke a declared set of operations, set at registration by the assembly layer" (Decision 4, Assumption 3). But register(spec, handler) takes no scoped-env declaration, OperationSpec has no field for it, and the only OperationEnv implementation shown is LocalOperationEnv wrapping the full registry — no scoping layer exists.

  3. Capabilities lives in two unconnected models. ADR-014 and operation-registry.md show two models for how a handler gets outbound credentials: construction-time capture in the handler closure (Model A) and per-request on OperationContext.capabilities propagated through composition (Model B). The two don't connect: if the handler closure captured capabilities at construction, OperationContext.capabilities is either redundant or must be populated from the closure — but the closure receives the context, it isn't passed it. An implementer would have to invent the bridge, and the consuming crates (call, agent, napi) could diverge.

Beyond these wiring gaps, there is a deeper issue with ADR-015's Assumption 6: "the handler identity is a full Identity (with scopes), not a special principal type." Identity was designed for inbound peer identity — who is calling me from the network. A handler is not a peer. Its id field would be something like "agent-chat-handler" — a label, not something resolvable through IdentityProvider. Calling it an Identity implies it's a peer, which it isn't. It's an authority bundle.

The kernel/user analogy

This is structurally the same problem an operating system solves with kernel/user mode:

  • User calls getaddrinfo() — the syscall gate (an External op). The kernel checks the user's capabilities at entry.
  • getaddrinfo internally makes DNS queries, allocates sockets, reads /etc/hostsInternal kernel functions. They don't check the user's CAP_NET_RAW. They run under kernel authority.
  • The user does NOT need CAP_NET_RAW to resolve DNS. The kernel does network access on the user's behalf, under the kernel's own authority.

The key principle: the user's authority is checked once at the gate. Inside, the handler runs under its own authority. The user's authority does not propagate into internal calls.

This is exactly what ADR-015 specifies. The internal flag is the boundary crossing. When internal: true, ACL switches from the caller's identity to the handler's composition authority. The user's [chat] scope got them through /agent/chat's External ACL. Once inside, it's /agent/chat's composition authority that authorizes composing /vastai/listMachines — not the user's.

The graph framing

Call trees and operation registries are graph-shaped. The TypeScript @alkdev/flowgraph package models this explicitly with three graphs:

  1. Operation Graph (static) — nodes are registered operations, edges are type-compatibility relationships. Built from OperationSpecs at startup.
  2. Call Graph (dynamic) — nodes are call invocations (request IDs), edges are parent-child relationships (parent_request_id). Built from call protocol events at runtime.
  3. Scoped Operation Subgraph (per-handler, static) — the declared subset of the operation graph that a handler may reach. This is what ADR-015 calls the "scoped env," framed as a subgraph rather than a list of names.

This ADR uses the graph model as structural framing but does not mandate a graph library. For v1, the operation graph can be implicit (a HashMap<String, OperationNode>), the call graph can be implicit (the PendingRequestMap indexed by parent_request_id is a call graph), and the scoped env can be a HashSet<String> of reachable operation names. A dedicated alknet-flowgraph crate (or folding graph structures into alknet-call) is a future enhancement for workflow templates, type compatibility validation, and call-graph observability — not a prerequisite for the security model.

Decision

1. Provenance is the primary registration axis

Every registered operation carries a provenance tag that classifies where it came from. Provenance determines whether the operation can compose, whether it has composition authority, its default visibility, and its trust model.

pub enum OperationProvenance {
    /// Assembly-written, trusted code, can compose.
    Local,
    /// HTTP forwarding stub (from_openapi), leaf — cannot compose.
    FromOpenAPI,
    /// MCP forwarding stub (from_mcp), leaf — cannot compose.
    FromMCP,
    /// QUIC forwarding stub (from_call). Leaf in the local registry —
    /// forwards calls to a remote node; cannot compose locally.
    FromCall,
    /// JSON Schema definition (from_jsonschema), no handler — schema only.
    FromJsonSchema,
    /// Agent-written, sandboxed, can compose within sandbox bounds.
    Session,
}
Provenance Can compose? Has composition authority? Default visibility Trust model
Local Yes Yes — scopes set by assembly layer External or Internal (assembly declares) Trusted code
FromOpenAPI No (leaf) No Internal HTTP endpoint trusted; handler is a forwarding stub
FromMCP No (leaf) No Internal MCP server trusted; handler is a forwarding stub
FromCall No (leaf in local registry) No Internal Remote node trusted; handler is a forwarding stub
FromJsonSchema N/A (no handler) No N/A N/A
Session Yes (within sandbox) Yes — scopes set by assembly layer at sandbox creation Internal always Untrusted code in sandbox

Only Local and Session ops get composition authority. Leaves (FromOpenAPI, FromMCP, FromCall) don't compose, so they don't get one. The assembly layer does not invent identities for leaves.

2. Composition authority replaces handler_identity: Identity

ADR-015's Assumption 6 said "the handler identity is a full Identity (with scopes), not a special principal type." This ADR refines that: composition authority is a declared authority bundle, not a peer Identity. It's only set for ops that can compose (Local, Session). Leaves don't have one.

/// Authority under which a handler composes child operations.
///
/// This is NOT a peer `Identity` — it's not resolvable through
/// `IdentityProvider` and doesn't represent an inbound caller. It's the
/// declared authority (scopes + resources + label) that the assembly layer
/// grants a handler for composition. When the handler composes children via
/// `OperationEnv::invoke()`, the child's ACL runs against this authority,
/// not the caller's identity and not as a blanket skip.
///
/// Only ops that can compose (`Local`, `Session`) have one. Leaves
/// (`FromOpenAPI`, `FromMCP`, `FromCall`) have `None`.
pub struct CompositionAuthority {
    /// Human-readable label for attribution and logging
    /// (e.g., "agent-chat", "fs-handler"). Not a peer id — not resolvable
    /// through IdentityProvider.
    pub label: String,

    /// Scopes the handler operates under for composition. When the handler
    /// composes a child via `env.invoke()`, the child's ACL checks against
    /// these scopes. Least privilege: the assembly layer grants only the
    /// scopes the handler needs for its declared composition.
    pub scopes: Vec<String>,

    /// Named resource lists, same shape as `Identity.resources`. Optional.
    /// e.g., {"service": ["vastai", "github"]} bounds which services the
    /// handler can reach in composition.
    pub resources: HashMap<String, Vec<String>>,
}

impl CompositionAuthority {
    /// `None` — for leaves that don't compose (convenience for
    /// `composition_authority: CompositionAuthority::none()`).
    pub fn none() -> Option<Self> { None }

    /// Construct a composition authority with the given label and scopes.
    pub fn new(
        label: &str,
        scopes: impl IntoIterator<Item = String>,
    ) -> Self {
        Self {
            label: label.to_string(),
            scopes: scopes.into_iter().collect(),
            resources: HashMap::new(),
        }
    }

    /// Convert to a synthetic `Identity` for ACL matching on child calls.
    ///
    /// When a handler composes a child via `env.invoke()`, the child's
    /// `identity` (the caller identity for ACL) is set to the parent's
    /// composition authority converted to an `Identity`. This constructs
    /// a synthetic `Identity { id: label, scopes, resources }` that is
    /// **not** resolvable via `IdentityProvider` — it's not a peer
    /// identity, it's a declared authority bundle used directly for ACL
    /// matching. This creates a second `Identity` construction path (the
    /// first is `IdentityProvider::resolve_*`), which is acknowledged and
    /// intentional: the composition authority is a declared authority, not
    /// a resolved credential.
    ///
    /// Returns `None` when the authority is `None` (leaf case — leaves
    /// don't compose, so `as_identity()` is never called on them in
    /// practice, but the `Option` makes the types line up).
    pub fn as_identity(&self) -> Option<Identity> {
        Some(Identity {
            id: self.label.clone(),
            scopes: self.scopes.clone(),
            resources: self.resources.clone(),
        })
    }
}

This supersedes ADR-015's Assumption 6. ADR-015's core decision (authority switch, not ACL skip) holds unchanged — the only change is what the authority is and which ops have it.

3. The scoped env is a declared subgraph (reachability control)

The scoped composition env from ADR-015 is the reachability control: it bounds which operations a handler can reach via env.invoke(). ADR-015 specifies it as "a declared set of operations, set at registration by the assembly layer." This ADR makes the registration path explicit and frames it as a subgraph of the operation graph.

/// The set of operations a handler may reach via `env.invoke()`.
///
/// This is the reachability control from ADR-015: a handler (or an LLM
/// picking tools, or a quickjs sandbox) can only compose declared operations,
/// not the entire registry. Set at registration by the assembly layer for
/// composing ops (`Local`, `Session`). `None` for leaves — they don't
/// compose, so they get an empty/no-op env.
///
/// Conceptually a subgraph of the operation graph. For v1, implemented as a
/// set of operation names — the *model* is a subgraph (which nodes this
/// handler can reach), but type-compatibility edges between those nodes are
/// a future enhancement for static validation, not a v1 requirement.
///
/// The `allowed_operations` field is **private** (not `pub`). Construction
/// is via `ScopedOperationEnv::new(ops)` or `ScopedOperationEnv::empty()`.
/// Reachability is queried via `allows(&name)`. This encapsulation makes the
/// future subgraph refactor (from `HashSet<String>` to a typed subgraph) a
/// non-breaking change to construction sites (review #002 W21). The
/// `HashSet<String>` representation does not support type-compatibility
/// validation — session-scoped ops (OQ-19, untrusted code) compose without
/// static type checking until a flowgraph crate is built.
pub struct ScopedOperationEnv {
    allowed_operations: HashSet<String>,
}

impl ScopedOperationEnv {
    /// Empty set — for leaves that don't compose (no reachable operations).
    pub fn empty() -> Self {
        Self { allowed_operations: HashSet::new() }
    }

    /// Construct from an iterable of operation names.
    pub fn new(ops: impl IntoIterator<Item = String>) -> Self {
        Self { allowed_operations: ops.into_iter().collect() }
    }

    /// Returns true if the given operation name is reachable.
    pub fn allows(&self, name: &str) -> bool {
        self.allowed_operations.contains(name)
    }
}

4. The registration bundle carries all three

The three controls from ADR-015 (visibility, composition authority, scoped env) plus the capability injection from ADR-014 all enter the system at the same boundary: the assembly layer hands the registry a (spec, handler) pair plus the handler's runtime context material. This ADR makes that explicit as a registration bundle.

pub struct HandlerRegistration {
    pub spec: OperationSpec,
    pub handler: Handler,
    pub provenance: OperationProvenance,
    /// Composition authority for this handler. `None` for leaves
    /// (`FromOpenAPI`, `FromMCP`, `FromCall`) — they don't compose.
    /// `Some(...)` for `Local` and `Session` ops that can compose children.
    pub composition_authority: Option<CompositionAuthority>,
    /// Scoped composition env. `None` for leaves — they get an empty
    /// no-op env. `Some(...)` for composing ops.
    pub scoped_env: Option<ScopedOperationEnv>,
    /// Outbound credentials the handler may use (decrypted API keys, signing
    /// keys, HTTP tokens). Populated by the assembly layer from the vault
    /// at handler construction. See ADR-014.
    pub capabilities: Capabilities,
}

The registry's register and builder's with accept a HandlerRegistration, not a bare (OperationSpec, Handler) pair:

impl OperationRegistry {
    pub fn register(&mut self, registration: HandlerRegistration);
}

impl OperationRegistryBuilder {
    pub fn with(mut self, registration: HandlerRegistration) -> Self;
}

Adapter convenience methods (from_openapi, from_mcp, from_call) construct HandlerRegistration with composition_authority: None and scoped_env: None for the leaf ops they produce — the adapter doesn't grant composition authority, and the assembly layer doesn't have to invent values for leaves.

5. The dispatch path reads from the registration bundle

The CallAdapter's build_root_context and OperationEnv::invoke() read composition authority, scoped env, and capabilities from the registration bundle, looked up by operation name.

build_root_context (wire-originated call, internal: false):

fn build_root_context(
    &self,
    request_id: String,
    operation_name: &str,        // looked up in registry
    identity: Option<Identity>,  // resolved per-request from AuthContext/auth_token
) -> OperationContext {
    let registration = self.registry.registration(operation_name);
    OperationContext {
        request_id,
        parent_request_id: None,
        identity,                           // caller's identity (inbound — gate credential)
        handler_identity: registration.composition_authority,  // C1: from bundle, None for leaves
        capabilities: registration.capabilities.clone(),       // C3: from bundle
        metadata: HashMap::new(),
        abort_policy: AbortPolicy::default(),  // abort-dependents (ADR-016 Decision 6)
        // env/scoped_env split by ADR-024: scoped_env is the reachability
        // data (from the bundle), env is the dispatch trait object (composed
        // per-call by the CallAdapter from active overlays).
        scoped_env: registration.scoped_env.clone()
            .unwrap_or_else(ScopedOperationEnv::empty),       // C2: from bundle, empty for leaves
        env: self.compose_root_env(/* connection, session */),  // Arc<dyn OperationEnv + Send + Sync> — see ADR-024
        internal: false,                    // wire call — ACL against caller identity
    }
}

ACL for the root checks against identity (the caller's identity, resolved per-request). handler_identity is on the context for propagation to children, not for the root's own ACL.

OperationEnv::invoke() (composition-originated call, internal: true):

async fn invoke(&self, namespace: &str, operation: &str, input: Value,
                parent: &OperationContext) -> ResponseEnvelope {
    let name = format!("{namespace}/{operation}");

    // Reachability check (C2): is this op in the parent's scoped env?
    // If not, return NOT_FOUND. This is the reachability control.
    // (ADR-024: the reachability check consults parent.scoped_env, not
    // parent.env — env is now the dispatch trait, scoped_env is the data.)
    if !parent.scoped_env.allows(&name) {
        return ResponseEnvelope::not_found(name);
    }

    let registration = self.registry.registration(&name);
    let context = OperationContext {
        request_id: generate_request_id(),
        parent_request_id: Some(parent.request_id.clone()),
        identity: parent.handler_identity.as_identity(),  // parent's authority becomes the caller
        handler_identity: registration.composition_authority.clone(),  // C1: child's own authority
        capabilities: parent.capabilities.clone(),       // C3: propagate through composition
        metadata: HashMap::new(),                        // fresh — does NOT propagate (ADR-014)
        abort_policy: parent.abort_policy.clone(),       // inherit parent's policy (ADR-016 Decision 6, W19)
        // env/scoped_env split by ADR-024:
        scoped_env: registration.scoped_env.clone()
            .unwrap_or_else(ScopedOperationEnv::empty),   // C2: child's own scoped env
        env: parent.env.clone(),                          // child inherits parent's composite env (Arc::clone)
        internal: true,                                    // composition — ACL against handler_identity
    };
    self.registry.invoke(&name, input, context).await
}

Two things happen here:

  1. Reachability check: before constructing the child context, invoke() checks whether the requested op is in the parent's scoped env. If not, NOT_FOUND. This bounds the parameterized-dispatch attack surface — a handler (or an LLM picking tools) can only reach declared ops.

  2. Authority propagation: the child's identity is the parent's handler_identity (the parent's composition authority becomes the caller for the child). The child's handler_identity is the child's own registration's composition_authority — so if the child itself composes further, its children inherit the child's authority. This is the principal/agent chain from ADR-015, now wired.

ACL for the child checks against handler_identity (the child's composition authority). For leaves, handler_identity is None — but leaves don't compose, so their handler_identity is never used for ACL on a grandchild. Leaves only have ACL checked against themselves (as the target of composition), where the check is: does the parent's composition authority satisfy the leaf's AccessControl?

6. Capabilities are per-request, populated from the bundle (Model A reconciled)

This ADR resolves the C3 ambiguity by adopting option (a) from the review: capabilities are only per-request on OperationContext, populated by the dispatch path from the per-handler capabilities in the registration bundle. The construction-time "baking" described in ADR-014 L82 populates the registration bundle's capabilities field — the handler closure does not capture capabilities.

// Assembly layer: construct registration with capabilities from vault
let google_api_key = vault.decrypt(&google_key_blob)?;
let agent_registration = HandlerRegistration {
    spec: agent_chat_spec(),
    handler: Arc::new(agent_chat_handler),  // closure captures nothing
    provenance: OperationProvenance::Local,
    composition_authority: Some(CompositionAuthority {
        label: "agent-chat".into(),
        scopes: vec!["llm:call".into(), "fs:read".into(), "vastai:query".into()],
        resources: HashMap::new(),
    }),
    scoped_env: Some(ScopedOperationEnv::new(
        ["fs/readFile", "vastai/listMachines", "llm/generate"])),
    capabilities: Capabilities::new()
        .with_api_key("google", google_api_key),  // C3: in the bundle, not the closure
};

The handler reads context.capabilities at call time. The dispatch path populates it from registration.capabilities. Composition propagates it via parent.capabilities.clone() in invoke(). No circular dependency, no redundant models.

7. The three controls together (ADR-015's model, now wired)

Control What it gates Where it's set Without it
Visibility (External/Internal) Whether the op is callable from the wire OperationSpec.visibility Internal ops exposed to external callers
Composition authority What authority internal calls run under HandlerRegistration.composition_authority ACL skipped or caller's scopes propagated (escalation)
Scoped env What ops a handler can reach HandlerRegistration.scoped_env Handler can call anything in the registry (confused deputy)

All three enter at registration. All three reach the dispatch path via the registration bundle. The user's identity is the gate credential — checked once at the External boundary. The composition authority is the internal credential — used for all composition inside. The scoped env is the reachability boundary — what the handler can even attempt to compose.

8. No intersection semantics

The user's authority does NOT limit internal calls. If the user has chat but not vastai:query, /agent/chat composing /vastai/listMachines is NOT denied because the user lacks vastai:query. The user's authority was checked at the gate (/agent/chat requires chat, user has chat). Inside, the handler runs under its own composition authority. The user's authority does not propagate into internal calls.

This is the kernel/user model: getaddrinfo doesn't require the caller to have CAP_NET_RAW to make DNS queries. The curated entry point exists because it does things the user can't, on the user's behalf, under its own authority.

If a handler wants to act on behalf of the user (e.g., a database proxy that runs queries under the user's DB identity), that's a handler-level decision — it reads context.identity and explicitly narrows its behavior. That's delegated access, not automatic intersection. The system shouldn't silently intersect; the handler should explicitly delegate.

Consequences

Positive:

  • The privilege model in ADR-015 is now implementable as specified. The composition authority, scoped env, and capabilities all have registration paths and dispatch-path wiring. No implementer has to invent the bridge.
  • Leaves (from_openapi, from_mcp, from_call) don't get fake identities. The assembly layer doesn't have to invent Identity { id: "vastai-listmachines-handler", scopes: [], resources: {} } for forwarding stubs that will never compose. composition_authority: None is natural for leaves, not an oversight.
  • External services can't self-grant composition authority. The OpenAPI spec defines the operation interface (name, schemas, access control). The provenance is set by the assembly layer when it runs from_openapi. The composition authority is None for imported ops — the external service can't grant itself scopes to compose into your registry. The assembly layer is the sole grantor, and only for Local and Session ops.
  • Capabilities have one model: per-request on OperationContext, populated from the registration bundle. No closure-capture vs context duplication ambiguity. The three consuming crates (call, agent, napi) can't diverge because there's one wiring path.
  • The graph model provides a precise structural framing without mandating a graph library for v1. The operation graph, scoped subgraph, and call graph are concepts that guide the API shape; HashMaps and HashSets are the v1 implementation. A future alknet-flowgraph crate can reify these as petgraph structures when workflow templates and type-compatibility validation are needed.
  • The kernel/user analogy makes the security model legible. The user's authority is the gate credential (checked once at External entry). The composition authority is the internal credential (used for all composition inside). The scoped env is the reachability boundary (what the handler can attempt to compose). This is the same model every OS uses, and it's been battle-tested.

Negative:

  • The registration API changes from register(spec, handler) to register(HandlerRegistration). This is a breaking change to the API surface shown in operation-registry.md, but since no implementation exists yet, it's a spec edit, not a migration.
  • CompositionAuthority is a new type, distinct from Identity. This adds a type to alknet-call. It's not a peer identity — it's a declared authority bundle. The distinction from Identity is intentional and necessary (a handler is not a network peer), but it means the codebase has two scope-bearing types. Mitigated: they serve different roles and don't converge — Identity is inbound (resolved from credentials via IdentityProvider), CompositionAuthority is declared (set by the assembly layer at registration).
  • The assembly layer has more registration-time responsibility: it must declare each handler's provenance, composition authority, and scoped env. This is expected — the assembly layer assembles everything (ADR-008), and forcing explicit declaration of privilege is a feature, not a bug. An OperationRegistryBuilder convenience API can reduce boilerplate for common cases (e.g., .with_local(spec, handler, authority, env, capabilities) vs .with_leaf(spec, handler, capabilities)).
  • The dispatch path does a registry lookup per call (to fetch the registration bundle's composition authority, scoped env, and capabilities). This is a HashMap lookup — negligible cost. The alternative (baking everything into the handler closure) creates the C3 ambiguity. The lookup is the right trade.

Validation strategy:

The security model should be validated by fuzzing. A fuzzer that generates call trees (valid and invalid compositions, different provenance mixes, edge cases around the gate) and asserts "no path through the call graph lets a user with scope X reach an operation requiring Y without going through a gate that checks X" would catch the class of privilege-escalation bug this ADR is designed to prevent. The typebox-rs fake data generator can produce valid and invalid inputs from JSON Schemas; with minor edits it can output invalid inputs or a mix of valid/invalid, enabling property-based testing of the ACL model. This is a downstream concern — the spec needs to be right first, then the fuzzer validates the implementation against the spec.

Assumptions

  1. Internal calls should run under a different authority than external calls, not skip ACL entirely. Inherited from ADR-015. The escalation vectors (buggy handler, parameterized dispatch) are real and must be prevented.

  2. Provenance is knowable at registration time. The assembly layer knows whether an op is Local, FromOpenAPI, FromMCP, FromCall, or Session when it registers the op — the adapter that produced the (OperationSpec, Handler) pair knows its own type. If a future use case requires provenance to be discovered at call time, the model needs extension.

  3. Composition reachability is knowable at registration time. The assembly layer can declare which operations a handler may compose when it registers the handler. If a use case requires fully dynamic scoping (handler discovers at call time what it can compose), the model needs extension — but the assumption is that composition reachability is knowable at registration time for Local ops, and at sandbox creation time for Session ops.

  4. The assembly layer is the trust boundary. The assembly layer declares provenance, composition authority, and scoped env. If the assembly layer is compromised, all handler authority is compromised. This is the same trust boundary as ADR-008 and ADR-014.

  5. Leaves don't compose. FromOpenAPI, FromMCP, and FromCall ops are forwarding stubs — they take input, forward it (over HTTP, MCP, or QUIC), and return output. They don't call env.invoke(). If a future use case requires an imported op to compose (e.g., a from_call op that locally composes other ops before forwarding), its provenance would need to change to Local (it's no longer a pure forwarding stub), or the model needs a hybrid provenance.

  6. Session ops compose under restricted authority. Session ops (agent-written, OQ-19) get composition authority scoped down by the parent handler at sandbox creation (ADR-015's "dynamic scoping at sandbox creation"). The assembly layer grants the sandbox's parent handler a composition authority; the parent handler scopes it down further when creating the sandbox. The session op's composition authority is a subset of the parent's.

References

  • ADR-014: Secret material flow and capability injection (capabilities are orthogonal to identity — both set at registration; this ADR specifies the registration path ADR-014 left as a two-way door)
  • ADR-015: Privilege model and authority context (this ADR refines Assumption 6 — composition authority is not a peer Identity; and wires the three controls that ADR-015 specified but left without registration paths)
  • ADR-016: Abort cascade for nested calls (the call graph is the abort cascade tree; parent_request_id indexes it)
  • ADR-017: Call protocol client and adapter contract (adapter-registered ops are Internal by default; this ADR's provenance makes that explicit)
  • ADR-024: Operation registry layering (amends this ADR's Decision 5: the env field shown in build_root_context and invoke() is split into scoped_env: ScopedOperationEnv (reachability data, populated from the bundle's scoped_env) and env: Arc<dyn OperationEnv + Send + Sync> (dispatch trait object). The split is required by ADR-024's overlay model — the trait-object design is what enables connection and session overlays to compose. The HandlerRegistration bundle shape, provenance model, composition authority, and capability injection specified by this ADR are unchanged.)
  • ADR-008: Vault integration point (assembly layer is the trust boundary)
  • OQ-19: Session-scoped operation registries (session ops are Session provenance, always Internal, compose under restricted authority)
  • docs/reviews/001-pre-implementation-architecture-sanity-check.md (findings C1C4, which this ADR resolves)
  • docs/reviews/002-pre-implementation-architecture-sanity-check.md (finding C6, resolved by ADR-024's env/scoped_env split)
  • /workspace/@alkdev/flowgraph/README.md — operation graph, call graph, and scoped subgraph concepts (the graph model this ADR uses as framing)
  • /workspace/@alkdev/alknet-main/docs/architecture/flowgraph.md — prior Rust speccing of flowgraph (incomplete; this ADR uses the model, not the crate)
  • Kernel/user mode analogy: getaddrinfo runs under kernel authority, not the caller's CAP_NET_RAW; the curated entry point exists to do things the user can't, on the user's behalf