docs(arch): call-completion — ADR-028 peer-scoped filtering + client-and-adapters spec + tasks

Resolves the four gap-analysis decisions (DC-1..4) blocking the alknet-call
client/adapter surface specced in ADR-017:

- ADR-028 (new): locks the one-way door for DC-1 — CallClient registry is
  default-deny (remote_safe: bool on HandlerRegistration, default false across
  all provenance); share-global is an explicit trusted-peer opt-in; filtering
  is a dispatch-time read over the single Layer-0 registry, not a copy.
- client-and-adapters.md (new spec): operationally fills the gap ADR-017 left
  to implementation — CallClient, from_call, from_jsonschema, OperationAdapter
  trait, adapter location map, no-env-vars invariant, exchange-of-operations
  pattern. Keeps call-protocol.md and operation-registry.md under the
  700-line split threshold.
- ADR-017 amended: records DC-2/3/4 v1 defaults (auto-on-reconnect,
  error-on-collision, Result error type) and points DC-1 at ADR-028.
- OQ-25..28 (new): two-way-door remainders (remote_safe shape, AdapterError
  variants, re-import trigger, namespace collision) with v1 defaults recorded.
- Index/cross-ref updates across READMEs and the two existing call specs.

Tasks: 6 task files under tasks/call/ decomposing the completion work along
the gap-analysis priority order — remote-safe-marking (one-way door, first)
→ call-client (phase-risk) → from-call → operation-adapter-trait →
from-jsonschema (parallel with call-client) → review-completion. Graph
validated with taskgraph; parallelism designed in (from-jsonschema runs
concurrent with call-client/from-call once the trait lands).
This commit is contained in:
2026-06-26 12:25:13 +00:00
parent 6940d9858d
commit 2649e068e5
14 changed files with 1817 additions and 11 deletions

View File

