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

@@ -87,12 +87,17 @@ pub trait SessionOverlaySource: Send + Sync {
/// The agent crate determines how to map a call to its session.
fn overlay_for(&self, context: &OperationContext) -> Option<Arc<dyn OperationEnv + Send + Sync>>;
}
```
The `CallAdapter` holds the static curated registry and an optional
session-overlay source. Per-connection imported-ops overlays (Layer 2,
ADR-024) are held with the connection and composed into the root
`OperationContext.env` per incoming call. See ADR-024 for the layering
model and `compose_root_env` below.
`OperationContext.env` per incoming call. The composition env is peer-keyed
(`PeerCompositeEnv`, ADR-029 §1) to handle head→N-workers routing — a head
node with multiple worker connections holds a peer-keyed
`HashMap<PeerId, connection_overlay>`, not one overlay. See ADR-024 for the
layering model, ADR-029 for the peer-keyed extension, and `compose_root_env`
below.
### CallConnection
@@ -237,13 +242,19 @@ The `payload` of a `call.requested` event has this shape:
{
"operationId": "/fs/readFile",
"input": { ... },
"auth_token": "alk_..." // optional — see Identity Resolution below
"auth_token": "alk_...", // optional — see Identity Resolution below
"forwarded_for": { // optional (ADR-032) — present when a hub forwards a call
"id": "alice",
"scopes": ["fs:read", "docker:start"],
"resources": {}
}
}
```
- `operationId` — the operation to invoke, **with a leading slash** on the wire (e.g., `/fs/readFile`, `/agent/chat`, `/services/list`). This is the display form of the operation name. The registry stores names without the leading slash (`fs/readFile` — see [operation-registry.md](operation-registry.md#operationspec)); the wire format adds it. The `CallAdapter` strips the leading slash before registry lookup.
- `input` — the operation input, matching the operation's `input_schema` (JSON Schema). Always a `serde_json::Value`.
- `auth_token` — optional. If present, the `CallAdapter` resolves it via `IdentityProvider::resolve_from_token()` and the resulting `Identity` takes precedence over the connection-level identity for this request. See [Identity Resolution](#authcontext-and-identity-resolution) below.
- `forwarded_for` — optional (ADR-032). Present when a `from_call` forwarding handler propagates the originator's identity to a spoke. Carries a serialized `Identity` (id, scopes, resources) — the end user the hub authenticated. **Metadata only**`AccessControl::check` never reads it; the spoke authorizes the hub (its direct caller), not the end user. The hub may set `forwarded_for: None` if it doesn't want to disclose the originator. See [ADR-032](../../decisions/032-forwarded-for-identity.md).
The `call.requested` payload does **not** carry an abort policy field. The abort policy (`abort-dependents` vs `continue-running`, ADR-016) is set on `OperationContext` and propagated through `OperationEnv::invoke()` — the composing handler decides the child's policy, not the wire caller. See [Abort Cascade and Nested Calls](#abort-cascade-and-nested-calls) below.
@@ -283,7 +294,7 @@ The `payload` field of `EventEnvelope` has a different shape per event type:
| Event | `payload` shape |
|-------|----------------|
| `call.requested` | `{ "operationId": "/fs/readFile", "input": {...}, "auth_token": "alk_..." (optional) }` |
| `call.requested` | `{ "operationId": "/fs/readFile", "input": {...}, "auth_token": "alk_..." (optional), "forwarded_for": { "id": "...", "scopes": [...], "resources": {} } (optional, ADR-032) }` |
| `call.responded` | `{ "output": <Value> }` — the operation's output, matching `output_schema` |
| `call.completed` | `{}` — empty object (subscription stream end signal) |
| `call.aborted` | `{}` — empty object (cancellation signal; the `id` identifies which request) |
@@ -429,6 +440,7 @@ fn build_root_context(
request_id: String,
operation_name: &str, // looked up in registry for the registration bundle
identity: Option<Identity>, // resolved per-request above (caller's identity)
forwarded_for: Option<Identity>, // from call.requested.forwarded_for (ADR-032)
) -> OperationContext {
let registration = self.registry.registration(operation_name);
OperationContext {
@@ -440,18 +452,27 @@ fn build_root_context(
// This is on the context for PROPAGATION to children via invoke(),
// not for the root's own ACL (which uses identity above).
handler_identity: registration.composition_authority.clone(),
// Forwarded-for identity (ADR-032): the originator when this call was
// forwarded by a from_call handler. Metadata only — AccessControl::check
// never reads it; ACL always authorizes `identity` (the direct caller).
// None when the call wasn't forwarded or the forwarder chose not to
// propagate it. Populated from the wire call.requested.forwarded_for
// field; NOT inherited by composed children (wire-ingress only).
forwarded_for,
capabilities: registration.capabilities.clone(), // from the registration bundle
metadata: HashMap::new(), // fresh per request
deadline: Some(Instant::now() + self.default_timeout), // root deadline (W7)
scoped_env: registration.scoped_env.clone()
.unwrap_or_else(ScopedOperationEnv::empty), // from the bundle, empty for leaves
// Per-call env composition (ADR-024): the root env is a composite
// of the curated base + this connection's imported-ops overlay +
// the active session overlay (if any). The CallAdapter builds this
// Per-call env composition (ADR-024 + ADR-029): the root env is a
// PeerCompositeEnv — the curated base + this connection's imported-
// ops overlay (peer-keyed in the head's aggregation env, ADR-029 §1)
// + the active session overlay (if any). The CallAdapter builds this
// composite per incoming call — same shape as per-call identity
// resolution via IdentityProvider. Handlers call env.invoke();
// resolution via IdentityProvider. Handlers call env.invoke() (peer-
// agnostic) or env.invoke_peer(peer, ...) (peer-specific, ADR-029 §2);
// the composite routes to the right overlay.
env: self.compose_root_env(/* connection, session */),
env: self.compose_root_env(/* peer_id, connection_overlay, session */),
abort_policy: AbortPolicy::default(), // abort-dependents (ADR-016 Decision 6)
internal: false, // external call — ACL against caller identity
}
@@ -460,7 +481,7 @@ fn build_root_context(
The `internal: false` here is what makes a wire call a wire call — ACL checks against the caller's resolved `identity`. When a handler subsequently calls `context.env.invoke(...)`, the `OperationEnv::invoke()` path (see [operation-registry.md](operation-registry.md#operationenv)) constructs a nested `OperationContext` with `internal: true`, switching authority to `handler_identity`. The two construction paths — `CallAdapter` for wire-originated, `OperationEnv::invoke()` for composition-originated — are the only places `internal` is set. Handlers cannot set it themselves (the field is module-private for writes — see [operation-registry.md](operation-registry.md#operationcontext) and ADR-015).
The per-call `env` composition (ADR-024) is the operation-dispatch analogue of the per-call identity resolution the CallAdapter already does via `IdentityProvider`. Both are integration-point patterns: the trait object owns the routing, the CallAdapter supplies the right sources per call. A connection's imported-ops overlay is part of the root env only for calls arriving on that connection; a session overlay is part of the root env only when a session is active. See ADR-024.
The per-call `env` composition (ADR-024 + ADR-029) is the operation-dispatch analogue of the per-call identity resolution the CallAdapter already does via `IdentityProvider`. Both are integration-point patterns: the trait object owns the routing, the CallAdapter supplies the right sources per call. A connection's imported-ops overlay is part of the root env only for calls arriving on that connection — and on a head node with multiple worker connections, the overlays are peer-keyed (`PeerCompositeEnv`, ADR-029 §1); a session overlay is part of the root env only when a session is active. See ADR-024, ADR-029, and the `PeerCompositeEnv` sketch in [operation-registry.md](operation-registry.md#operationenv).
### ResponseEnvelope
@@ -539,6 +560,7 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction. Client/adapter surface specced in [client-and-adapters.md](client-and-adapters.md) |
| Handler registration, provenance, and composition authority | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
| Peer-graph routing model (supersedes ADR-028) | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
| Operation error schemas | [ADR-023](../../decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details` |
## Open Questions
@@ -552,16 +574,45 @@ See [open-questions.md](../../open-questions.md) for full details.
- **OQ-25** (dissolved by ADR-029): `remote_safe` marking shape — moot;
`remote_safe`/`trusted_peer` retired; peer authorization is
`AccessControl::check(peer_identity)`.
- **OQ-26..29** (OQ-26/27/29 open two-way; OQ-28 cross-peer dissolved / same-peer stays):
`OperationAdapter` error type, `from_call` re-import trigger, `from_call`
namespace collision, `CallClient` TLS client-auth. See
[client-and-adapters.md](client-and-adapters.md) and ADR-029.
- **OQ-30..32** (open): `PeerRef::Any` routing policy, `services/list-peers`
re-export semantics, multi-hop federation. See ADR-029.
- **OQ-26** (resolved): `OperationAdapter` error type — `AdapterError`
variants (`DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
`SamePeerCollision`); `#[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. 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). See
[client-and-adapters.md](client-and-adapters.md).
- **OQ-29** (resolved): `CallClient` TLS client-auth — wire quinn client-auth
(present Ed25519 key as raw public key client cert); key-type-aware server
cert verification; fingerprint normalization. See
[client-and-adapters.md](client-and-adapters.md).
- **OQ-30** (resolved): `PeerRef::Any` routing policy — insertion-order
first-match. See [client-and-adapters.md](client-and-adapters.md).
- **OQ-31** (resolved): `services/list-peers` re-export semantics — opt-in;
`services/list` is "own ops only." See [client-and-adapters.md](client-and-adapters.md).
- **OQ-32** (open, feature extension): Multi-hop federation — the one-hop
model is the architectural commitment; multi-hop is a feature extension
that doesn't break downstream. See [client-and-adapters.md](client-and-adapters.md).
- **OQ-33** (resolved by ADR-030): `PeerId` source — `Identity.id` from
`IdentityProvider` resolution (= `PeerEntry.peer_id`, stable across key
rotation), not a connection-assigned UUID.
- **OQ-34** (resolved by ADR-030 + ADR-033): Persistent peer registry —
the storage boundary is `core trait + in-memory default`; persistence
adapters are separate crates.
- **OQ-37** (resolved by ADR-034): X.509 outgoing-only case — three remote
roles named (public X.509 endpoint, transport relay, hub); pure-client
X.509 connections are not in the peer graph on the client side. See
[client-and-adapters.md](client-and-adapters.md).
## References
- [operation-registry.md](operation-registry.md) — OperationSpec, Handler, AccessControl, service discovery
- [client-and-adapters.md](client-and-adapters.md) — CallClient, from_call, OperationAdapter, peer-keyed composition env
- ADR-005: irpc as call protocol foundation
- ADR-012: Call protocol stream model
- ADR-029: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing)
- ADR-030: PeerEntry and Identity.id decoupling (`PeerId` source)
- ADR-032: Forwarded-for identity (`forwarded_for` on `call.requested` and `OperationContext`)
- ADR-034: Outgoing-only X.509 and the three peer roles
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`