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:
183
tasks/call/client/call-client.md
Normal file
183
tasks/call/client/call-client.md
Normal 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).
|
||||
155
tasks/call/client/from-call.md
Normal file
155
tasks/call/client/from-call.md
Normal 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).
|
||||
124
tasks/call/client/from-jsonschema.md
Normal file
124
tasks/call/client/from-jsonschema.md
Normal 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.
|
||||
125
tasks/call/client/operation-adapter-trait.md
Normal file
125
tasks/call/client/operation-adapter-trait.md
Normal 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.
|
||||
Reference in New Issue
Block a user