docs(arch): amend call specs for ADR-029/030/032/034 — peer-keyed routing, PeerEntry, forwarded-for, three roles

Sync the call crate specs to the accepted ADRs 029-034:
- operation-registry: PeerCompositeEnv (peer-keyed overlays), invoke_peer/
  PeerRef routing, retire remote_safe/trusted_peer, AccessControl-based peer
  auth, forwarded_for on OperationContext (ADR-029/030/032)
- call-protocol: peer-keyed compose_root_env, forwarded_for in call.requested
  payload, build_root_context forwarded_for parameter (ADR-029/032)
- client-and-adapters: CallClient verifier selection by PeerEntry presence,
  remote_identity: None load-bearing, three remote roles (ADR-034)
- README: ADR-029/030/032/034 in applicable ADRs table
This commit is contained in:
2026-06-28 21:08:26 +00:00
parent 0de2cebb1d
commit 4a52779460
4 changed files with 243 additions and 64 deletions

View File

@@ -361,24 +361,68 @@ pub trait OperationEnv: Send + Sync {
) -> ResponseEnvelope;
/// Does this env contain the named operation? Used by
/// `CompositeOperationEnv` to probe overlays before dispatching
/// (ADR-024). The composite checks `session.contains()` →
/// `connection.contains()` → base, dispatching to the first overlay
/// that contains the op. Default impl returns `true` (a single-layer
/// env like `LocalOperationEnv` contains everything it can dispatch).
/// `PeerCompositeEnv` to probe overlays before dispatching
/// (ADR-024 + ADR-029). The composite checks `session.contains()` →
/// each peer's sub-overlay (in `connection_order`) → base,
/// dispatching to the first overlay that contains the op. Default
/// impl returns `true` (a single-layer env like `LocalOperationEnv`
/// contains everything it can dispatch).
fn contains(&self, name: &str) -> bool { true }
/// Peer-routing composition (ADR-029 §2). Routes to a specific peer
/// (`PeerRef::Specific`) or to the first peer that serves the op
/// (`PeerRef::Any`). The default impl ignores the peer selector and
/// delegates to `invoke_with_policy`, preserving back-compat for
/// single-layer envs (`LocalOperationEnv`, `OverlayOperationEnv`)
/// that don't override it. `PeerCompositeEnv` overrides with real
/// peer-keyed routing.
async fn invoke_peer(
&self,
peer: &PeerRef,
namespace: &str,
operation: &str,
input: Value,
parent: &OperationContext,
policy: AbortPolicy,
) -> ResponseEnvelope {
// default: ignore peer selector, dispatch via invoke_with_policy
let _ = peer; // unused — single-layer envs don't route by peer
self.invoke_with_policy(namespace, operation, input, parent, policy).await
}
/// Does this env contain the named op *on the named peer*? Used by
/// `PeerCompositeEnv` to probe a specific peer's sub-overlay before
/// dispatching via `invoke_peer` with `PeerRef::Specific`. Default
/// impl delegates to `contains` (single-layer envs ignore the peer
/// dimension). `PeerCompositeEnv` overrides to check the specific
/// peer's sub-overlay.
fn peer_contains(&self, _peer: &PeerId, name: &str) -> bool { self.contains(name) }
}
```
The `parent` parameter propagates the calling context: the nested call gets `parent_request_id: Some(parent.request_id)`, inherits `parent.handler_identity` as the caller identity, and is marked `internal: true`.
The `invoke_peer` / `peer_contains` methods (ADR-029 §2) take a `PeerRef`
selector and a `PeerId`. These types are defined alongside the
`PeerCompositeEnv` struct — see [client-and-adapters.md](client-and-adapters.md#peer-keyed-composition-env-adr-029)
and [ADR-029](../../decisions/029-peer-graph-routing-model.md) §2:
```rust
pub enum PeerRef {
Specific(PeerId), // route to this peer; NOT_FOUND if it doesn't serve the op
Any, // first peer (insertion order) that serves it
}
pub type PeerId = String; // = Identity.id from IdentityProvider resolution
// = PeerEntry.peer_id (stable, not crypto material — ADR-030)
```
**Metadata does not propagate through composition.** Nested calls get fresh metadata (`HashMap::new()`), not the parent's metadata bag. This is a security constraint (ADR-014): `metadata: HashMap<String, Value>` accepts any `serde_json::Value`, including secret material. If metadata propagated through `env.invoke()`, a handler that accidentally placed a secret in metadata would leak it to every child operation — and if a child is a `from_call` operation (ADR-017), the metadata would cross the wire to the remote node. The tracing link between parent and child is `parent_request_id`, not metadata propagation. Anything a handler needs to pass to a child goes in the call `input`, not in ambient context.
**Local dispatch only.** The initial `OperationEnv` implementation for the
curated layer (Layer 0) dispatches directly through the local
`OperationRegistry`. The composite env (curated + session + connection
overlays) is a separate type built by the `CallAdapter` per call — see
ADR-024 and the `CompositeOperationEnv` sketch below.
`OperationRegistry`. The composite env (curated + session + peer-keyed
connection overlays) is a separate type built by the `CallAdapter` per call —
see ADR-024, ADR-029, and the `PeerCompositeEnv` sketch below.
```rust
/// Layer 0 dispatch — the curated registry. This is the base env that
@@ -446,53 +490,102 @@ impl OperationEnv for LocalOperationEnv {
```
The composite env (built by the `CallAdapter` per incoming call) wraps the
curated base and any active overlays:
curated base and any active overlays. Per ADR-029, the connection overlay is
**peer-keyed** — a head node with N worker connections holds a
`HashMap<PeerId, connection_overlay>`, not one overlay. The singular-connection
case (one peer) is the degenerate case with a single-entry map.
```rust
/// Per-call composite env (ADR-024). Built by the CallAdapter in
/// Per-call composite env (ADR-024 + ADR-029). Built by the CallAdapter in
/// build_root_context from the active layers. The child inherits this by
/// Arc::clone through invoke().
pub struct CompositeOperationEnv {
session: Option<Arc<dyn OperationEnv + Send + Sync>>, // Layer 1 — active session, if any
connection: Option<Arc<dyn OperationEnv + Send + Sync>>, // Layer 2 — this connection's imported ops
base: Arc<dyn OperationEnv + Send + Sync>, // Layer 0 — curated registry (LocalOperationEnv)
/// Arc::clone through invoke(). The connection overlay is peer-keyed
/// (ADR-029 §1) to handle head→N-workers routing.
pub struct PeerCompositeEnv {
pub base: Arc<dyn OperationEnv + Send + Sync>, // Layer 0 curated
pub session: Option<Arc<dyn OperationEnv + Send + Sync>>, // Layer 1
pub connections: HashMap<PeerId, Arc<dyn OperationEnv + Send + Sync>>, // Layer 2, peer-keyed
connection_order: Vec<PeerId>, // insertion order for PeerRef::Any first-match
}
#[async_trait]
impl OperationEnv for CompositeOperationEnv {
impl OperationEnv for PeerCompositeEnv {
// `invoke` uses the default impl (delegates to `invoke_with_policy`
// with `parent.abort_policy.clone()`).
async fn invoke_with_policy(&self, namespace: &str, operation: &str, input: Value, parent: &OperationContext, policy: AbortPolicy) -> ResponseEnvelope {
// PeerRef::Any routing (ADR-029 §2): session → peers in insertion
// order → curated base. First overlay that *contains* the op wins.
let name = format!("{namespace}/{operation}");
// Reachability check against parent.scoped_env (same as LocalOperationEnv).
if !parent.scoped_env.allows(&name) {
return ResponseEnvelope::not_found(name);
}
// Dispatch in overlay order: session → connection → curated base.
// First overlay that *contains* the op wins. `contains()` (ADR-024)
// is the probe — it avoids the sentinel-return ambiguity and ensures
// cross-impl interop: any OperationEnv impl that correctly reports
// `contains` works with this composite.
if let Some(session) = &self.session {
if session.contains(&name) {
return session.invoke_with_policy(namespace, operation, input, parent, policy).await;
}
}
if let Some(connection) = &self.connection {
if connection.contains(&name) {
return connection.invoke_with_policy(namespace, operation, input, parent, policy).await;
// Peer-keyed overlay: iterate peers in insertion order, dispatch to
// the first peer whose sub-overlay contains the op. This is the
// head→N-workers fan-out primitive (ADR-029 §2, OQ-30: insertion-
// order first-match).
for peer_id in &self.connection_order {
if let Some(conn_env) = self.connections.get(peer_id) {
if conn_env.contains(&name) {
return conn_env.invoke_with_policy(namespace, operation, input, parent, policy).await;
}
}
}
self.base.invoke_with_policy(namespace, operation, input, parent, policy).await
}
// `invoke_peer` overrides the default impl with real peer-keyed
// routing (ADR-029 §2). `PeerRef::Specific` routes to the named peer's
// sub-overlay only (no fallthrough — NOT_FOUND if that peer doesn't
// serve the op). `PeerRef::Any` reuses `invoke_with_policy` (the
// insertion-order fan-out above).
async fn invoke_peer(
&self,
peer: &PeerRef,
namespace: &str,
operation: &str,
input: Value,
parent: &OperationContext,
policy: AbortPolicy,
) -> ResponseEnvelope {
let name = format!("{namespace}/{operation}");
if !parent.scoped_env.allows(&name) {
return ResponseEnvelope::not_found(name);
}
match peer {
PeerRef::Specific(peer_id) => {
// Route to this peer's sub-overlay only. No fallthrough —
// explicit routing must be honored or fail loudly (ADR-029 §2).
match self.connections.get(peer_id) {
Some(conn_env) if conn_env.contains(&name) => {
conn_env.invoke_with_policy(namespace, operation, input, parent, policy).await
}
_ => ResponseEnvelope::not_found(name),
}
}
PeerRef::Any => {
// Same as invoke_with_policy: session → peers in order → base.
self.invoke_with_policy(namespace, operation, input, parent, policy).await
}
}
}
fn contains(&self, name: &str) -> bool {
// The composite contains the op if any layer does.
// The composite contains the op if any layer does (peer-agnostic).
self.session.as_ref().map_or(false, |s| s.contains(name))
|| self.connection.as_ref().map_or(false, |c| c.contains(name))
|| self.connections.values().any(|c| c.contains(name))
|| self.base.contains(name)
}
fn peer_contains(&self, peer: &PeerId, name: &str) -> bool {
// Does the named peer's sub-overlay contain the op?
self.connections.get(peer).map_or(false, |c| c.contains(name))
}
}
```
@@ -500,8 +593,10 @@ The `contains()` method (review #003 C9) is the overlay-dispatch contract.
It replaces the previous "sentinel or contains check — two-way door" framing,
which was ambiguous enough to produce non-interoperable `OperationEnv` impls.
The structural decision (composite trait object, overlay order, `Arc::clone`
inheritance) is locked by ADR-024; the dispatch contract (`contains` probe
before `invoke_with_policy`) is now locked too.
inheritance) is locked by ADR-024; the peer-keyed overlay extension
(`PeerCompositeEnv`, `invoke_peer`, `peer_contains`) is locked by ADR-029; the
dispatch contract (`contains` probe before `invoke_with_policy`) is locked by
both.
Two things happen in `invoke()`:
@@ -510,7 +605,7 @@ Two things happen in `invoke()`:
Future work may add irpc service dispatch and remote call protocol dispatch as additional backends. The handler-facing API stays the same.
**`OperationEnv` must remain a trait.** This is a constraint, not a suggestion. The trait-based design enables registry layering (ADR-024): the CallAdapter composes the root env per call from the curated base + active connection/session overlays, and overlays wrap the base via trait layering. Session-scoped registries (OQ-19) and connection-scoped remote imports (ADR-017 `from_call`) are both overlays on the same base, using the same mechanism. Making `OperationEnv` concrete or hardcoding the global registry into the dispatch path would close both the session-overlay and connection-overlay patterns. This is the same integration-point pattern as `IdentityProvider` (ADR-004). See OQ-19 and ADR-024.
**`OperationEnv` must remain a trait.** This is a constraint, not a suggestion. The trait-based design enables registry layering (ADR-024): the CallAdapter composes the root env per call from the curated base + active peer-keyed connection overlays + session overlay, and overlays wrap the base via trait layering. Session-scoped registries (OQ-19) and connection-scoped remote imports (ADR-017 `from_call`) are both overlays on the same base, using the same mechanism. The peer-keyed extension (`PeerCompositeEnv`, `invoke_peer`, ADR-029) composes on top of the same trait — it overrides the new peer-routing methods, not the base dispatch. Making `OperationEnv` concrete or hardcoding the global registry into the dispatch path would close both the session-overlay and connection-overlay patterns, and would prevent the peer-keyed routing model from composing. This is the same integration-point pattern as `IdentityProvider` (ADR-004). See OQ-19, ADR-024, and ADR-029.
### Service Discovery
@@ -686,10 +781,27 @@ See [open-questions.md](../../open-questions.md) for full details.
`remote_safe`/`trusted_peer` are retired; peer authorization is
`AccessControl::check(peer_identity)`, the existing mechanism. See
[client-and-adapters.md](client-and-adapters.md) and ADR-029 §3.
- **OQ-26..28** (OQ-26/27 stay two-way; OQ-28 cross-peer dissolved by ADR-029 /
same-peer stays): `OperationAdapter` error type, `from_call` re-import
trigger, `from_call` namespace collision. v1 defaults recorded in
- **OQ-26** (resolved): `OperationAdapter` error type — `AdapterError`
variants: `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
`SamePeerCollision` (replaces flat `Conflict`). `#[non_exhaustive]`. See
[client-and-adapters.md](client-and-adapters.md).
- **OQ-27** (resolved): `from_call` re-import trigger — auto-re-import on
connection establishment. `CallConnection::refresh()` is a feature
addition, not an unmade decision. See [client-and-adapters.md](client-and-adapters.md).
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
collision = error; cross-peer dissolved by ADR-029 (separate sub-overlays).
`namespace_prefix` is optional local-naming sugar. See
[client-and-adapters.md](client-and-adapters.md).
- **OQ-29..37** are tracked in [client-and-adapters.md](client-and-adapters.md)
(they concern the `CallClient` / adapter surface and peer-graph routing,
not the registry layering this document covers). In brief: OQ-29
(CallClient TLS client-auth) resolved; OQ-30 (`PeerRef::Any` routing
policy) resolved; OQ-31 (`services/list-peers` re-export semantics)
resolved; OQ-32 (multi-hop federation) open (feature extension);
OQ-33 (PeerId source) resolved by ADR-030; OQ-34 (persistent peer
registry) resolved by ADR-030+033; OQ-35 (API key asymmetry) dissolved;
OQ-36 (concrete persistence adapter shapes) resolved by ADR-035;
OQ-37 (X.509 outgoing-only) resolved by ADR-034.
## References
@@ -699,4 +811,7 @@ See [open-questions.md](../../open-questions.md) for full details.
- ADR-010: ALPN router and endpoint (static registration — applies to the `HandlerRegistry`, not the `OperationRegistry`; see ADR-024 for the distinction)
- ADR-012: Call protocol stream model
- ADR-024: Operation registry layering (curated + session/connection overlays; `OperationEnv` as trait-object integration point)
- ADR-029: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing; `PeerCompositeEnv` supersedes the singular-connection `CompositeOperationEnv`)
- ADR-030: PeerEntry and Identity.id decoupling (`PeerId` source = `Identity.id` = `PeerEntry.peer_id`)
- ADR-032: Forwarded-for identity (`forwarded_for` on `OperationContext` and `call.requested`; metadata only)
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`