docs(arch): hub-wiring cluster — aggregated env, peer_operations, from_call cleanup, alknet-hub crate spec

Three ADRs addressing gaps surfaced by the first hub consumer (alkapi):

- ADR-067: Aggregated peer-env wiring — Dispatcher::with_aggregated_env hook
  so compose_root_env reads a shared PeerCompositeEnv across all calls,
  not a fresh per-call one. The hub-defining gap (alkapi OQ-08 / G.1).

- ADR-068: PeerCompositeEnv::peer_operations override — adds
  list_operation_names() to OperationEnv, overrides on OverlayOperationEnv
  and PeerCompositeEnv. Fixes services/list-peers returning empty operation
  lists for non-local peers (alkapi G.6).

- ADR-069: from_call is a manual free function, not auto-wired — reverses
  the aspirational OQ-27 resolution to match the implementation. Cleans
  the 'v1 default' hedging language in ADR-017 and client-and-adapters.md
  (alkapi G.4).

New crate spec: alknet-hub — reusable hub pattern (aggregated env,
connection lifecycle, worker supervision with backoff, service discovery).

Spec fixes: builder API drift (with_local/with_local_streaming separation),
ScopedOperationEnv → ScopedPeerEnv type name, OQ count 51→54.

Three new OQs: OQ-52 (wait_for_close), OQ-53 (backoff defaults),
OQ-54 (inbound hook placement).
This commit is contained in:
glm-5.2 committed 2026-07-09 16:01:30 +00:00
1 parent 2282647f8b
commit 87b2c2a5ef
15 files changed
+1052 -47

No files matched your search

