tasks: decompose vault, core, call crates into 28 atomic implementation tasks
Break down the three initial crates (alknet-vault, alknet-core, alknet-call) into dependency-ordered task files for implementation agents. Structure: - tasks/vault/ (10 tasks) — drift fixes from ADR-025/026 refactor, review, spec sync. Vault is independent and can run fully in parallel with core/call. - tasks/core/ (6 tasks) — crate init, core types, config, auth, endpoint, review. Core is foundational; call depends on it. - tasks/call/ (12 tasks) — split into registry/ and protocol/ topic subdirs reflecting the two subsystems. CallAdapter is the merge point. Key decisions: - Drifts 3+9+10 grouped as one task (key-versioning-rotation) — the complete ADR-021 rotation feature that doesn't compile in pieces - Reviews injected at end of each crate phase (vault, core, call) - Vault spec-sync task removes the drift table and bumps doc status to stable - ACME deferred in core/endpoint (noted as TODO; X509 manual certs for now) - OperationEnv kept as a trait (load-bearing for ADR-024 layering) Validated: 28 tasks, no cycles, 11 generations of parallel work. Critical path runs through call (11 tasks). Vault completes by generation 4. 6 high-risk tasks identified (21%): irpc-removal, endpoint, operation-context, operation-env, call-adapter, abort-cascade.
This commit is contained in:
202
tasks/call/registry/handler-registration.md
Normal file
202
tasks/call/registry/handler-registration.md
Normal file
@@ -0,0 +1,202 @@
|
||||
---
|
||||
id: call/registry/handler-registration
|
||||
name: Implement Handler, HandlerRegistration, OperationProvenance, OperationRegistry, and OperationRegistryBuilder
|
||||
status: pending
|
||||
depends_on: [call/registry/operation-context]
|
||||
scope: broad
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement the handler registration types and the operation registry in
|
||||
`src/registry/registration.rs`. The registry maps operation names to
|
||||
registration bundles and provides the dispatch entry point.
|
||||
|
||||
### Handler
|
||||
|
||||
```rust
|
||||
pub type Handler = Arc<
|
||||
dyn Fn(Value, OperationContext) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>
|
||||
+ Send + Sync
|
||||
>;
|
||||
```
|
||||
|
||||
Handlers are async. They receive:
|
||||
- `input: Value` — deserialized payload from `call.requested` (always `serde_json::Value`)
|
||||
- `context: OperationContext` — request ID, identity, metadata, env
|
||||
|
||||
And return `ResponseEnvelope` (defined in protocol/wire task — use a forward
|
||||
reference or define a minimal version here, full impl in the wire task).
|
||||
|
||||
### HandlerRegistration
|
||||
|
||||
```rust
|
||||
pub struct HandlerRegistration {
|
||||
pub spec: OperationSpec,
|
||||
pub handler: Handler,
|
||||
pub provenance: OperationProvenance,
|
||||
pub composition_authority: Option<CompositionAuthority>, // None for leaves
|
||||
pub scoped_env: Option<ScopedOperationEnv>, // None for leaves
|
||||
pub capabilities: Capabilities,
|
||||
}
|
||||
```
|
||||
|
||||
The registration bundle carries everything the dispatch path needs to
|
||||
construct an `OperationContext`. See ADR-022.
|
||||
|
||||
### OperationProvenance
|
||||
|
||||
```rust
|
||||
pub enum OperationProvenance {
|
||||
Local, // Assembly-written, trusted, can compose
|
||||
FromOpenAPI, // HTTP forwarding stub, leaf
|
||||
FromMCP, // MCP forwarding stub, leaf
|
||||
FromCall, // QUIC forwarding stub, leaf locally
|
||||
FromJsonSchema, // JSON Schema definition, no handler — schema only
|
||||
Session, // Agent-written, sandboxed, can compose within sandbox
|
||||
}
|
||||
```
|
||||
|
||||
| Provenance | Can compose? | Has composition authority? | Default visibility |
|
||||
|-----------|-------------|---------------------------|-------------------|
|
||||
| `Local` | Yes | Yes | External or Internal (assembly declares) |
|
||||
| `FromOpenAPI` | No (leaf) | No | Internal |
|
||||
| `FromMCP` | No (leaf) | No | Internal |
|
||||
| `FromCall` | No (leaf in local registry) | No | Internal |
|
||||
| `FromJsonSchema` | N/A (no handler) | No | N/A |
|
||||
| `Session` | Yes (within sandbox) | Yes | Internal always |
|
||||
|
||||
### OperationRegistry
|
||||
|
||||
```rust
|
||||
pub struct OperationRegistry {
|
||||
operations: HashMap<String, HandlerRegistration>,
|
||||
}
|
||||
```
|
||||
|
||||
The curated layer (Layer 0) is a `HashMap<String, HandlerRegistration>`. Session
|
||||
and connection overlays (Layers 1 and 2) are separate maps composed into the
|
||||
per-call `OperationContext.env` by the CallAdapter (ADR-024).
|
||||
|
||||
Methods:
|
||||
- `register(registration)`: add to curated layer at startup
|
||||
- `registration(name)`: find by operation name (checks active overlays first,
|
||||
then curated base — ADR-024). Returns spec, handler, provenance, composition
|
||||
authority, scoped env, capabilities.
|
||||
- `invoke(name, input, context)`: look up, check ACL, invoke handler, return result
|
||||
- `list_operations()`: return all registered specs (for `/services/list` —
|
||||
returns curated + active overlay ops, External only)
|
||||
|
||||
### OperationRegistryBuilder
|
||||
|
||||
Fluent API with convenience methods:
|
||||
|
||||
```rust
|
||||
pub struct OperationRegistryBuilder {
|
||||
operations: HashMap<String, HandlerRegistration>,
|
||||
}
|
||||
|
||||
impl OperationRegistryBuilder {
|
||||
pub fn new() -> Self;
|
||||
|
||||
// with_local: Local provenance, full bundle — all 5 args required
|
||||
pub fn with_local(
|
||||
mut self,
|
||||
spec: OperationSpec,
|
||||
handler: Handler,
|
||||
composition_authority: Option<CompositionAuthority>,
|
||||
scoped_env: Option<ScopedOperationEnv>,
|
||||
capabilities: Capabilities,
|
||||
) -> Self;
|
||||
|
||||
// with_leaf: leaf provenance (FromOpenAPI/FromMCP/FromCall), no authority, no scoped env
|
||||
pub fn with_leaf(
|
||||
mut self,
|
||||
spec: OperationSpec,
|
||||
handler: Handler,
|
||||
capabilities: Capabilities,
|
||||
) -> Self;
|
||||
|
||||
// with: full manual registration (any provenance)
|
||||
pub fn with(mut self, registration: HandlerRegistration) -> Self;
|
||||
|
||||
pub fn build(self) -> OperationRegistry;
|
||||
}
|
||||
```
|
||||
|
||||
`with_local` sets `provenance: Local`. `with_leaf` sets `provenance: FromOpenAPI`
|
||||
(or a parameter), `composition_authority: None`, `scoped_env: None`. `with` takes
|
||||
the full bundle for any provenance.
|
||||
|
||||
### Registry invoke flow
|
||||
|
||||
```rust
|
||||
impl OperationRegistry {
|
||||
pub async fn invoke(&self, name: &str, input: Value, context: OperationContext) -> ResponseEnvelope {
|
||||
// 1. Look up registration by name
|
||||
// 2. Check visibility: if Internal and context is external (internal: false), return NOT_FOUND
|
||||
// 3. Check ACL: access_control.check(identity or handler_identity depending on internal flag)
|
||||
// 4. If denied: return FORBIDDEN
|
||||
// 5. Invoke handler: (handler)(input, context).await
|
||||
// 6. Return ResponseEnvelope
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The ACL authority depends on `context.internal`:
|
||||
- `internal: false` (wire call): check against `context.identity` (caller)
|
||||
- `internal: true` (composition): check against `context.handler_identity.as_identity()`
|
||||
|
||||
### Layer 0 immutability
|
||||
|
||||
The curated layer (Layer 0 — `Local` provenance ops) is immutable after
|
||||
construction. Adding a `Local` op requires restarting the process. Session and
|
||||
imported overlays are dynamic at their respective scopes (ADR-024). The
|
||||
`OperationRegistryBuilder` is Layer-0-only; runtime overlay registration uses
|
||||
`CallConnection::register_imported()` (in the protocol/connection task).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `Handler` type alias (async closure returning ResponseEnvelope)
|
||||
- [ ] `HandlerRegistration` struct with all 6 fields
|
||||
- [ ] `OperationProvenance` enum with all 6 variants
|
||||
- [ ] `OperationRegistry` struct with operations HashMap
|
||||
- [ ] `OperationRegistry::register()` adds to curated layer
|
||||
- [ ] `OperationRegistry::registration()` looks up by name
|
||||
- [ ] `OperationRegistry::invoke()` checks visibility, ACL, invokes handler
|
||||
- [ ] `OperationRegistry::list_operations()` returns External specs only
|
||||
- [ ] `OperationRegistryBuilder` with `new()`, `with_local()`, `with_leaf()`, `with()`, `build()`
|
||||
- [ ] `with_local` sets provenance Local, requires all 5 args
|
||||
- [ ] `with_leaf` sets provenance leaf, composition_authority None, scoped_env None
|
||||
- [ ] invoke: Internal op called externally → NOT_FOUND (not FORBIDDEN)
|
||||
- [ ] invoke: ACL denied → FORBIDDEN
|
||||
- [ ] invoke: internal: true → ACL against handler_identity, not identity
|
||||
- [ ] invoke: internal: false → ACL against identity
|
||||
- [ ] Unit test: register and invoke a simple operation
|
||||
- [ ] Unit test: Internal op returns NOT_FOUND from external call
|
||||
- [ ] Unit test: ACL check with sufficient scopes → Allowed
|
||||
- [ ] Unit test: ACL check with insufficient scopes → Forbidden
|
||||
- [ ] Unit test: builder with_local and with_leaf produce correct provenance
|
||||
- [ ] `cargo test -p alknet-call` succeeds
|
||||
- [ ] `cargo clippy -p alknet-call` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/call/operation-registry.md — Handler, HandlerRegistration, OperationRegistry, builder
|
||||
- docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md — ADR-022
|
||||
- docs/architecture/decisions/024-operation-registry-layering.md — ADR-024 (layering, immutability)
|
||||
|
||||
## Notes
|
||||
|
||||
> The registry is the dispatch core. The ACL authority switch (internal: true
|
||||
> → handler_identity, internal: false → identity) is the ADR-015 privilege
|
||||
> model — get this right. Internal ops return NOT_FOUND from the wire (don't
|
||||
> leak existence), not FORBIDDEN. The builder is Layer-0-only; runtime overlay
|
||||
> registration is via CallConnection (protocol task).
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
204
tasks/call/registry/operation-context.md
Normal file
204
tasks/call/registry/operation-context.md
Normal file
@@ -0,0 +1,204 @@
|
||||
---
|
||||
id: call/registry/operation-context
|
||||
name: Implement OperationContext, AbortPolicy, CompositionAuthority, and ScopedOperationEnv
|
||||
status: pending
|
||||
depends_on: [call/registry/operation-spec, core/core-types]
|
||||
scope: broad
|
||||
risk: high
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement the operation context types in `src/registry/context.rs`. This is
|
||||
the highest-density task in the call crate — `OperationContext` has 10 fields,
|
||||
each tied to an ADR. The authority-switch semantics (`internal: true` → ACL
|
||||
against `handler_identity`, not `identity`) is where ADR-015, ADR-022, and
|
||||
ADR-024 converge.
|
||||
|
||||
**Read ADR-015, ADR-022, and ADR-024 before starting this task.**
|
||||
|
||||
### OperationContext
|
||||
|
||||
```rust
|
||||
pub struct OperationContext {
|
||||
pub request_id: String,
|
||||
pub parent_request_id: Option<String>,
|
||||
pub identity: Option<Identity>, // Caller's identity (inbound)
|
||||
pub handler_identity: Option<CompositionAuthority>, // Handler's composition authority (ADR-022)
|
||||
pub capabilities: Capabilities,
|
||||
pub metadata: HashMap<String, Value>,
|
||||
pub scoped_env: ScopedOperationEnv, // Reachability set (data, ADR-022)
|
||||
pub env: Arc<dyn OperationEnv + Send + Sync>, // Composition dispatch trait (ADR-024)
|
||||
pub abort_policy: AbortPolicy, // ADR-016 Decision 6
|
||||
pub deadline: Option<Instant>,
|
||||
pub(crate) internal: bool, // Module-private for writes (ADR-015)
|
||||
}
|
||||
```
|
||||
|
||||
Field-by-field:
|
||||
|
||||
- `request_id`: correlates with `call.requested` event's `id` field. For wire
|
||||
calls, this is the client-generated ID. For composed calls, generated by
|
||||
`OperationEnv::invoke()` via `generate_request_id()` (UUID v4 or
|
||||
`parent_id + "-" + counter`). **Deterministic IDs must not be used** — they
|
||||
collide across concurrent invocations, corrupting PendingRequestMap and the
|
||||
abort-cascade tree.
|
||||
- `parent_request_id`: set when this call was initiated by another operation
|
||||
(via OperationEnv). Records the agency chain — the call tree is the
|
||||
principal→agent chain (ADR-015).
|
||||
- `identity`: the authenticated caller (from IdentityProvider) — inbound auth
|
||||
(who is calling me). For external calls, who sent `call.requested`. For
|
||||
internal calls, the parent handler's `handler_identity` (propagated through
|
||||
`OperationEnv::invoke()`).
|
||||
- `handler_identity`: the composition authority of the handler processing this
|
||||
call. `None` for leaves (FromOpenAPI/FromMCP/FromCall) — they don't compose.
|
||||
`Some(...)` for Local/Session ops. For internal calls (`internal: true`), ACL
|
||||
checks against this authority (ADR-015, ADR-022). This is NOT a peer Identity
|
||||
— it's a declared authority bundle set at registration.
|
||||
- `capabilities`: outbound credentials the handler may use (decrypted API keys,
|
||||
scoped vault access). From the registration bundle (ADR-022).
|
||||
- `metadata`: request-scoped context (tracing IDs, connection info). **Must not
|
||||
hold secret material** (ADR-014). **Does not propagate through
|
||||
`OperationEnv::invoke()`** — nested calls get fresh metadata. The tracing
|
||||
link is `parent_request_id`, not metadata propagation.
|
||||
- `scoped_env`: the reachability set — operations this handler may compose.
|
||||
Populated from the registration bundle (ADR-022). This is *data* (a struct),
|
||||
not a dispatch trait. `None`/empty for leaves.
|
||||
- `env`: the composition dispatch trait (`Arc<dyn OperationEnv + Send + Sync>`).
|
||||
A handler calls `context.env.invoke(...)` to compose children. This is a
|
||||
trait object, not a concrete struct — enables registry layering (ADR-024).
|
||||
- `abort_policy`: for this call's descendants (ADR-016 Decision 6). Default
|
||||
`AbortDependents`. `ContinueRunning` is opt-in for long-running work. Set by
|
||||
the composing handler via `invoke()`, not by the wire caller.
|
||||
- `deadline`: for this call and all descendants. Set by `build_root_context`
|
||||
to `now + CallAdapter.default_timeout` (default 30s). Composed calls inherit
|
||||
the parent's deadline (children do NOT get a fresh 30s). `None` = unbounded
|
||||
(long-running subscriptions).
|
||||
- `internal`: when `true`, this call originated from composition (a handler
|
||||
calling another operation via OperationEnv), not from a wire request. This
|
||||
switches the authority context: ACL runs against `handler_identity`, not
|
||||
`identity`. Module-private for writes; read via `is_internal()`. Only set by
|
||||
`OperationEnv::invoke()` (true) or `CallAdapter` dispatch path (false).
|
||||
|
||||
### AbortPolicy
|
||||
|
||||
```rust
|
||||
pub enum AbortPolicy {
|
||||
AbortDependents, // default — abort cascades to all non-terminal descendants
|
||||
ContinueRunning, // opt-in — started descendants continue, unstarted aborted
|
||||
}
|
||||
|
||||
impl Default for AbortPolicy {
|
||||
fn default() -> Self { Self::AbortDependents }
|
||||
}
|
||||
```
|
||||
|
||||
### CompositionAuthority
|
||||
|
||||
```rust
|
||||
pub struct CompositionAuthority {
|
||||
pub label: String, // e.g., "agent-chat" — not a peer id
|
||||
pub scopes: Vec<String>, // e.g., ["llm:call", "fs:read"]
|
||||
pub resources: HashMap<String, Vec<String>>, // e.g., {"service": ["vastai"]}
|
||||
}
|
||||
|
||||
impl CompositionAuthority {
|
||||
pub fn none() -> Option<Self> { None } // Convenience for leaves
|
||||
pub fn new(label: &str, scopes: impl IntoIterator<Item = String>) -> Self { ... }
|
||||
pub fn as_identity(&self) -> Option<Identity> { ... } // Synthetic Identity for ACL
|
||||
}
|
||||
```
|
||||
|
||||
The declared authority the handler operates under when composing children.
|
||||
`None` for leaves. This replaces ADR-015's `handler_identity: Identity` — it's
|
||||
not a peer identity, it's a declared authority bundle. See ADR-022.
|
||||
|
||||
`as_identity()` produces a synthetic `Identity` from the authority (label as
|
||||
id, scopes, resources) for ACL checking against `AccessControl`.
|
||||
|
||||
### ScopedOperationEnv
|
||||
|
||||
```rust
|
||||
pub struct ScopedOperationEnv {
|
||||
allowed: HashSet<String>, // operation names this handler may reach
|
||||
}
|
||||
|
||||
impl ScopedOperationEnv {
|
||||
pub fn empty() -> Self;
|
||||
pub fn new(ops: impl IntoIterator<Item = impl Into<String>>) -> Self;
|
||||
pub fn allows(&self, name: &str) -> bool; // is this op in the reachability set?
|
||||
}
|
||||
```
|
||||
|
||||
The reachability set — the operations this handler may reach via `env.invoke()`.
|
||||
Populated from the registration bundle (ADR-022). This is *data*, not a dispatch
|
||||
trait. The reachability check in `OperationEnv::invoke()` consults
|
||||
`scoped_env.allows(&name)`. `None`/empty for leaves.
|
||||
|
||||
### OperationContext methods
|
||||
|
||||
```rust
|
||||
impl OperationContext {
|
||||
pub fn is_internal(&self) -> bool { self.internal }
|
||||
}
|
||||
```
|
||||
|
||||
The `internal` field is `pub(crate)` — only `OperationEnv::invoke()` and the
|
||||
`CallAdapter` dispatch path can set it. Handlers read via `is_internal()`.
|
||||
|
||||
### generate_request_id
|
||||
|
||||
```rust
|
||||
pub(crate) fn generate_request_id() -> String {
|
||||
// UUID v4 — must be unique across concurrent invocations
|
||||
// Deterministic IDs (e.g., format!("env-{name}")) MUST NOT be used
|
||||
}
|
||||
```
|
||||
|
||||
Use the `uuid` crate (already a dependency). This is module-internal — called
|
||||
by `OperationEnv::invoke()` for composed calls.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `OperationContext` struct with all 10 fields
|
||||
- [ ] `internal` field is `pub(crate)` (module-private for writes)
|
||||
- [ ] `is_internal()` method exposes read access
|
||||
- [ ] `AbortPolicy` enum with AbortDependents, ContinueRunning
|
||||
- [ ] `Default for AbortPolicy` returns `AbortDependents`
|
||||
- [ ] `CompositionAuthority` struct with label, scopes, resources
|
||||
- [ ] `CompositionAuthority::none()` returns `None`
|
||||
- [ ] `CompositionAuthority::new(label, scopes)` constructor
|
||||
- [ ] `CompositionAuthority::as_identity()` produces synthetic Identity for ACL
|
||||
- [ ] `ScopedOperationEnv` struct with allowed set
|
||||
- [ ] `ScopedOperationEnv::empty()`, `new()`, `allows()` methods
|
||||
- [ ] `generate_request_id()` produces UUID v4 (unique, non-deterministic)
|
||||
- [ ] Unit test: ScopedOperationEnv::allows (in set → true, not in set → false)
|
||||
- [ ] Unit test: CompositionAuthority::as_identity produces correct Identity
|
||||
- [ ] Unit test: AbortPolicy default is AbortDependents
|
||||
- [ ] `cargo test -p alknet-call` succeeds
|
||||
- [ ] `cargo clippy -p alknet-call` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/call/operation-registry.md — OperationContext, AbortPolicy, CompositionAuthority, ScopedOperationEnv
|
||||
- docs/architecture/decisions/015-privilege-model-and-authority-context.md — ADR-015 (internal flag, authority switch)
|
||||
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016 (AbortPolicy)
|
||||
- docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md — ADR-022 (CompositionAuthority, ScopedOperationEnv)
|
||||
- docs/architecture/decisions/024-operation-registry-layering.md — ADR-024 (env as trait object)
|
||||
|
||||
## Notes
|
||||
|
||||
> **Read ADR-015, ADR-022, and ADR-024 before starting.** This is the
|
||||
> highest-density task in the call crate. OperationContext has 10 fields, each
|
||||
> tied to an ADR. The authority-switch semantics (internal: true → ACL against
|
||||
> handler_identity, not identity) is where three ADRs converge. The `internal`
|
||||
> field is module-private for writes — only OperationEnv::invoke() and the
|
||||
> CallAdapter dispatch path set it. Metadata does NOT propagate through
|
||||
> composition (security constraint, ADR-014). Request IDs must be unique
|
||||
> (UUID v4) — deterministic IDs corrupt PendingRequestMap and abort-cascade tree.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
225
tasks/call/registry/operation-env.md
Normal file
225
tasks/call/registry/operation-env.md
Normal file
@@ -0,0 +1,225 @@
|
||||
---
|
||||
id: call/registry/operation-env
|
||||
name: Implement OperationEnv trait, LocalOperationEnv, and CompositeOperationEnv
|
||||
status: pending
|
||||
depends_on: [call/registry/handler-registration]
|
||||
scope: broad
|
||||
risk: high
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement the `OperationEnv` trait and its implementations in
|
||||
`src/registry/env.rs`. This is the universal composition mechanism — a handler
|
||||
calls `context.env.invoke(...)` to compose child operations. The trait-object
|
||||
design is what enables registry layering (ADR-024).
|
||||
|
||||
**Read ADR-024 before starting this task.** The trait-object pattern is
|
||||
load-bearing — making `OperationEnv` concrete would close the session-overlay
|
||||
and connection-overlay patterns.
|
||||
|
||||
### OperationEnv trait
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait OperationEnv: Send + Sync {
|
||||
/// Compose a child operation. The child's OperationContext is constructed
|
||||
/// with internal: true, inheriting the parent's composition authority as
|
||||
/// the child's caller identity. Abort policy defaults to parent's.
|
||||
async fn invoke(
|
||||
&self,
|
||||
namespace: &str,
|
||||
operation: &str,
|
||||
input: Value,
|
||||
parent: &OperationContext,
|
||||
) -> ResponseEnvelope {
|
||||
self.invoke_with_policy(namespace, operation, input, parent, parent.abort_policy.clone()).await
|
||||
}
|
||||
|
||||
/// Compose with explicit abort policy (ADR-016 Decision 6).
|
||||
/// This is the required method — invoke() delegates to it.
|
||||
async fn invoke_with_policy(
|
||||
&self,
|
||||
namespace: &str,
|
||||
operation: &str,
|
||||
input: Value,
|
||||
parent: &OperationContext,
|
||||
policy: AbortPolicy,
|
||||
) -> ResponseEnvelope;
|
||||
|
||||
/// Does this env contain the named operation? Used by CompositeOperationEnv
|
||||
/// to probe overlays before dispatching (ADR-024).
|
||||
fn contains(&self, name: &str) -> bool { true }
|
||||
}
|
||||
```
|
||||
|
||||
`invoke()` has a default impl that delegates to `invoke_with_policy()` with
|
||||
the parent's abort policy. Implementations only need to implement
|
||||
`invoke_with_policy()`.
|
||||
|
||||
### LocalOperationEnv (Layer 0)
|
||||
|
||||
```rust
|
||||
pub struct LocalOperationEnv {
|
||||
registry: Arc<OperationRegistry>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OperationEnv for LocalOperationEnv {
|
||||
async fn invoke_with_policy(&self, namespace: &str, operation: &str, input: Value, parent: &OperationContext, policy: AbortPolicy) -> ResponseEnvelope {
|
||||
let name = format!("{namespace}/{operation}");
|
||||
|
||||
// 1. Reachability check (ADR-015, ADR-022): is this op in parent's scoped env?
|
||||
if !parent.scoped_env.allows(&name) {
|
||||
return ResponseEnvelope::not_found(name);
|
||||
}
|
||||
|
||||
// 2. Look up registration
|
||||
let registration = self.registry.registration(&name);
|
||||
|
||||
// 3. Construct child OperationContext
|
||||
let context = OperationContext {
|
||||
request_id: generate_request_id(), // UUID v4 — NOT deterministic
|
||||
parent_request_id: Some(parent.request_id.clone()),
|
||||
identity: parent.handler_identity.as_identity(), // authority switch
|
||||
handler_identity: registration.composition_authority.clone(),
|
||||
capabilities: parent.capabilities.clone(), // inherit
|
||||
metadata: HashMap::new(), // fresh — does NOT propagate parent metadata (ADR-014)
|
||||
abort_policy: policy,
|
||||
deadline: parent.deadline, // inherit — children don't get fresh 30s
|
||||
scoped_env: registration.scoped_env.clone().unwrap_or_else(ScopedOperationEnv::empty),
|
||||
env: parent.env.clone(), // inherit the same composite env
|
||||
internal: true, // nested calls use handler authority
|
||||
};
|
||||
|
||||
// 4. Dispatch
|
||||
self.registry.invoke(&name, input, context).await
|
||||
}
|
||||
|
||||
// contains() uses default (returns true — curated registry contains everything it can dispatch)
|
||||
}
|
||||
```
|
||||
|
||||
Key points:
|
||||
- **Reachability check first**: if op not in parent's scoped_env, NOT_FOUND.
|
||||
This bounds the parameterized-dispatch attack surface.
|
||||
- **Authority propagation**: child's `identity` = parent's `handler_identity`
|
||||
(the parent's composition authority becomes the caller). This is the
|
||||
authority switch from ADR-015.
|
||||
- **Fresh metadata**: `HashMap::new()`, NOT parent's metadata. Security
|
||||
constraint (ADR-014) — prevents secret leakage through composition.
|
||||
- **Inherited deadline**: children don't get a fresh 30s — the root call's
|
||||
deadline bounds the entire call tree.
|
||||
- **Inherited env**: child gets `parent.env.clone()` (the same composite of
|
||||
curated base + active overlays).
|
||||
- **internal: true**: this is the flag that switches ACL authority.
|
||||
|
||||
### CompositeOperationEnv (per-call, ADR-024)
|
||||
|
||||
```rust
|
||||
pub struct CompositeOperationEnv {
|
||||
session: Option<Arc<dyn OperationEnv + Send + Sync>>, // Layer 1
|
||||
connection: Option<Arc<dyn OperationEnv + Send + Sync>>, // Layer 2
|
||||
base: Arc<dyn OperationEnv + Send + Sync>, // Layer 0 (LocalOperationEnv)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OperationEnv for CompositeOperationEnv {
|
||||
async fn invoke_with_policy(&self, namespace: &str, operation: &str, input: Value, parent: &OperationContext, policy: AbortPolicy) -> ResponseEnvelope {
|
||||
let name = format!("{namespace}/{operation}");
|
||||
|
||||
// Reachability check (same as LocalOperationEnv)
|
||||
if !parent.scoped_env.allows(&name) {
|
||||
return ResponseEnvelope::not_found(name);
|
||||
}
|
||||
|
||||
// Dispatch in overlay order: session → connection → curated base
|
||||
// First overlay that *contains* the op wins
|
||||
if let Some(session) = &self.session {
|
||||
if session.contains(&name) {
|
||||
return session.invoke_with_policy(namespace, operation, input, parent, policy).await;
|
||||
}
|
||||
}
|
||||
if let Some(connection) = &self.connection {
|
||||
if connection.contains(&name) {
|
||||
return connection.invoke_with_policy(namespace, operation, input, parent, policy).await;
|
||||
}
|
||||
}
|
||||
self.base.invoke_with_policy(namespace, operation, input, parent, policy).await
|
||||
}
|
||||
|
||||
fn contains(&self, name: &str) -> bool {
|
||||
self.session.as_ref().map_or(false, |s| s.contains(name))
|
||||
|| self.connection.as_ref().map_or(false, |c| c.contains(name))
|
||||
|| self.base.contains(name)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `contains()` method (review #003 C9) is the overlay-dispatch contract. It
|
||||
replaces the previous ambiguous "sentinel or contains check" framing. The
|
||||
structural decision (composite trait object, overlay order, Arc::clone
|
||||
inheritance) is locked by ADR-024; the dispatch contract (contains probe before
|
||||
invoke_with_policy) is locked too.
|
||||
|
||||
### Why OperationEnv must remain a trait
|
||||
|
||||
The trait-based design enables registry layering (ADR-024):
|
||||
- The CallAdapter composes the root env per call from curated base + active
|
||||
connection/session overlays
|
||||
- Overlays wrap the base via trait layering
|
||||
- Session-scoped registries (OQ-19) and connection-scoped remote imports
|
||||
(ADR-017 `from_call`) are both overlays on the same base
|
||||
|
||||
Making `OperationEnv` concrete or hardcoding the global registry into the
|
||||
dispatch path would close both patterns. This is the same integration-point
|
||||
pattern as `IdentityProvider` (ADR-004).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `OperationEnv` trait with `invoke()`, `invoke_with_policy()`, `contains()`
|
||||
- [ ] `invoke()` has default impl delegating to `invoke_with_policy()` with parent's policy
|
||||
- [ ] `contains()` has default impl returning `true`
|
||||
- [ ] `LocalOperationEnv` struct holding `Arc<OperationRegistry>`
|
||||
- [ ] `LocalOperationEnv::invoke_with_policy` checks reachability (scoped_env.allows)
|
||||
- [ ] `LocalOperationEnv` constructs child context with internal: true, authority switch
|
||||
- [ ] `LocalOperationEnv` fresh metadata (HashMap::new(), not parent's)
|
||||
- [ ] `LocalOperationEnv` inherited deadline (parent.deadline, not fresh 30s)
|
||||
- [ ] `LocalOperationEnv` inherited env (parent.env.clone())
|
||||
- [ ] `CompositeOperationEnv` with session, connection, base fields
|
||||
- [ ] `CompositeOperationEnv::invoke_with_policy` dispatches in overlay order (session → connection → base)
|
||||
- [ ] `CompositeOperationEnv` uses `contains()` probe before dispatching to overlay
|
||||
- [ ] `CompositeOperationEnv::contains` returns true if any layer contains the op
|
||||
- [ ] Reachability check returns NOT_FOUND if op not in scoped_env
|
||||
- [ ] Unit test: LocalOperationEnv invoke with allowed op → dispatches
|
||||
- [ ] Unit test: LocalOperationEnv invoke with disallowed op → NOT_FOUND
|
||||
- [ ] Unit test: child context has internal: true
|
||||
- [ ] Unit test: child context identity = parent's handler_identity
|
||||
- [ ] Unit test: child metadata is fresh (empty), not parent's
|
||||
- [ ] Unit test: CompositeOperationEnv dispatches to session overlay if contains
|
||||
- [ ] Unit test: CompositeOperationEnv falls through to base if no overlay contains
|
||||
- [ ] `cargo test -p alknet-call` succeeds
|
||||
- [ ] `cargo clippy -p alknet-call` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/call/operation-registry.md — OperationEnv, LocalOperationEnv, CompositeOperationEnv
|
||||
- docs/architecture/decisions/015-privilege-model-and-authority-context.md — ADR-015 (authority switch)
|
||||
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016 (abort policy propagation)
|
||||
- docs/architecture/decisions/024-operation-registry-layering.md — ADR-024 (layering, contains contract)
|
||||
|
||||
## Notes
|
||||
|
||||
> **Read ADR-024 before starting.** The trait-object design is load-bearing —
|
||||
> OperationEnv MUST remain a trait, not a concrete type. The authority switch
|
||||
> (child identity = parent handler_identity) is the ADR-015 privilege model.
|
||||
> Metadata does NOT propagate (ADR-014 security constraint). Deadline
|
||||
> inherits (children don't get fresh 30s). The `contains()` probe is the
|
||||
> overlay-dispatch contract from review #003 C9 — any OperationEnv impl that
|
||||
> correctly reports contains works with the composite.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
168
tasks/call/registry/operation-spec.md
Normal file
168
tasks/call/registry/operation-spec.md
Normal file
@@ -0,0 +1,168 @@
|
||||
---
|
||||
id: call/registry/operation-spec
|
||||
name: Implement OperationSpec, OperationType, Visibility, ErrorDefinition, and AccessControl
|
||||
status: pending
|
||||
depends_on: [call/crate-init]
|
||||
scope: moderate
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement the operation specification types in `src/registry/spec.rs`. These
|
||||
types declare what an operation is, its schemas, and its access control policy.
|
||||
|
||||
### OperationSpec
|
||||
|
||||
```rust
|
||||
pub struct OperationSpec {
|
||||
pub name: String, // e.g., "fs/readFile", "agent/chat" (no leading slash)
|
||||
pub namespace: String, // e.g., "fs", "agent"
|
||||
pub op_type: OperationType, // Query, Mutation, Subscription
|
||||
pub visibility: Visibility, // External (wire-callable) or Internal (composition-only)
|
||||
pub input_schema: Value, // JSON Schema for input
|
||||
pub output_schema: Value, // JSON Schema for output
|
||||
pub error_schemas: Vec<ErrorDefinition>, // Declared domain errors (ADR-023)
|
||||
pub access_control: AccessControl,
|
||||
}
|
||||
```
|
||||
|
||||
Operation names use slash-based paths **without a leading slash**, aligned with
|
||||
URL path conventions: `fs/readFile`, `agent/chat`, `services/list`. The leading
|
||||
slash is added for display (`spec.path()` returns `/fs/readFile`) and wire
|
||||
format. The registry stores names without the leading slash.
|
||||
|
||||
The `namespace` field is derived from the name: for `fs/readFile` it's `fs`,
|
||||
for `agent/chat` it's `agent`. It's a convenience accessor for ACL matching and
|
||||
service grouping.
|
||||
|
||||
Implement `OperationSpec::path(&self) -> String` that returns `/{name}` (the
|
||||
wire/display form with leading slash).
|
||||
|
||||
### OperationType
|
||||
|
||||
```rust
|
||||
pub enum OperationType {
|
||||
Query, // Read-only, idempotent (e.g., "fs/readFile", "services/list")
|
||||
Mutation, // Side effects (e.g., "bash/exec", "github/authenticate")
|
||||
Subscription, // Streaming (e.g., "agent/chat", "events/subscribe")
|
||||
}
|
||||
```
|
||||
|
||||
### Visibility
|
||||
|
||||
```rust
|
||||
pub enum Visibility {
|
||||
External, // Callable from the wire (call.requested from a client)
|
||||
Internal, // Composition-only (env.invoke from a handler)
|
||||
}
|
||||
```
|
||||
|
||||
`External` operations appear in `services/list` and accept `call.requested`.
|
||||
`Internal` operations return `NOT_FOUND` when called from the wire and do not
|
||||
appear in `services/list`. The assembly layer declares visibility at
|
||||
registration. All import adapters register operations as `Internal` by default
|
||||
(they're composition material); the handler that composes them is `External`.
|
||||
|
||||
### ErrorDefinition
|
||||
|
||||
```rust
|
||||
pub struct ErrorDefinition {
|
||||
pub code: String, // e.g., "FILE_NOT_FOUND", "RATE_LIMITED"
|
||||
pub description: String, // Human-readable description
|
||||
pub schema: Value, // JSON Schema for the error detail payload
|
||||
pub http_status: Option<u16>, // HTTP status for adapter projection (from_openapi/to_openapi)
|
||||
}
|
||||
```
|
||||
|
||||
A declared operation-level error (ADR-023). When a handler returns a `CallError`
|
||||
whose `code` matches a declared `ErrorDefinition`, the `call.error` event
|
||||
carries that code and the error's detail payload. If it doesn't match, the
|
||||
`call.error` carries `INTERNAL`.
|
||||
|
||||
### AccessControl
|
||||
|
||||
```rust
|
||||
pub struct AccessControl {
|
||||
pub required_scopes: Vec<String>, // AND-checked: caller must have ALL
|
||||
pub required_scopes_any: Option<Vec<String>>, // OR-checked: caller must have at LEAST ONE
|
||||
pub resource_type: Option<String>, // e.g., "service"
|
||||
pub resource_action: Option<String>, // e.g., "read"
|
||||
}
|
||||
```
|
||||
|
||||
### ACL check flow
|
||||
|
||||
When a `call.requested` event arrives:
|
||||
1. Registry checks **visibility** — if `Internal`, returns `NOT_FOUND` (does
|
||||
not leak existence)
|
||||
2. Registry checks `access_control.check(identity)`:
|
||||
- For external calls (`internal: false`): ACL against the **caller's identity**
|
||||
- For internal calls (`internal: true`): ACL against the **handler's
|
||||
composition authority** (ADR-015)
|
||||
3. If denied: `FORBIDDEN`
|
||||
4. If identity is `None` and operation has restrictions: `FORBIDDEN` with
|
||||
message `"authentication required"`
|
||||
|
||||
Operations with empty `AccessControl` (no required scopes, no resource checks)
|
||||
are accessible to all callers, including unauthenticated ones.
|
||||
|
||||
### Implement AccessControl::check
|
||||
|
||||
```rust
|
||||
impl AccessControl {
|
||||
pub fn check(&self, identity: Option<&Identity>) -> AccessResult;
|
||||
}
|
||||
|
||||
pub enum AccessResult {
|
||||
Allowed,
|
||||
Forbidden(String), // reason
|
||||
}
|
||||
```
|
||||
|
||||
The check logic:
|
||||
- `required_scopes`: caller must have ALL (subset check)
|
||||
- `required_scopes_any`: caller must have at LEAST ONE (if present)
|
||||
- `resource_type` / `resource_action`: check against `identity.resources`
|
||||
- If `identity` is `None` and any scope/resource is required: `Forbidden("authentication required")`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `OperationSpec` struct with all 8 fields
|
||||
- [ ] `OperationSpec::path()` returns `/{name}` (leading slash for wire/display)
|
||||
- [ ] `OperationSpec::namespace` derived from name (split on `/`)
|
||||
- [ ] `OperationType` enum with Query, Mutation, Subscription
|
||||
- [ ] `Visibility` enum with External, Internal
|
||||
- [ ] `ErrorDefinition` struct with all 4 fields
|
||||
- [ ] `AccessControl` struct with all 4 fields
|
||||
- [ ] `AccessControl::check(identity)` returns `AccessResult`
|
||||
- [ ] `required_scopes` is AND-checked (caller must have all)
|
||||
- [ ] `required_scopes_any` is OR-checked (caller must have at least one)
|
||||
- [ ] `None` identity with restrictions → `Forbidden("authentication required")`
|
||||
- [ ] Empty AccessControl → `Allowed` for all callers
|
||||
- [ ] Unit tests for AccessControl::check (all combinations)
|
||||
- [ ] Unit test: OperationSpec::path() produces leading slash
|
||||
- [ ] Unit test: namespace derived correctly from name
|
||||
- [ ] `cargo test -p alknet-call` succeeds
|
||||
- [ ] `cargo clippy -p alknet-call` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/call/operation-registry.md — OperationSpec, AccessControl, Visibility
|
||||
- docs/architecture/decisions/015-privilege-model-and-authority-context.md — ADR-015 (visibility, ACL)
|
||||
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023 (ErrorDefinition)
|
||||
|
||||
## Notes
|
||||
|
||||
> Operation names have NO leading slash in the registry (`fs/readFile`). The
|
||||
> leading slash is added for wire format and display (`/fs/readFile`). This is
|
||||
> a single rule applied consistently — do not mix the two forms. Visibility
|
||||
> controls wire-callability: Internal ops return NOT_FOUND from the wire (don't
|
||||
> leak existence). AccessControl.check is the ACL gate — read it carefully
|
||||
> against ADR-015 for the internal vs external authority distinction.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
181
tasks/call/registry/service-discovery.md
Normal file
181
tasks/call/registry/service-discovery.md
Normal file
@@ -0,0 +1,181 @@
|
||||
---
|
||||
id: call/registry/service-discovery
|
||||
name: Implement services/list and services/schema built-in operations
|
||||
status: pending
|
||||
depends_on: [call/registry/handler-registration]
|
||||
scope: narrow
|
||||
risk: low
|
||||
impact: isolated
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement the two built-in service discovery operations in
|
||||
`src/registry/discovery.rs`. These are read-only operations that expose what
|
||||
the node offers.
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation name | Display path | Type | Description |
|
||||
|---------------|-------------|------|-------------|
|
||||
| `services/list` | `/services/list` | Query | List registered operation names and metadata |
|
||||
| `services/schema` | `/services/schema` | Query | Get the OperationSpec for a specific operation |
|
||||
|
||||
### services/list
|
||||
|
||||
Returns `External` operations only. `Internal` operations are not part of the
|
||||
wire-facing API surface — they're implementation details of composition. A
|
||||
remote client cannot enumerate the internal call tree (ADR-015).
|
||||
|
||||
```json
|
||||
{
|
||||
"operations": [
|
||||
{ "name": "fs/readFile", "namespace": "fs", "op_type": "query" },
|
||||
{ "name": "agent/chat", "namespace": "agent", "op_type": "subscription" },
|
||||
{ "name": "events/subscribe", "namespace": "events", "op_type": "subscription" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The handler queries the registry's `list_operations()` (which returns External
|
||||
specs only) and serializes to the above format.
|
||||
|
||||
### services/schema
|
||||
|
||||
Accepts `{ "name": "fs/readFile" }` (no leading slash — registry form, same as
|
||||
`OperationSpec.name`) and returns the full `OperationSpec` including
|
||||
input/output JSON Schemas and declared `error_schemas` (ADR-023).
|
||||
|
||||
The CallAdapter normalizes the leading slash from wire `operationId`s before
|
||||
lookup, so `services/schema` accepts both `fs/readFile` and `/fs/readFile`.
|
||||
|
||||
This enables client code generation: a client reading the schema can produce
|
||||
typed error enums instead of generic error handling.
|
||||
|
||||
### Registration
|
||||
|
||||
These are registered as `Local` provenance with empty composition authority,
|
||||
empty scoped env, and empty capabilities (they don't compose, don't need
|
||||
credentials):
|
||||
|
||||
```rust
|
||||
.with_local(services_list_spec(), Arc::new(services_list_handler),
|
||||
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
|
||||
.with_local(services_schema_spec(), Arc::new(schema_handler),
|
||||
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
|
||||
```
|
||||
|
||||
### Specs
|
||||
|
||||
```rust
|
||||
fn services_list_spec() -> OperationSpec {
|
||||
OperationSpec {
|
||||
name: "services/list".into(),
|
||||
namespace: "services".into(),
|
||||
op_type: OperationType::Query,
|
||||
visibility: Visibility::External,
|
||||
input_schema: json!({}), // no input
|
||||
output_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"namespace": { "type": "string" },
|
||||
"op_type": { "type": "string", "enum": ["query", "mutation", "subscription"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
error_schemas: vec![],
|
||||
access_control: AccessControl::default(), // no restrictions — callable by all
|
||||
}
|
||||
}
|
||||
|
||||
fn services_schema_spec() -> OperationSpec {
|
||||
OperationSpec {
|
||||
name: "services/schema".into(),
|
||||
namespace: "services".into(),
|
||||
op_type: OperationType::Query,
|
||||
visibility: Visibility::External,
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": { "name": { "type": "string" } },
|
||||
"required": ["name"]
|
||||
}),
|
||||
output_schema: json!({ /* full OperationSpec schema */ }),
|
||||
error_schemas: vec![],
|
||||
access_control: AccessControl::default(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handlers
|
||||
|
||||
The handlers need access to the registry. Since handlers are `Arc<dyn Fn>`,
|
||||
the registry reference is captured in the closure. Use `Arc<OperationRegistry>`
|
||||
cloned into the closure.
|
||||
|
||||
```rust
|
||||
fn services_list_handler(registry: Arc<OperationRegistry>) -> Handler {
|
||||
Arc::new(move |input: Value, ctx: OperationContext| {
|
||||
let registry = registry.clone();
|
||||
Box::pin(async move {
|
||||
let ops: Vec<_> = registry.list_operations()
|
||||
.into_iter()
|
||||
.filter(|s| s.visibility == Visibility::External)
|
||||
.map(|s| json!({
|
||||
"name": s.name,
|
||||
"namespace": s.namespace,
|
||||
"op_type": match s.op_type {
|
||||
OperationType::Query => "query",
|
||||
OperationType::Mutation => "mutation",
|
||||
OperationType::Subscription => "subscription",
|
||||
}
|
||||
}))
|
||||
.collect();
|
||||
ResponseEnvelope::ok(ctx.request_id, json!({ "operations": ops }))
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `services/list` spec with correct fields (Query, External, no input, output schema)
|
||||
- [ ] `services/schema` spec with correct fields (Query, External, name input, full spec output)
|
||||
- [ ] `services/list` handler returns External operations only (Internal excluded)
|
||||
- [ ] `services/list` output format matches spec (operations array with name, namespace, op_type)
|
||||
- [ ] `services/schema` handler accepts name with or without leading slash
|
||||
- [ ] `services/schema` returns full OperationSpec (input_schema, output_schema, error_schemas)
|
||||
- [ ] `services/schema` returns NOT_FOUND for unknown operation name
|
||||
- [ ] Both registered as Local provenance, empty authority/env/caps
|
||||
- [ ] Both have empty AccessControl (callable by all, including unauthenticated)
|
||||
- [ ] Unit test: services/list returns only External ops
|
||||
- [ ] Unit test: services/schema returns spec for known op
|
||||
- [ ] Unit test: services/schema returns NOT_FOUND for unknown op
|
||||
- [ ] Unit test: services/schema accepts both "fs/readFile" and "/fs/readFile"
|
||||
- [ ] `cargo test -p alknet-call` succeeds
|
||||
- [ ] `cargo clippy -p alknet-call` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/call/operation-registry.md — Service Discovery section
|
||||
- docs/architecture/decisions/015-privilege-model-and-authority-context.md — ADR-015 (Internal not in services/list)
|
||||
|
||||
## Notes
|
||||
|
||||
> services/list returns External ops only — Internal ops are implementation
|
||||
> details of composition and must not be enumerable from the wire. The
|
||||
> CallAdapter normalizes leading slashes, so services/schema accepts both
|
||||
> forms. These are the only built-in operations; no admin operations are
|
||||
> exposed through the call protocol itself.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
Reference in New Issue
Block a user