@@ -0,0 +1,183 @@
---
id: call/client/call-client
name: Implement CallClient (outbound connection opener) with peer-scoped default-deny dispatch (ADR-017, ADR-028)
status: pending
depends_on: [call/protocol/call-connection, call/registry/remote-safe-marking]
scope: moderate
risk: high
impact: phase
level: implementation
---
## Description
Implement `CallClient` in `src/client/mod.rs` (new `client` module). This is
the #1 gap in alknet-call — the outbound connection opener. Every downstream
consumer (runner pattern, container service, bilateral exchange, NAPI
projection, agent cross-node tool dispatch) is blocked on it. It opens a QUIC
connection to a remote node on ALPN `alknet/call`, performs credential setup,
and produces a `CallConnection` running the **shared** dispatch loop (ADR-017
§1). `CallClient` is the connection-establishment half; `CallAdapter`'s
accept path is the inbound half. Both produce the same `CallConnection`.
### CallClient struct
```rust
pub struct CallClient {
/// The operation registry. The peer-scoped view is a dispatch-time read
/// over this registry, not a copy (ADR-028 §5).
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
/// Trusted-peer mode (ADR-028 §3): when true, the dispatch path exposes
/// all External ops to the remote peer and services/list lists all
/// External ops, ignoring the remote_safe marking. When false (default),
/// only registrations with remote_safe: true dispatch, and services/list
/// hides non-remote-safe ops (ADR-028 Assumption 2).
trusted_peer: bool,
}
impl CallClient {
/// Default: peer-scoped (default-deny). Filters dispatch + services/list
/// by remote_safe == true.
pub fn new(registry: Arc<OperationRegistry>, idp: Arc<dyn IdentityProvider>) -> Self;
/// Trusted-peer mode: expose all External ops, ignore remote_safe.
/// Explicit opt-in per ADR-028 §3.
pub fn trusted_peer(registry: Arc<OperationRegistry>, idp: Arc<dyn IdentityProvider>) -> Self;
/// Open a QUIC connection to `addr` on ALPN `alknet/call`, perform
/// credential handshake, and return a CallConnection running the shared
/// dispatch loop. Credentials come from capabilities (ADR-014), not env
/// vars — see client-and-adapters.md "No-Env-Vars Invariant".
pub async fn connect(
&self,
addr: SocketAddr,
credentials: CallCredentials,
) -> Result<CallConnection, ClientError>;
}
```
### Shared dispatch loop
The dispatch loop is **shared** with `CallAdapter`. Once a connection is
established (whether accepted or opened), the same logic applies: read
`EventEnvelope` frames, dispatch to the operation registry, write responses,
send outgoing `call.requested` for calls initiated on this side. Refactor the
existing accept-path dispatch out of `CallAdapter` into a shared function
(likely in `src/protocol/connection.rs` or a new `src/protocol/dispatch.rs`)
that both `CallAdapter::handle` and `CallClient::connect` call. Do not
duplicate the dispatch loop — ADR-017 §1 is explicit that the client is the
connection-establishment half, not a parallel protocol implementation.
The `CallConnection` type already exists (`protocol/connection.rs`) and
holds the Layer 2 overlay + call/subscribe/abort API. `CallClient::connect`
constructs it from the opened connection (vs `CallAdapter` constructing it
from the accepted connection).
### Peer-scoped dispatch (ADR-028 — default-deny)
The incoming-call dispatch path in the `CallClient` must filter by
`remote_safe`:
- **Default mode** (`trusted_peer: false`): an incoming `call.requested` for
an op name resolves to the registration; if `registration.remote_safe ==
false`, return `NOT_FOUND` (not `FORBIDDEN` — same posture as
`Visibility::Internal` per ADR-015). If `true`, dispatch normally.
`OperationContext.capabilities` is populated from the registration bundle
only for remote-safe ops — this is the security argument for default-deny
(ADR-028 Context): a remote peer's call must not trigger dispatch that
populates capabilities from the local node's registration bundle unless the
op is explicitly exposed.
- **Trusted-peer mode** (`trusted_peer: true`): bypass the `remote_safe`
filter; expose all `External` ops. The operator has made the trust decision
explicitly.
This is a dispatch-time read over the single Layer-0 registry (ADR-028 §5) —
not a copied subset, not a third registry instance. The `OperationRegistry`
from `remote-safe-marking` is the single source.
### services/list hide behavior (ADR-028 Assumption 2)
When the `CallClient` serves `services/list` to the remote peer:
- **Default mode**: hide ops where `remote_safe == false` (in addition to
the existing `Visibility::External` filter). A peer should not see ops it
cannot call.
- **Trusted-peer mode**: list all `External` ops regardless of
`remote_safe`.
The existing `services_list_handler` in `registry/discovery.rs` filters by
`Visibility::External` only. Wire the additional `remote_safe` filter for the
`CallClient`'s serving path. (The `CallAdapter`'s serving path — local
accept — is unchanged; it continues to list all `External` ops, since a
direct QUIC client is not a `CallClient` peer in the filtered sense. Clarify
this split in code comments and a test.)
### Credentials
`connect()` takes a `CallCredentials` bundle. Credentials come from
`Capabilities` (ADR-014), never env vars. The three dimensions (ADR-017 §7):
TLS identity (RFC 7250 raw key or X.509, ADR-027), auth token (opaque,
vault-decrypted), remote identity verification (expected fingerprint/cert).
Populated by the assembly layer at `CallClient` construction time from
vault-derived `Capabilities`. The concrete `TlsIdentity` / `AuthToken` /
`RemoteIdentity` shapes are implementation-detail two-way doors (recorded in
client-and-adapters.md); the one-way constraint is they come from
capabilities, not env vars.
### Connection symmetry
After establishment, the connection is symmetric (ADR-017 §2): both sides
can send and receive `call.requested`. Connection direction is independent of
call direction. The `CallClient` is both a caller (initiates outgoing calls
via `CallConnection::call()`/`subscribe()`/`abort()`) and a callee
(dispatches incoming calls against its peer-scoped view).
## Acceptance Criteria
- [ ] `src/client/mod.rs` exists with `CallClient` struct (registry, idp, trusted_peer)
- [ ] `CallClient::new` constructs default-deny (trusted_peer: false)
- [ ] `CallClient::trusted_peer` constructs trusted-peer mode
- [ ] `connect()` opens a QUIC connection on ALPN `alknet/call`
- [ ] `connect()` returns a `CallConnection` running the shared dispatch loop
- [ ] Dispatch loop is shared with `CallAdapter` (refactored, not duplicated)
- [ ] Default mode: incoming call to op with `remote_safe == false` returns NOT_FOUND
- [ ] Default mode: incoming call to op with `remote_safe == true` dispatches
- [ ] Default mode: capabilities populated only for remote-safe dispatched ops
- [ ] Trusted-peer mode: all External ops dispatch regardless of remote_safe
- [ ] Default mode: services/list hides non-remote-safe ops from the peer
- [ ] Trusted-peer mode: services/list lists all External ops
- [ ] Outgoing call()/subscribe()/abort() work through the returned CallConnection
- [ ] Connection symmetry: remote peer can call back into the CallClient
- [ ] `CallCredentials` carries TLS identity / auth token / remote identity (from capabilities)
- [ ] No env-var reads in the credential path (no-env-vars invariant, ADR-014)
- [ ] Integration test: two-node call (CallClient connects to CallAdapter, both call each other)
- [ ] Integration test: default-deny op returns NOT_FOUND to remote peer
- [ ] Integration test: remote_safe op dispatches to remote peer
- [ ] Integration test: trusted-peer mode exposes all External ops
- [ ] Integration test: services/list hides non-remote-safe in default mode
- [ ] `cargo test -p alknet-call` succeeds
- [ ] `cargo clippy -p alknet-call --all-targets` succeeds with no warnings
## References
- docs/architecture/crates/call/client-and-adapters.md — CallClient §, credential sources §
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 §1 (shared loop), §2 (symmetry), §7 (credentials)
- docs/architecture/decisions/028-callclient-peer-scoped-registry-filtering.md — ADR-028 (default-deny, trusted-peer, Assumption 2 services/list hide)
- docs/architecture/crates/call/call-protocol.md — CallConnection (the type connect() produces)
- tasks/call/protocol/call-connection.md — completed CallConnection task
- tasks/call/registry/remote-safe-marking.md — prerequisite (adds remote_safe field)
- docs/research/alknet-call-completion/gap-analysis.md — DC-1, implementation priority #1
## Notes
> This is the single highest-value piece of work in the alknet-call
> completion — every downstream consumer is blocked on it. The dispatch loop
> is shared with CallAdapter (refactor, don't duplicate — ADR-017 §1 is
> explicit). The peer-scoped default-deny (ADR-028) is the one-way-door
> security dimension: a remote peer's call must not populate
> OperationContext.capabilities from the local bundle unless the op is
> explicitly remote-safe. The v1 shape is `trusted_peer: bool` + the
> `remote_safe: bool` field from `remote-safe-marking`; per-peer allowlists
> are OQ-25 and explicitly out of scope. Credentials come from capabilities,
> never env vars (no-env-vars invariant).

View File