+1 -1
View File
@@ -58,7 +58,7 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
| OQ-19 | Session-scoped operation registries | resolved | Agent-written operations overlaid on curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Generalized by ADR-024 to cover connection-scoped overlays. |
| OQ-25 | ~~Remote-safe marking shape~~ | **dissolved** (ADR-029) | `remote_safe`/`trusted_peer` retired; peer authorization is `AccessControl::check(peer_identity)` |
| OQ-26 | OperationAdapter error type (AdapterError variants) | **resolved** | `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`; `#[non_exhaustive]` |
| OQ-27 | from_call re-import trigger | **resolved** | Auto-re-import on connection establishment; `refresh()` is a feature addition |
| OQ-27 | from_call re-import trigger | **resolved** | `from_call` is a manual free function; the assembly layer calls it after `connect()`. `refresh()` is a genuine feature addition. See ADR-069. |
| OQ-28 | from_call namespace collision | **resolved** | Same-peer collision = error; cross-peer dissolved by ADR-029 (separate sub-overlays) |
| OQ-29 | CallClient TLS client-auth | **resolved** | Wire quinn client-auth; key-type-aware server cert verification; fingerprint normalization |
| OQ-30 | `PeerRef::Any` routing policy | **resolved** | Insertion-order first-match; richer routing is a feature extension |
@@ -474,7 +474,7 @@ fn build_root_context(
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
.unwrap_or_else(ScopedPeerEnv::empty), // from the bundle, empty for leaves
// 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)
@@ -590,8 +590,9 @@ See [open-questions.md](../../open-questions.md) for full details.
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-27** (resolved): `from_call` re-import trigger — `from_call` is a manual
free function; the assembly layer calls it after `connect()`. See
[ADR-069](../../decisions/069-from-call-manual-free-function.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).
@@ -352,11 +352,14 @@ The flow (ADR-017 §3):
4. The caller registers the bundles via
`CallConnection::register_imported_all()`.
**Re-import on reconnection** (DC-2, OQ-27): `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 v1 default; explicit re-import via a
future `CallConnection::refresh()` is additive.
**Re-import on reconnection** (DC-2, OQ-27): `from_call` is a free function;
the assembly layer calls it after `connect()`. 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. A
`CallConnection::refresh()` method for mid-connection re-discovery is a
genuine feature addition — non-breaking, additive — if a deployment needs
manual re-discovery without drop-and-reconnect. See
[ADR-069](../../decisions/069-from-call-manual-free-function.md).
**Namespace collision** (DC-3, OQ-28): under the peer-graph model (ADR-029),
cross-peer collision dissolves — same name on different peers is fine (they
@@ -649,8 +652,10 @@ Based on the gap analysis and the downstream unblock chain:
holds a `PeerCompositeEnv` with `connections: HashMap<PeerId, Arc<dyn OperationEnv>>`,
not a singular connection overlay. `invoke_peer()` routes to the right peer
via `PeerRef::Specific` / `PeerRef::Any` (ADR-029 §1-2).
- **`from_call` re-import is auto-on-reconnect.** v1 default; the overlay is
per-connection so re-import is naturally scoped (DC-2, OQ-27).
- **`from_call` is a manual free function.** The assembly layer calls it after
`connect()`. The overlay is per-connection so re-import on reconnect is
naturally scoped (DC-2, OQ-27). See
[ADR-069](../../decisions/069-from-call-manual-free-function.md).
- **`from_call` namespace collision is same-peer only.** Cross-peer collision
dissolves (same name on different peers is fine — separate sub-overlays,
ADR-029 §5). Same-peer collision stays an error. `namespace_prefix` is
@@ -713,9 +718,10 @@ See [open-questions.md](../../open-questions.md) for full details.
- **OQ-26** (resolved): `AdapterError` variants — `DiscoveryFailed`,
`SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`
(replaces flat `Conflict`). `#[non_exhaustive]`.
- **OQ-27** (resolved): `from_call` re-import trigger — auto-re-import on
connection establishment. `CallConnection::refresh()` is a feature
addition, not an unmade decision.
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a manual
free function; the assembly layer calls it after `connect()`. A
`CallConnection::refresh()` method is a genuine feature addition —
non-breaking, additive. See [ADR-069](../../decisions/069-from-call-manual-free-function.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.
@@ -243,7 +243,7 @@ pub struct OperationContext {
/// Populated from the registration bundle's `scoped_env` (ADR-022).
/// The reachability check in `OperationEnv::invoke()` consults
/// `scoped_env.allows(&name)`. This is data, not a dispatch trait.
pub scoped_env: ScopedOperationEnv,
pub scoped_env: ScopedPeerEnv,
/// Composition dispatch trait. A handler calls `env.invoke(...)` to
/// compose child operations. This is `Arc<dyn OperationEnv>` (a trait
/// object), not a concrete struct — the trait-object design is what
@@ -303,7 +303,7 @@ impl OperationContext {
- `forwarded_for`: The original caller when this call was forwarded by a `from_call` handler (ADR-032). **Metadata only** — `AccessControl::check` never reads it; the ACL always authorizes the direct caller's `identity`. Handlers may read it for logging, auditing, per-user rate limiting, or application context. Populated from `call.requested.forwarded_for` by the dispatch path; set to `None` for composed children (wire-ingress only). The forwarder's claim, not a verified identity — a malicious hub can lie (same property as HTTP `X-Forwarded-For`). See ADR-032.
- `capabilities`: Outbound credentials the handler may use (decrypted API keys, scoped vault access) — see [Capability Injection](#capability-injection) below
- `metadata`: Request-scoped context (tracing IDs, connection info). **Must not hold secret material** — see ADR-014. **Does not propagate through `OperationEnv::invoke()`** — nested calls get fresh metadata. 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`.
- `scoped_env`: The reachability set — the operations this handler may compose. Populated from the registration bundle's `scoped_env` (ADR-022). The reachability check in `OperationEnv::invoke()` consults `scoped_env.allows(&name)`. This is *data* (a `ScopedOperationEnv` struct), not a dispatch trait. `None`/empty for leaves.
- `scoped_env`: The reachability set — the operations this handler may compose. Populated from the registration bundle's `scoped_env` (ADR-022). The reachability check in `OperationEnv::invoke()` consults `scoped_env.allows(&name)`. This is *data* (a `ScopedPeerEnv` struct), not a dispatch trait. `None`/empty for leaves.
- `env`: The composition dispatch trait (`Arc<dyn OperationEnv + Send + Sync>`). A handler calls `context.env.invoke(...)` to compose child operations. This is a trait object, not a concrete struct — the trait-object design enables registry layering (ADR-024): the CallAdapter composes the root env per call from the active layers (curated base + connection overlay + session overlay), and overlays wrap the base via trait layering. Same pattern as `IdentityProvider` (ADR-004). See ADR-024.
- `internal`: When `true`, this call originated from composition (a handler calling another operation via `OperationEnv`), not from a wire request. This switches the authority context: ACL runs against `handler_identity`, not `identity`. The `internal` field uses module-private construction — handlers construct `OperationContext` through `OperationEnv::invoke()` which sets `internal: true`, or through the `CallAdapter` dispatch path which sets `internal: false`. The field is not `pub` for writes; only `pub fn is_internal(&self) -> bool` is exposed for reads. See ADR-015.
@@ -433,24 +433,37 @@ impl CompositionAuthority {
- `scoped_env`: The set of operations this handler may reach via `env.invoke()`. `None` for leaves (empty env). The reachability control from ADR-015.
- `capabilities`: Outbound credentials (decrypted API keys, signing keys). Populated by the assembly layer from the vault at registration time. See [Capability Injection](#capability-injection).
The `OperationRegistryBuilder` provides a fluent API with convenience methods for common cases. The builder absorbs the `HandlerKind` wrapping internally — `.with_local()` and `.with_leaf()` take the raw `Handler` (or `StreamingHandler`) and wrap it in the right `HandlerKind` based on `spec.op_type` (ADR-049):
The `OperationRegistryBuilder` provides a fluent API with convenience methods for common cases. The builder validates handler kind against `spec.op_type` at registration time — `with_local` / `with_leaf` accept `Handler` (for `Query`/`Mutation` ops), `with_local_streaming` / `with_leaf_streaming` accept `StreamingHandler` (for `Subscription` ops). Passing a `StreamingHandler` to `with_local` or a `Handler` to `with_local_streaming` is a registration-time error:
```rust
// with_local: Local provenance, full bundle — all 5 args required.
// Accepts Handler (for Query/Mutation ops). Validates op_type at registration.
// with_local(spec, handler, composition_authority, scoped_env, capabilities)
// The builder inspects spec.op_type and wraps in HandlerKind::Once
// (Query/Mutation) or HandlerKind::Stream (Subscription) automatically.
// with_local_streaming: Local provenance, full bundle — all 5 args required.
// Accepts StreamingHandler (for Subscription ops). Validates op_type at registration.
// with_local_streaming(spec, streaming_handler, composition_authority, scoped_env, capabilities)
// with_leaf: Leaf provenance (default FromOpenAPI), no composition authority.
// Accepts Handler (for Query/Mutation ops).
// with_leaf(spec, handler, capabilities)
// with_leaf_streaming: Leaf provenance (default FromOpenAPI), no composition authority.
// Accepts StreamingHandler (for Subscription ops).
// with_leaf_streaming(spec, streaming_handler, capabilities)
// with_leaf_provenance / with_leaf_streaming_provenance: explicit provenance variant.
let registry = OperationRegistryBuilder::new()
// Built-in service discovery (Local, no composition — empty authority, empty env, empty caps)
.with_local(services_list_spec(), Arc::new(services_list_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
CompositionAuthority::none(), ScopedPeerEnv::empty(), Capabilities::new())
.with_local(services_schema_spec(), Arc::new(schema_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
CompositionAuthority::none(), ScopedPeerEnv::empty(), Capabilities::new())
// Agent handler (Local, Subscription — streams call.responded as the
// LLM generates tokens; builder wraps in HandlerKind::Stream)
.with_local(agent_chat_spec(), Arc::new(agent_chat_streaming_handler),
// LLM generates tokens; uses with_local_streaming for the StreamingHandler)
.with_local_streaming(agent_chat_spec(), Arc::new(agent_chat_streaming_handler),
CompositionAuthority::new("agent-chat", ["llm:call", "fs:read", "vastai:query"]),
ScopedOperationEnv::new(["fs/readFile", "vastai/listMachines", "llm/generate"]),
ScopedPeerEnv::new(["fs/readFile", "vastai/listMachines", "llm/generate"]),
Capabilities::new().with_api_key("google", google_api_key))
// Imported ops (leaves — no authority, no scoped env; capabilities for outbound HTTP)
.with_leaf(vastai_listMachines_spec(), Arc::new(vastai_handler), vastai_credentials)
@@ -621,7 +634,7 @@ impl OperationEnv for LocalOperationEnv {
abort_policy: policy, // Explicit policy (from invoke() default or invoke_with_policy)
deadline: parent.deadline, // Inherit parent's deadline (children don't get a fresh 30s)
scoped_env: registration.scoped_env.clone()
.unwrap_or_else(ScopedOperationEnv::empty), // Child's own scoped env (empty for leaves)
.unwrap_or_else(ScopedPeerEnv::empty), // Child's own scoped env (empty for leaves)
// Dispatch trait: the child inherits the parent's env (the same
// composite of curated base + active overlays). See ADR-024.
env: parent.env.clone(),
@@ -820,9 +833,9 @@ let vastai_credentials = Capabilities::new().with_http_token("vastai", vastai_to
let registry = OperationRegistryBuilder::new()
// Built-in service discovery (Local, no composition — empty caps)
.with_local(services_list_spec(), Arc::new(services_list_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
CompositionAuthority::none(), ScopedPeerEnv::empty(), Capabilities::new())
.with_local(services_schema_spec(), Arc::new(schema_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
CompositionAuthority::none(), ScopedPeerEnv::empty(), Capabilities::new())
// Agent handler (Local, Subscription — composes; streaming handler
// wrapped in HandlerKind::Stream by the builder per ADR-049)
.with(HandlerRegistration {
@@ -831,7 +844,7 @@ let registry = OperationRegistryBuilder::new()
provenance: OperationProvenance::Local,
composition_authority: Some(CompositionAuthority::new(
"agent-chat", ["llm:call", "fs:read", "vastai:query"])),
scoped_env: Some(ScopedOperationEnv::new(
scoped_env: Some(ScopedPeerEnv::new(
["fs/readFile", "vastai/listMachines", "llm/generate"])),
capabilities: Capabilities::new().with_api_key("google", google_api_key),
})
@@ -940,9 +953,10 @@ See [open-questions.md](../../open-questions.md) for full details.
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-27** (resolved): `from_call` re-import trigger — `from_call` is a manual
free function; the assembly layer calls it after `connect()`. A
`CallConnection::refresh()` method is a genuine feature addition —
non-breaking, additive. See [ADR-069](../../decisions/069-from-call-manual-free-function.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