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.
|
||||
118
tasks/call/registry/remote-safe-marking.md
Normal file
118
tasks/call/registry/remote-safe-marking.md
Normal 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.
|
||||
158
tasks/call/review-completion.md
Normal file
158
tasks/call/review-completion.md
Normal 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.
|
||||
Reference in New Issue
Block a user