@@ -0,0 +1,155 @@
---
id: call/client/from-call
name: Implement from_call adapter (discover remote ops via services/list + services/schema, register FromCall leaves)
status: pending
depends_on: [call/client/call-client]
scope: moderate
risk: medium
impact: component
level: implementation
---
## Description
Implement `from_call` in `src/client/from_call.rs`. This is the #2 gap — it
discovers the remote peer's `External` operations and registers them in the
connection's Layer 2 overlay as `FromCall`-provenance leaves with forwarding
handlers. The discovery mechanism (`services/list` + `services/schema`) is
already implemented in `registry/discovery.rs`; `from_call` is the
client-side consumer of that API.
### Flow (ADR-017 §3)
1. Call `services/list` on the remote → list of `External` operations.
2. Call `services/schema` for each → input/output JSON Schemas and declared
`error_schemas` (ADR-023).
3. For each discovered op, construct a `HandlerRegistration`:
- `spec` mirrors the remote op's name (with optional prefix), namespace,
type, schemas, access control.
- `handler` is a forwarding handler: sends `call.requested` through the
`CallConnection`, awaits `call.responded` (or streams for subscriptions).
- `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
(leaf — ADR-022).
4. The caller registers the bundles via
`CallConnection::register_imported_all()`.
### API
```rust
pub struct FromCallConfig {
/// Namespace prefix applied to imported operation names. Optional —
/// default no prefix. Collision on import is an error (DC-3, OQ-28),
/// not last-wins.
pub namespace_prefix: Option<String>,
/// Optional filter — import only operations whose names match. None
/// imports all External ops discovered via services/list.
pub operation_filter: Option<HashSet<String>>,
}
/// Discover the remote peer's External ops and construct HandlerRegistration
/// bundles with FromCall provenance and forwarding handlers. The caller
/// registers the bundles in the connection's overlay via
/// CallConnection::register_imported_all().
pub async fn from_call(
connection: &CallConnection,
config: FromCallConfig,
) -> Result<Vec<HandlerRegistration>, AdapterError>;
```
### Forwarding handler
The handler captures a handle to the `CallConnection` and, on invocation:
- For a `Query`/`Mutation` op: calls `connection.call(imported_name, input)`,
returns the `ResponseEnvelope`.
- For a `Subscription` op: calls `connection.subscribe(imported_name, input)`,
yields each `call.responded` until `call.completed`/`call.aborted`.
- The handler's `parent_request_id` participates in the abort cascade
(ADR-016 §6) — if the parent is aborted, the cascade reaches this handler,
which sends `call.aborted` to the remote node; the remote node cascades to
its own descendants. Cross-node abort is transparent.
### Re-import on reconnection (DC-2, OQ-27)
v1 default: `from_call` runs **automatically on connection establishment**.
The overlay is per-connection (Layer 2, ADR-024), so a stale overlay dies with
the connection; re-import on reconnect is naturally scoped to the new
connection. This is the right default for the runner pattern (a worker
reconnects → the hub re-discovers the worker's ops automatically). Wire the
auto-re-import into the `CallClient::connect` path (or document that the
assembly layer calls `from_call` immediately after `connect()` — pick the
cleaner integration; the auto-on-reconnect behavior is the v1 contract).
Explicit re-import via a future `CallConnection::refresh()` is additive
(OQ-27); do not implement `refresh()` in this task unless the auto-import
wiring naturally produces it.
### Namespace collision (DC-3, OQ-28)
v1 default: **optional prefix, default no prefix, collision = error**. A node
importing from two remotes that both expose `/container/exec` without
prefixes should fail loudly (return `AdapterError`) rather than silently
overwrite. The operator adds prefixes when importing from multiple sources.
Implement collision detection: if applying the (possibly empty) prefix
produces a name that already exists in the target overlay, return an error.
This matches the default-deny, explicit-allow posture (ADR-015, ADR-028).
### Provenance and visibility
`from_call`-registered operations are `Internal` by default (ADR-015) —
composition material, not directly callable from the wire. The handler that
composes them is `External`. Set `remote_safe: false` on FromCall leaves
(they're leaves — they don't expose to *their* peers; the composition
authority is `None`).
### Trust is transitive
A `from_call`-imported operation executes the remote node's code, not yours.
The scoped env (ADR-015) bounds *which* operations are reachable, not *what*
they do. `from_call` means "I trust the remote node as much as my own
handlers." This is inherent to remote composition; the spec records it, the
implementation doesn't need to enforce it beyond the scoped-env reachability
that already exists.
## Acceptance Criteria
- [ ] `src/client/from_call.rs` exists with `FromCallConfig` and `from_call`
- [ ] `from_call` calls `services/list` then `services/schema` for each op
- [ ] Each discovered op becomes a `HandlerRegistration` with `provenance: FromCall`
- [ ] Forwarding handler sends `call.requested` via `CallConnection::call`/`subscribe`
- [ ] Subscription forwarding yields until `call.completed`/`call.aborted`
- [ ] `composition_authority: None`, `scoped_env: None` for FromCall leaves
- [ ] `remote_safe: false` on FromCall leaves
- [ ] Namespace prefix applied when `config.namespace_prefix` is Some
- [ ] Collision on import (same prefixed name) returns `AdapterError`, not silent overwrite
- [ ] `operation_filter` limits which ops are imported
- [ ] Re-import runs on connection establishment (auto-on-reconnect, v1 default)
- [ ] Cross-node abort: parent abort cascades to from_call handler → sends call.aborted remote
- [ ] `from_call` returns `Result<_, AdapterError>` (the error type from OQ-26)
- [ ] Integration test: from_call populates Layer 2 overlay with remote External ops
- [ ] Integration test: forwarding handler invokes remote op and returns result
- [ ] Integration test: subscription forwarding streams remote events
- [ ] Integration test: namespace collision returns error
- [ ] Integration test: operation_filter limits imports
- [ ] `cargo test -p alknet-call` succeeds
- [ ] `cargo clippy -p alknet-call --all-targets` succeeds with no warnings
## References
- docs/architecture/crates/call/client-and-adapters.md — from_call §, re-import §, namespace collision §
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 §3 (from_call flow), §6 (cross-node abort)
- docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md — ADR-022 (leaf provenance, None authority/env)
- docs/architecture/decisions/024-operation-registry-layering.md — ADR-024 (Layer 2 overlay)
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016 §6 (cross-node cascade)
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023 (error_schemas mirrored)
- docs/research/alknet-call-completion/gap-analysis.md — DC-2, DC-3, DC-5, implementation priority #2
## Notes
> from_call is the client-side consumer of the already-implemented
> services/list + services/schema discovery API. The v1 defaults are
> auto-on-reconnect (DC-2/OQ-27) and error-on-collision (DC-3/OQ-28) — both
> two-way doors, recorded in client-and-adapters.md, revisitable without an
> ADR. The AdapterError type (DC-4/OQ-26) is shared with the
> operation-adapter-trait task — coordinate the enum shape. Cross-node abort
> is transparent via the forwarding handler's parent_request_id (ADR-016 §6).

View File

@@ -0,0 +1,124 @@
---
id: call/client/from-jsonschema
name: Implement from_jsonschema adapter (schema-only registration, FromJsonSchema provenance, no handler)
status: pending
depends_on: [call/client/operation-adapter-trait]
scope: narrow
risk: low
impact: isolated
level: implementation
---
## Description
Implement `from_jsonschema` in `src/client/from_jsonschema.rs`. This is the #4
gap — schema-only registration: produces `HandlerRegistration` bundles with
no handler (`FromJsonSchema` provenance). Used for validation, discovery, and
composition-graph construction without a runtime — type-checking a
composition plan without executing it, building a UI of available operations
without standing up the transports, etc.
### Distinct from from_call (gap analysis DC-5 — confirmed, not a decision)
| | `from_jsonschema` | `from_call` |
|---|---|---|
| Schema source | Provided directly (caller fetches, passes in) | Discovered over wire (`services/list` + `services/schema`) |
| Handler at call time | None (schema-only, `FromJsonSchema` provenance) | Forwards over QUIC (`FromCall` provenance, leaf) |
| Use case | Type validation, discovery, composition graph construction | Actually invoking remote operations |
Keeping them separate preserves the "schema-only, no execution" use case (type
checking, safe composition planning without runtime).
### API
```rust
/// Schema-only registration: produce a HandlerRegistration bundle with
/// FromJsonSchema provenance and no handler. The caller fetches the JSON
/// Schema doc and passes it in; this adapter does no network I/O.
pub fn from_jsonschema(
spec: OperationSpec,
schema: serde_json::Value,
) -> HandlerRegistration;
```
The bundle:
- `provenance: FromJsonSchema`
- `composition_authority: None` (no composition — it's schema-only)
- `scoped_env: None` (leaf-equivalent — no reachability)
- `capabilities: Capabilities::new()` (empty — no outbound credentials, no handler to use them)
- `remote_safe: false` (default — ADR-028 §4; provenance-aware default)
- `handler`: a placeholder that returns a `NOT_FOUND`-style or
`INVALID_INPUT`-style error if ever invoked. Since `FromJsonSchema` ops are
`Internal`/not-remote-safe by default and have no composition authority, they
should never be dispatched; the placeholder makes the type-level constraint
hold (the `Handler` type requires a closure) and fails loudly if a bug routes
a call to it.
### OperationAdapter impl
`from_jsonschema` implements the `OperationAdapter` trait (from
`operation-adapter-trait`). Because it does no I/O, the `import()` body
contains no `.await` points — it trivially satisfies the async trait.
```rust
pub struct FromJsonSchema {
spec: OperationSpec,
schema: serde_json::Value,
}
#[async_trait]
impl OperationAdapter for FromJsonSchema {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError> {
// No .await — pure parse. Validates schema shape if useful, returns bundle.
Ok(vec![from_jsonschema(self.spec.clone(), self.schema.clone())])
}
}
```
If the schema is malformed, return `AdapterError::SchemaParse`.
### Why this is standalone (medium priority)
`from_jsonschema` doesn't depend on `CallClient` or `from_call` — it's pure
parse with no transport. It's sequenced after `operation-adapter-trait` only
because it implements the trait; if the trait lands first, this can proceed
in parallel with `call-client`/`from-call`. It's medium priority because the
primary consumers (runner, container service, agent) need `from_call`, not
`from_jsonschema`; the schema-only use case is validation/discovery tooling.
## Acceptance Criteria
- [ ] `src/client/from_jsonschema.rs` exists with `from_jsonschema` fn + `FromJsonSchema` struct
- [ ] `from_jsonschema` produces a `HandlerRegistration` with `provenance: FromJsonSchema`
- [ ] `composition_authority: None`, `scoped_env: None`, empty `capabilities`
- [ ] `remote_safe: false` (provenance-aware default, ADR-028 §4)
- [ ] Handler placeholder returns an error if invoked (no real handler)
- [ ] `FromJsonSchema` implements `OperationAdapter` (async, no .await in import)
- [ ] Malformed schema returns `AdapterError::SchemaParse`
- [ ] No network I/O (pure parse — caller fetches the doc)
- [ ] Unit test: from_jsonschema produces a bundle with correct provenance + None fields
- [ ] Unit test: placeholder handler returns error when invoked
- [ ] Unit test: OperationAdapter impl returns Ok with one bundle
- [ ] Unit test: malformed schema returns SchemaParse error
- [ ] `cargo test -p alknet-call` succeeds
- [ ] `cargo clippy -p alknet-call --all-targets` succeeds with no warnings
## References
- docs/architecture/crates/call/client-and-adapters.md — from_jsonschema §, from_jsonschema vs from_call table
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 §5 (FromJsonSchema impl)
- docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md — ADR-022 (FromJsonSchema provenance, leaf-equivalent None fields)
- docs/architecture/decisions/028-callclient-peer-scoped-registry-filtering.md — ADR-028 §4 (remote_safe default false)
- tasks/call/client/operation-adapter-trait.md — prerequisite (the trait + AdapterError)
- docs/research/alknet-call-completion/gap-analysis.md — DC-5, implementation priority #4
## Notes
> from_jsonschema is distinct from from_call (DC-5): schema source is
> provided directly (caller fetches), there's no handler at call time, and
> the use case is validation/discovery/composition-graph construction without
> a runtime. It's pure parse with no transport, so it can proceed in parallel
> with call-client/from-call once the trait lands. The placeholder handler
> fails loudly if a bug ever routes a call to a schema-only op — they're
> Internal + not-remote-safe + no composition authority, so dispatch should
> never reach them.

View File

@@ -0,0 +1,125 @@
---
id: call/client/operation-adapter-trait
name: Define OperationAdapter async trait + AdapterError enum (ADR-017 §5, DC-4/OQ-26)
status: pending
depends_on: [call/registry/handler-registration]
scope: narrow
risk: low
impact: project
level: implementation
---
## Description
Define the `OperationAdapter` async trait and the `AdapterError` crate-level
enum in `src/client/adapter.rs` (or `src/registry/adapter.rs` — pick the
module that keeps the trait near the types it produces). This is the #3 gap
(enabling, not blocking) — `from_call` can be built as a free function before
the trait exists, but the trait is needed before `alknet-http`'s
`from_openapi`/`from_mcp` adapters can be built. Small, standalone, unblocks
`alknet-http` Phase 1.
### The trait (ADR-017 §5)
```rust
#[async_trait]
pub trait OperationAdapter: Send + Sync {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
}
```
The trait is **async** because `from_call` requires async discovery
(`services/list` + `services/schema` over a QUIC connection). Sync adapters
(`from_openapi`, `from_mcp` reading a static spec) trivially satisfy an async
trait — their `import()` bodies contain no `.await` points. This is locked by
ADR-017 §5; the async/sync question is decided.
The return type is `Vec<HandlerRegistration>` (not `(OperationSpec, Handler)`
pairs) — ADR-022 changed the registration API to the bundle shape, and
adapters must produce bundles. Adapter convenience methods construct bundles
with `composition_authority: None` and `scoped_env: None` for the leaf ops they
produce.
The `to_*` adapters (`to_openapi`, `to_mcp`) are outbound projections, not
`OperationAdapter` implementations — they consume the registry, they don't
produce entries for it (ADR-017 §5). Do not implement `to_*` here.
### AdapterError (DC-4, OQ-26)
ADR-017 §5 showed `async fn import(&self) -> Vec<HandlerRegistration>` with no
error type. A real implementation needs to handle failures. The trait returns
`Result<Vec<HandlerRegistration>, AdapterError>` where `AdapterError` is a
crate-level enum covering the failure modes real implementations hit:
- `DiscoveryFailed``from_call` remote unreachable / `services/list` failed
- `SchemaParse``from_openapi` / `from_jsonschema` couldn't parse the spec
- `Transport` — underlying transport error (QUIC for `from_call`, HTTP for
`from_openapi`/`from_mcp`)
- `Unauthorized` — HTTP 401 for `from_openapi`/`from_mcp`, auth rejected for
`from_call`
- `Conflict` — namespace collision in `from_call` (DC-3); reuse for other
adapter collisions
The exact variant set is the two-way-door remainder (OQ-26); the *presence* of
an error type is recorded in `client-and-adapters.md`. Pick the variants
above as the v1 set; add a `#[non_exhaustive]` so `alknet-http`'s adapters can
extend without breaking match arms. Use `thiserror::Error` for the derive
(consistent with the crate's existing error types).
### Where the trait lives
The trait lives in **alknet-call** (where the types — `HandlerRegistration`,
`OperationSpec`, `Handler` — live). The *implementations* live where their
transport dependencies live (the adapter location map, client-and-adapters.md):
- `FromCall` — QUIC-backed (in `alknet-call`, task `call/client/from_call`)
- `FromJsonSchema` — pure parse, no transport (in `alknet-call`, task
`call/client/from-jsonschema`)
- `FromOpenAPI` — HTTP-backed (in `alknet-http`, separate Phase 0)
- `FromMCP` — MCP streamable-HTTP-backed (in `alknet-http`, feature-gated,
separate Phase 0)
Do not implement `FromOpenAPI`/`FromMCP` here — those are `alknet-http` tasks.
This task defines the trait + error; `from_call` and `from_jsonschema`
implement it (in their tasks).
### Implementations registered in this task
Optionally implement a trivial `FromJsonSchema` adapter in this task if it
falls out naturally (it's a pure-parse adapter with no transport — see
`call/client/from-jsonschema`). If it doesn't fall out naturally, leave it for
the `from-jsonschema` task; the trait + error alone satisfy this task's
acceptance criteria.
## Acceptance Criteria
- [ ] `OperationAdapter` trait defined: `async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>`
- [ ] Trait is `#[async_trait]` (async — ADR-017 §5, locked)
- [ ] `AdapterError` enum defined with `#[non_exhaustive]` and `thiserror::Error`
- [ ] `AdapterError` variants: `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`, `Conflict`
- [ ] Trait + error are `pub` and re-exported from `lib.rs`
- [ ] Trait is located in alknet-call (where the types live), not alknet-http
- [ ] Doc comments link to ADR-017 §5 and client-and-adapters.md
- [ ] Unit test: a trivial test adapter implementing the trait compiles and returns Ok
- [ ] Unit test: a test adapter returning `Err(AdapterError::SchemaParse)` compiles
- [ ] `cargo test -p alknet-call` succeeds
- [ ] `cargo clippy -p alknet-call --all-targets` succeeds with no warnings
## References
- docs/architecture/crates/call/client-and-adapters.md — OperationAdapter trait §, Adapter Location Map §
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 §5 (the trait contract), Amendments (DC-4 resolution)
- docs/architecture/open-questions.md — OQ-26 (AdapterError variants, two-way-door remainder)
- docs/research/alknet-call-completion/gap-analysis.md — DC-4, implementation priority #3
## Notes
> The trait is async because from_call needs async discovery; sync adapters
> (from_openapi reading a static spec) trivially satisfy it. The trait lives
> in alknet-call (where the types live); implementations live with their
> transport deps (from_call/from_jsonschema here, from_openapi/from_mcp in
> alknet-http). The AdapterError variants are the two-way-door remainder
> (OQ-26) — `#[non_exhaustive]` lets alknet-http extend without breaking. This
> task is small and standalone; it unblocks alknet-http Phase 1's adapter
> implementations. The to_* adapters are projections, not OperationAdapter
> impls — don't implement them here.

View File

@@ -0,0 +1,118 @@
---
id: call/registry/remote-safe-marking
name: Add remote_safe field to HandlerRegistration for CallClient peer-scoped filtering (ADR-028)
status: pending
depends_on: []
scope: narrow
risk: medium
impact: isolated
level: implementation
---
## Description
Add the `remote_safe: bool` field to `HandlerRegistration` (and its builder) so
that a `CallClient` can default-deny which operations it exposes to a remote
peer. This is the v1 shape of the peer-scoped filtering mechanism locked by
ADR-028. It is the prerequisite for `call/client/call-client` (the
`CallClient`'s dispatch path reads this field) and is the only one-way-door
piece of the call-completion work, so it goes first.
### Field
```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,
pub remote_safe: bool, // default false; ADR-028 — exposes this op to
// CallClient peers (trusted-peer mode bypasses)
}
```
`remote_safe` defaults to `false` across **all** provenance (Local, Session,
and the leaf provenances — see ADR-028 §4). The operator flips specific
operations to `true` when they want a peer to reach them. This mirrors the
default-deny posture of ADR-015 (visibility `Internal` by default) and
ADR-022 (composition authority `None` for leaves by default).
### Builder
`OperationRegistryBuilder` needs a way to set `remote_safe`:
- `.with_local(...)` / `.with_leaf(...)` / `.with(...)` should default
`remote_safe: false` (current call sites stay valid unchanged).
- Add a chainable setter, e.g. `.remote_safe(true)` on the builder or a
`with_local_remote(...)` / explicit-arg variant. The exact builder API shape
is a two-way door — pick the least invasive (an optional trailing arg or a
builder setter method); do not over-engineer per-peer allowlists (that's
OQ-25's two-way-door remainder, explicitly out of scope here).
### services/list interaction (ADR-028 Assumption 2)
`services/list` already filters by `Visibility::External` (ADR-015). Per
ADR-028 Assumption 2, when served to a `CallClient` peer, `services/list` must
**additionally hide** non-remote-safe ops — a peer should not see ops it
cannot call, so discovery and dispatch filters agree. The
`services_list_handler` in `registry/discovery.rs` currently filters only on
`visibility`.
**Scoping note**: the `services/list` handler doesn't know whether the caller
is a `CallClient` peer or a local process. The v1 implementation: the filter
applied by `services/list` is the *registry's* filter, and the peer-scoped
*view* a `CallClient` exposes is built atop this. The cleanest v1 split is:
- `services/list` keeps filtering by `Visibility::External` (unchanged).
- The `CallClient`'s peer-scoped view (task `call/client/call-client`) is a
dispatch-time read that additionally filters by `remote_safe`, and the
`CallClient`'s *own* `services/list` serving (when it receives
`services/list` from the remote peer) hides non-remote-safe ops.
So this task adds the **field + builder setter + provenance defaults**, and
the *filtering behavior* that consumes the field is wired in
`call/client/call-client` (the dispatch path) and — for the
`services/list`-hides-non-remote-safe behavior — in the `CallClient`'s
serving path. **This task only adds the data and defaults**, plus a unit
test that the field defaults to `false` and that the setter flips it. Keep
this task tightly scoped: adding the field must not change any existing
dispatch behavior (the field is read-only by the CallClient layer added
later).
## Acceptance Criteria
- [ ] `HandlerRegistration` has `pub remote_safe: bool` field
- [ ] All existing `with_local` / `with_leaf` / `with` builder call sites
compile unchanged (default `remote_safe: false`)
- [ ] A builder setter exists to set `remote_safe: true` (e.g.
`.remote_safe(true)` or an explicit-arg variant)
- [ ] Provenance-aware defaults: `Local`, `Session`, `FromOpenAPI`, `FromMCP`,
`FromCall`, `FromJsonSchema` all default to `false` (ADR-028 §4)
- [ ] No existing dispatch path behavior changes (field is data-only here;
the CallClient filter that reads it is a later task)
- [ ] `services/list` handler is unchanged in this task (filtering wired later)
- [ ] Unit test: `HandlerRegistration` default has `remote_safe == false`
- [ ] Unit test: builder setter produces `remote_safe == true`
- [ ] Unit test: all six provenance variants default `remote_safe == false`
- [ ] `cargo test -p alknet-call` succeeds
- [ ] `cargo clippy -p alknet-call --all-targets` succeeds with no warnings
## References
- docs/architecture/decisions/028-callclient-peer-scoped-registry-filtering.md — ADR-028 (the one-way door; §2 field location, §4 provenance defaults, Assumption 2 services/list hide)
- docs/architecture/crates/call/operation-registry.md — HandlerRegistration struct sketch (now shows `remote_safe`)
- docs/architecture/crates/call/client-and-adapters.md — CallClient § (consumes the field; trusted-peer bypass)
- docs/research/alknet-call-completion/gap-analysis.md — DC-1
## Notes
> This is the one-way-door piece of the call-completion work: the *existence*
> of default-deny filtering is locked by ADR-028; this task adds only the v1
> data shape (`remote_safe: bool`) and defaults. The richer per-peer shape
> (allowlist, capability-class tag) is explicitly out of scope — it's the
> two-way-door remainder tracked as OQ-25. Do not implement per-peer logic
> here. The filtering *behavior* (dispatch path + services/list hide) is wired
> in `call/client/call-client`, not here — this task is data + defaults only
> so it can land first and unblock the CallClient task.

View File

@@ -0,0 +1,158 @@
---
id: call/review-completion
name: Review alknet-call client/adapter completion for spec conformance (ADR-017, ADR-028) and no-env-vars invariant
status: pending
depends_on: [call/client/from-call, call/client/operation-adapter-trait, call/client/from-jsonschema]
scope: broad
risk: low
impact: phase
level: review
---
## Description
Review the alknet-call client/adapter completion (the gap ADR-017 left to
implementation, now specced in `client-and-adapters.md`) for spec conformance,
security-constraint conformance, and pattern consistency. This is the quality
checkpoint at the end of the call-completion batch — the work that unblocks
every downstream consumer (runner, container service, bilateral exchange,
NAPI, agent cross-node dispatch).
### Review Checklist
1. **CallClient conformance** (client-and-adapters.md §CallClient):
- `CallClient` struct with registry, identity_provider, trusted_peer
- `new()` constructs default-deny (trusted_peer: false)
- `trusted_peer()` constructs trusted-peer mode (explicit opt-in)
- `connect()` opens QUIC on ALPN `alknet/call`, returns CallConnection
- Dispatch loop is **shared** with CallAdapter (refactored, not duplicated — ADR-017 §1)
- Connection symmetry (ADR-017 §2): both sides call each other after establishment
- Credentials from Capabilities, not env vars (ADR-014, no-env-vars invariant)
2. **Peer-scoped filtering conformance** (ADR-028):
- Default-deny: op with `remote_safe == false` returns NOT_FOUND to remote peer
- Default-deny: `OperationContext.capabilities` populated only for remote-safe ops
- Trusted-peer mode: all External ops dispatch regardless of remote_safe
- services/list hides non-remote-safe ops in default mode (ADR-028 Assumption 2)
- services/list lists all External ops in trusted-peer mode
- Dispatch-time read over single Layer-0 registry (not a copy — ADR-028 §5)
- `remote_safe` defaults false across all provenance (ADR-028 §4)
3. **from_call conformance** (client-and-adapters.md §from_call):
- Calls services/list then services/schema for each op
- Constructs HandlerRegistration with `provenance: FromCall`
- Forwarding handler sends call.requested via CallConnection
- Subscription forwarding yields until completed/aborted
- `composition_authority: None`, `scoped_env: None` (leaf — ADR-022)
- `remote_safe: false` on FromCall leaves
- Namespace collision = error (DC-3/OQ-28), not silent overwrite
- Re-import on connection establishment (DC-2/OQ-27, v1 default)
- Cross-node abort via parent_request_id (ADR-016 §6)
4. **OperationAdapter trait conformance** (client-and-adapters.md §OperationAdapter):
- `async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>`
- Trait is `#[async_trait]` (async — ADR-017 §5, locked)
- `AdapterError` is `#[non_exhaustive]` + `thiserror::Error`
- Variants: DiscoveryFailed, SchemaParse, Transport, Unauthorized, Conflict
- Trait lives in alknet-call (where the types live), not alknet-http
5. **from_jsonschema conformance** (client-and-adapters.md §from_jsonschema):
- `provenance: FromJsonSchema`, no real handler (placeholder errors if invoked)
- `composition_authority: None`, `scoped_env: None`, empty capabilities
- `remote_safe: false` (provenance default, ADR-028 §4)
- Implements OperationAdapter, no .await in import (pure parse)
- Malformed schema → `AdapterError::SchemaParse`
6. **Adapter location map conformance** (client-and-adapters.md §Adapter Location Map):
- OperationAdapter trait + from_call + from_jsonschema + CallClient in alknet-call
- No HTTP client / HTTP server deps in alknet-call (stays lean)
- from_openapi/from_mcp/to_openapi/to_mcp NOT in alknet-call (deferred to alknet-http)
- MCP stdio not built (security position, not a feature gap)
7. **No-env-vars invariant** (client-and-adapters.md §No-Env-Vars Invariant):
- Credential path: vault → assembly → Capabilities → HandlerRegistration.capabilities → OperationContext.capabilities → handler
- No handler reads outbound credentials from any source other than OperationContext.capabilities
- No `std::env::var` reads in the credential path
- The invariant is enforced by the dispatch path (build_root_context), not runtime convention
8. **ADR conformance (completion-specific)**:
- ADR-017 §1: shared dispatch loop, CallClient own registry (now peer-scoped per ADR-028)
- ADR-017 §2: connection direction independent of call direction
- ADR-017 §3: from_call flow (services/list + services/schema), FromCallConfig prefix/filter
- ADR-017 §5: async trait, bundles not (spec,handler) pairs, to_* are projections not impls
- ADR-017 §6: cross-node abort cascade through from_call handler
- ADR-017 §7: credentials from capabilities (TLS identity, auth token, remote identity)
- ADR-028: default-deny, remote_safe bool, trusted-peer opt-in, dispatch-time read, services/list hide
9. **Security constraints (completion-specific)**:
- Default-deny filtering: remote peer can't trigger capability exposure for non-remote-safe ops
- Trusted-peer opt-in is explicit, never default
- Capabilities non-serializable, never cross the wire (ADR-014)
- from_call trust is transitive (remote node's code runs) — recorded in spec, not enforced beyond scoped env
- FromCall/FromJsonSchema leaves have no composition authority (can't escalate)
10. **Test coverage**:
- Integration test: two-node call (CallClient ↔ CallAdapter, both call each other)
- Integration test: default-deny op → NOT_FOUND to remote peer
- Integration test: remote_safe op dispatches to remote peer
- Integration test: trusted-peer mode exposes all External ops
- Integration test: services/list hides non-remote-safe in default mode
- Integration test: from_call populates Layer 2 overlay, forwarding works
- Integration test: subscription forwarding streams remote events
- Integration test: namespace collision returns error
- Integration test: cross-node abort cascades through from_call handler
- Unit tests: AdapterError variants, OperationAdapter trait compiles
11. **Spec drift check**: verify `client-and-adapters.md` still matches the
implementation after the completion (no spec/impl drift introduced during
implementation). In particular: the CallClient struct sketch, the
CallCredentials sketch, the FromCallConfig fields, the AdapterError
variants, and the remote_safe field on HandlerRegistration.
## Acceptance Criteria
- [ ] CallClient matches client-and-adapters.md (struct, new/trusted_peer, connect, shared loop)
- [ ] Peer-scoped filtering matches ADR-028 (default-deny, trusted-peer, services/list hide)
- [ ] from_call matches client-and-adapters.md (flow, FromCallConfig, provenance, None fields)
- [ ] OperationAdapter trait + AdapterError match client-and-adapters.md (async, non_exhaustive, variants)
- [ ] from_jsonschema matches client-and-adapters.md (provenance, placeholder handler, no I/O)
- [ ] Adapter location map respected (no HTTP deps in alknet-call; from_openapi/mcp not built here)
- [ ] No-env-vars invariant holds (credentials from Capabilities, no env-var reads)
- [ ] ADRs 017 + 028 conformed to (plus 014/015/016/022/023/024 where touched)
- [ ] Default-deny security constraint enforced (no capability exposure for non-remote-safe)
- [ ] Integration tests cover two-node call, default-deny, trusted-peer, from_call, abort cascade
- [ ] No spec/impl drift in client-and-adapters.md (or drift documented + spec amended)
- [ ] `cargo fmt --check -p alknet-call` passes
- [ ] `cargo clippy -p alknet-call --all-targets` passes with no warnings
- [ ] All tests pass
## References
- docs/architecture/crates/call/client-and-adapters.md — the spec being reviewed against
- docs/architecture/crates/call/README.md — crate index (now lists client-and-adapters.md)
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 (amended)
- docs/architecture/decisions/028-callclient-peer-scoped-registry-filtering.md — ADR-028
- docs/architecture/open-questions.md — OQ-25..28 (two-way-door remainders — verify defaults match spec)
- docs/research/alknet-call-completion/gap-analysis.md — DC-1..4, the decisions this batch resolved
- tasks/call/registry/remote-safe-marking.md
- tasks/call/client/call-client.md
- tasks/call/client/from-call.md
- tasks/call/client/operation-adapter-trait.md
- tasks/call/client/from-jsonschema.md
## Notes
> This review closes the call-completion batch. The load-bearing security
> invariant is ADR-028's default-deny: a remote peer's call must not trigger
> dispatch that populates OperationContext.capabilities from the local
> registration bundle unless the op is explicitly remote-safe. Verify this
> with a test that asserts a non-remote-safe op's call does NOT populate
> capabilities (not just that it returns NOT_FOUND — the security argument is
> about capability exposure, not just call denial). The no-env-vars invariant
> (ADR-014) is the dispatch-side corollary: no handler reads credentials from
> any source other than OperationContext.capabilities. The shared dispatch
> loop (ADR-017 §1) is the architectural commitment that keeps CallClient from
> becoming a parallel protocol implementation — verify the loop is genuinely
> shared (refactored out of CallAdapter), not copy-pasted. If deviations are
> found, document and fix before considering the call-completion batch done.
> This unblocks every downstream consumer, so spec/impl drift here propagates.