feat(channels): implement the single-publish fixes — S-01..S-04 (reviews 010/011; ADR-047 am. 4, ADR-052, ADR-053, ADR-022 am.)

S-01 — the marked-pub open contract (ADR-047 amendment 4):
- ChannelCore::register_pub_openable + PubOpenHandler (the put path's
  handler returns its result for the merged single response; the
  wrapper awaits inline, teardown is EOF-driven, handler_task stays
  None); register_openable* refuses Pub-typed specs; the wrapper entry
  points refuse unmarked/non-Pub shapes
- the reserved input key `channel_id` (the initiator-allocated id):
  stripped pre-validation by resolve_sink_handler (the single source of
  truth, P-08), handed to the wrapper via OperationContext.metadata, a
  missing key rejected INVALID_INPUT; adopt parity enforced
  (ManagerError::InvalidAdopt) and adopt failures answer
  channel:open_failed / adopt_failed
- zero call.published on marked ops: the first publish chunk on a
  marked op is rejected loudly (channel:publish_chunks_rejected) in
  both dispatch loops; the handler is never fed
- relay put-translation replaces RelayError::PubTypedOpen: hub
  allocates odd on the producer leg, opens the spoke op in the
  background (put-acks-in-result), one-direction pump, response-mirror
  with the producer's reserved key stripped
- ChannelClient::open_pub_channel + PubOpenReply (the initiator-
  allocates put open capability)

S-02 — channel/control implemented (ADR-052): establisher-contributed
ChannelControl (Establishment::with_control), manager-owned registry,
teardown removals, channel:no_control_handle capability answer.

S-03 — channel/resources/subscribe implemented (ADR-053): the
ResourceEnumerators registry (shared per peer), snapshot-then-changes
enumerators, scan-once aggregation, the empty seed; the
always-failing one-shot error stream removed.

S-04 — op/register forward-through (ADR-022 amendment): announced
unmarked Sub/Pub register the real streaming/sink forwarding shapes;
marked announcements refused with MARKED_SPEC_UNSUPPORTED; marked
specs refuse the generic registration path and register_imported
(Result-returning now).

Guard (review 010 action 3): tests/stub_guard.rs greps src/ for
not_implemented; the four pinned stub tests are deleted and the
marked-spec registration refusals are pinned by tests.

Verified: cargo test (696 + guard), clippy --all-targets -D warnings,
fmt --check, fuzz corpus replay.
This commit is contained in:
glm-5.3-flash committed 2026-10-13 09:00:21 +00:00
1 parent df4d4d3a9e
commit 944c300c17
18 files changed
+2418 -326

No files matched your search

+130 -2
View File
@@ -70,8 +70,8 @@ impl ChannelOpenError {
/// The establishment-failure reason code — `details.reason` of a /// The establishment-failure reason code — `details.reason` of a
/// `channel:open_failed` error (`dial_failed`, `unknown_resource`, /// `channel:open_failed` error (`dial_failed`, `unknown_resource`,
/// `resource_shortage`, `handler_error`, `timeout`). `None` for /// `resource_shortage`, `handler_error`, `timeout`, `adopt_failed`).
/// any other failure shape. /// `None` for any other failure shape.
pub fn establishment_reason(&self) -> Option<&str> { pub fn establishment_reason(&self) -> Option<&str> {
match self { match self {
ChannelOpenError::CallFailed { error } ChannelOpenError::CallFailed { error }
@@ -88,6 +88,30 @@ impl ChannelOpenError {
} }
} }
/// The awaited single merged response of a put open
/// ([`ChannelClient::open_pub_channel`], ADR-047 amendment 4 §4.9 —
/// the put ack rides in the result, ADR-047 amendment 4 §4.5). The
/// putter writes bytes on the returned `send` half (or spawns pumps)
/// and awaits this for the put's wire contract (e.g. alkblobs' put
/// replying `{ token, digest }`). Await this after (or alongside) the
/// byte stream, never before it — the response cannot exist before the
/// byte stream completes.
pub struct PubOpenReply {
rx: tokio::sync::oneshot::Receiver<Result<Value, ChannelOpenError>>,
}
impl PubOpenReply {
/// The putter-side await: the merged response output (`Ok`);
/// `Err` carries the wire `CallError` verbatim
/// (`channel:open_failed` + `details.reason` per ADR-049 §3).
pub async fn resolve(self) -> Result<Value, ChannelOpenError> {
match self.rx.await {
Ok(result) => result,
Err(_) => Err(ChannelOpenError::MissingChannelId),
}
}
}
/// The client-side handle for a channels connection. Constructed via /// The client-side handle for a channels connection. Constructed via
/// [`ChannelClient::from_connection`] from an established /// [`ChannelClient::from_connection`] from an established
/// `Connection`. Holds the `ChannelManager` (for the demux/mux state) /// `Connection`. Holds the `ChannelManager` (for the demux/mux state)
@@ -382,6 +406,110 @@ impl ChannelClient {
.map(|(send, recv)| (channel_id, out, send, recv)) .map(|(send, recv)| (channel_id, out, send, recv))
} }
/// The putter-facing open (ADR-047 amendment 4 §4.9 — the
/// initiator-allocates capability `open_channel`/`open_channel_with_reply`
/// cannot provide, because those await the open reply before
/// returning the halves and a put's bytes flow before the reply
/// exists, review 011 §2.7): allocates the connect-side odd id
/// locally, inserts the reserved input key, sends the open op in a
/// background task, and returns **immediately** — the caller
/// writes bytes on `send` without waiting for anything (the
/// put ack rides in the merged response; ADR-047 amendment 4 §4.5).
///
/// Returns `(channel_id, send, recv, reply)`; `reply` is the
/// awaited single merged response — `Ok(Value)` (the full output,
/// e.g. `{ token, digest }`) or the wire error. On a failed open
/// the local adopt entry is torn down and `send`/`recv` are
/// already EOF'd (teardown); the caller still holds them but every
/// write is EOF-shaped. The `recv` half is the (bidirectional)
/// channel's read side — a put that also receives reads it.
pub async fn open_pub_channel(
&self,
operation_id: &str,
input: Value,
alpn: &str,
) -> Result<(u32, MpscSendStream, MpscRecvStream, PubOpenReply), ChannelOpenError> {
use crate::registry::spec::RESERVED_OPEN_INPUT_KEY;
let (channel_id, send, recv) = self
.manager
.open_channel(
alpn,
// The opener ledger is inert on the connect side (it
// does not enforce the per-identity cap, ADR-041) —
// the record is observability only.
"channel-client",
None,
)
.await
.map_err(|e| match e {
crate::channels::manager::ManagerError::TooManyChannels { count, max } => {
ChannelOpenError::CallFailed {
error: crate::protocol::wire::CallError::new(
"channel:too_many_channels",
format!("per-connection channel cap exceeded: {count}/{max}"),
false,
)
.with_details(serde_json::json!({ "count": count, "max": max })),
}
}
other => ChannelOpenError::CallFailed {
error: crate::protocol::wire::CallError::new(
"channel:allocation_failed",
format!("channel allocation failed: {other}"),
false,
),
},
})?;
let mut op_input = input;
match op_input.as_object_mut() {
Some(obj) => {
obj.insert(
RESERVED_OPEN_INPUT_KEY.to_string(),
serde_json::json!(channel_id),
);
}
None => {
return Err(ChannelOpenError::CallFailed {
error: crate::protocol::wire::CallError::invalid_input(
"marked pub open op input must be an object (ADR-047 amendment 4 §4.3)",
),
});
}
}
let call_conn = {
let guard = self.call_connection.lock().await;
guard.as_ref().map(Arc::clone)
};
let manager = self.manager.clone();
let op_id = operation_id.to_string();
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let response = match &call_conn {
Some(conn) => conn.call(&op_id, op_input).await,
None => ResponseEnvelope::error(
"channel-client",
crate::protocol::wire::CallError::internal("channel client closed"),
),
};
// A failed open tears the allocator-side entry down: the
// responder refused, the channel is dead, and the local
// half must not linger in the connection's channel map.
if response.result.is_err() {
let _ = manager.teardown_channel(channel_id);
}
let reply = match response.result {
Ok(out) => Ok(out),
Err(error) => Err(ChannelOpenError::CallFailed { error }),
};
let _ = reply_tx.send(reply);
});
Ok((channel_id, send, recv, PubOpenReply { rx: reply_rx }))
}
/// Take the `CallConnection` — used by the consumer to register /// Take the `CallConnection` — used by the consumer to register
/// imported ops (`from_call`) on the connection's overlay. After /// imported ops (`from_call`) on the connection's overlay. After
/// this, `call_open_op` returns an error (the connection is owned /// this, `call_open_op` returns an error (the connection is owned
+92
View File
@@ -62,6 +62,7 @@ const TTY_OP: &str = "channels/tty/sub";
const TTY_ALPN: &str = "alk/tty"; const TTY_ALPN: &str = "alk/tty";
const ALPN: &str = "alk/tunnel"; const ALPN: &str = "alk/tunnel";
const PLAIN_OP: &str = "fs/read"; const PLAIN_OP: &str = "fs/read";
const PUT_OP: &str = "channels/put/bulk";
fn identity(id: &str, scopes: &[&str]) -> Identity { fn identity(id: &str, scopes: &[&str]) -> Identity {
Identity { Identity {
@@ -256,6 +257,49 @@ fn make_spoke_install(
) )
.expect("spoke registers the tty open op"); .expect("spoke registers the tty open op");
// The marked pub openable (ADR-047 amendment 4 §4.7's
// relay-translation gate): a put handler that consumes the
// adopted channel's data plane to EOF and replies with the
// digest over the streamed bytes.
let put_spec = OperationSpec::new(
PUT_OP,
OperationType::Pub,
Visibility::External,
json!({ "type": "object" }),
json!({
"type": "object",
"properties": {
"channel_id": { "type": "integer" },
"digest": { "type": "string" }
}
}),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/blobs"));
let put_handler: crate::channels::operations::PubOpenHandler =
Arc::new(move |_input, _plan, channel_conn, _auth| {
Box::pin(async move {
let mut bidi = channel_conn.accept_bi().await.map_err(|_| {
crate::protocol::wire::CallError::internal(
"spoke put: the data plane never opened",
)
})?;
let mut buf = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut bidi, &mut buf)
.await
.map_err(|e| {
crate::protocol::wire::CallError::internal(format!(
"spoke put read failed: {e}"
))
})?;
Ok(json!({ "digest": format!("spoke-{}", buf.len()) }))
})
});
core.register_pub_openable(put_spec, None, put_handler, &registry, auth.clone(), None)
.expect("spoke registers the marked pub openable");
registry registry
.register(HandlerRegistration::new( .register(HandlerRegistration::new(
plain_spec(PLAIN_OP), plain_spec(PLAIN_OP),
@@ -740,3 +784,51 @@ async fn gate2_tty_standard_shape_relays_with_no_regression() {
"the tty relay's ledger decremented" "the tty relay's ledger decremented"
); );
} }
// --- gate: the pub-openables relay translation (ADR-047 amendment 4 §4.7) ---
/// A hub translating a pub openable end-to-end (review 011 §3): the
/// browser's put via `open_pub_channel` on the consumer leg; bytes
/// flow browser → hub → spoke (the hub never parses them); the
/// spoke's single response loses its reserved key and its result
/// fields ride the consumer reply; the consumer replies exactly once
/// with the hub-allocated consumer-leg id.
#[tokio::test]
async fn gate_pub_relay_put_end_to_end() {
let h = start().await;
let (id, mut send, _recv, reply) = h
.consumer
.open_pub_channel(PUT_OP, json!({ "path": "g" }), "alk/blobs")
.await
.expect("the put opens through the relayed pub openable");
send.write_all(b"payload!").await.expect("stream the bytes");
drop(send);
let merged = reply
.resolve()
.await
.expect("the put's single merged response");
assert_eq!(
merged["digest"],
json!("spoke-8"),
"the spoke's result rode the relay (the producer's reserved key stripped)"
);
assert_eq!(
merged["channel_id"],
json!(id),
"the reply carries the hub-allocated consumer-leg id"
);
assert_eq!(id % 2, 1, "the browser (connect side) allocated odd");
wait_for(
|| data_channels(&h.hub_consumer_manager) == 0,
"the consumer-leg put channel reclaimed at the wrapper's completion teardown",
)
.await;
wait_for(
|| data_channels(&h.hub_producer_manager) == 0,
"the producer-leg put channel reclaimed at the plan's drop guard",
)
.await;
}
+17 -9
View File
@@ -668,7 +668,11 @@ async fn template_plain_bundle_registration_failure_is_loud_at_install() {
/// call to any op — including the bootstrap discovery ops — does not /// call to any op — including the bootstrap discovery ops — does not
/// resolve). Never a silent stub. /// resolve). Never a silent stub.
#[tokio::test] #[tokio::test]
async fn template_pub_typed_marked_spec_is_loud_at_install() { async fn template_pub_typed_marked_spec_registers_through_the_relay() {
// ADR-047 amendment 4 §4.7: the put-forwarding translation replaced
// the `PubTypedOpen` assembly error — a pub-typed marked spec in
// the stash composes through `register_pub_openable` and the op
// re-exposes on the consumer leg.
let pub_bundle = HandlerRegistration::new( let pub_bundle = HandlerRegistration::new(
relay_spec( relay_spec(
"channels/x/pub", "channels/x/pub",
@@ -685,15 +689,19 @@ async fn template_pub_typed_marked_spec_is_loud_at_install() {
); );
let imports = HubLegImports::from_bundles(vec![pub_bundle]); let imports = HubLegImports::from_bundles(vec![pub_bundle]);
let consumer = start_hub(None, Some(imports), None, false).await; let consumer = start_hub(None, Some(imports), None, false).await;
let response = tokio::time::timeout( let response = consumer.call_open_op("services/list", json!({})).await;
std::time::Duration::from_secs(3), let out = response
consumer.call_open_op("services/list", json!({})), .result
) .expect("the leg dispatches; the op re-exposed");
.await; let names: Vec<&str> = out["operations"]
.as_array()
.expect("operations")
.iter()
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
.collect();
assert!( assert!(
response.is_err(), names.contains(&"channels/x/pub"),
"the leg's channel 0 never dispatches after the loud assembly error, got {:?}", "the pub openable is re-exposed through the relay, got: {names:?}"
response
); );
} }
+76
View File
@@ -18,6 +18,9 @@ use tokio::sync::mpsc;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tracing::debug; use tracing::debug;
use crate::protocol::wire::CallError;
use serde_json::Value;
use super::mux::{MuxHandle, OpenerLedger}; use super::mux::{MuxHandle, OpenerLedger};
use super::reassembly::{MpscRecvStream, MpscSendStream, DEFAULT_BUFFER_CAP}; use super::reassembly::{MpscRecvStream, MpscSendStream, DEFAULT_BUFFER_CAP};
@@ -38,6 +41,15 @@ pub enum ChannelSide {
/// `ChannelLifecyclePolicy` (ADR-041, amended by ADR-047 §7). /// `ChannelLifecyclePolicy` (ADR-041, amended by ADR-047 §7).
pub const DEFAULT_MAX_CHANNELS: usize = 256; pub const DEFAULT_MAX_CHANNELS: usize = 256;
/// The per-channel out-of-band control callback (ADR-052): received
/// from the open-op establisher (`Establishment::with_control`),
/// installed by the open-op wrapper under the channel's id, routed to
/// by `channel/control`, removed by every teardown path. The manager
/// stays blind to what the closure does (ADR-039).
pub type ChannelControl = Arc<
dyn Fn(Value) -> futures::future::BoxFuture<'static, Result<Value, CallError>> + Send + Sync,
>;
/// The per-channel state — the reassembly sender (the demux feeds chunk /// The per-channel state — the reassembly sender (the demux feeds chunk
/// payloads into it; the handler reads them out via `MpscRecvStream`), /// payloads into it; the handler reads them out via `MpscRecvStream`),
/// the handler task handle, and the ALPN (for observability). /// the handler task handle, and the ALPN (for observability).
@@ -62,6 +74,13 @@ pub enum ManagerError {
UnknownChannel(u32), UnknownChannel(u32),
#[error("channel already exists: {0}")] #[error("channel already exists: {0}")]
ChannelExists(u32), ChannelExists(u32),
#[error(
"channel {channel_id} is outside the remote side's allocation range (expected {expected})"
)]
InvalidAdopt {
channel_id: u32,
expected: &'static str,
},
} }
/// The shared state for a channels connection. `Clone` (cheap — `Arc` /// The shared state for a channels connection. `Clone` (cheap — `Arc`
@@ -101,6 +120,10 @@ struct Inner {
/// drains them into the new receiver. `clear_all` drops the map. /// drains them into the new receiver. `clear_all` drops the map.
early_arrivals: Mutex<HashMap<u32, VecDeque<Bytes>>>, early_arrivals: Mutex<HashMap<u32, VecDeque<Bytes>>>,
early_arrival_count: AtomicU64, early_arrival_count: AtomicU64,
/// Per-channel out-of-band control callbacks (ADR-052). Installed
/// by the open-op wrapper from the establisher's
/// `Establishment::with_control`; removed by every teardown path.
control_handles: Mutex<HashMap<u32, ChannelControl>>,
} }
/// The per-channel cap on parked early-arrival chunks (`route_payload` /// The per-channel cap on parked early-arrival chunks (`route_payload`
@@ -151,6 +174,7 @@ impl ChannelManager {
dropped_unknown_chunks: AtomicU64::new(0), dropped_unknown_chunks: AtomicU64::new(0),
early_arrivals: Mutex::new(HashMap::new()), early_arrivals: Mutex::new(HashMap::new()),
early_arrival_count: AtomicU64::new(0), early_arrival_count: AtomicU64::new(0),
control_handles: Mutex::new(HashMap::new()),
}), }),
} }
} }
@@ -317,6 +341,32 @@ impl ChannelManager {
alpn: impl Into<String>, alpn: impl Into<String>,
handler_task: Option<JoinHandle<()>>, handler_task: Option<JoinHandle<()>>,
) -> Result<(MpscSendStream, MpscRecvStream), ManagerError> { ) -> Result<(MpscSendStream, MpscRecvStream), ManagerError> {
// The adopt range is the remote side's allocation range (the
// odd/even split, ADR-047 §5): the connect side allocates odd,
// so the accept side adopts odd; the accept side allocates
// even, so the connect side adopts even. Adopting inside the
// local range would collide with this side's own monotonic
// allocator shortly after. The duplicate check comes first —
// adopting an id this side already holds returns
// `ChannelExists` and must never touch the live channel (the
// 0.8.1 regression pin).
let expected = match self.inner.side {
ChannelSide::Connect => "even (the accept side's allocated ids; ADR-047 §5)",
ChannelSide::Accept => "odd (the connect side's allocated ids; ADR-047 §5)",
};
if self.inner.channels.lock().contains_key(&channel_id) {
return Err(ManagerError::ChannelExists(channel_id));
}
let parity_ok = match self.inner.side {
ChannelSide::Connect => channel_id.is_multiple_of(2),
ChannelSide::Accept => !channel_id.is_multiple_of(2),
};
if !parity_ok {
return Err(ManagerError::InvalidAdopt {
channel_id,
expected,
});
}
let alpn = alpn.into(); let alpn = alpn.into();
let send = self let send = self
@@ -381,6 +431,30 @@ impl ChannelManager {
} }
} }
/// Install the out-of-band control callback for a channel
/// (ADR-052). Called by the open-op wrapper from the establisher's
/// `Establishment::with_control`. Replaces an existing callback for
/// the id (the establisher runs once per channel — replacement is
/// not an expected path; the last call wins).
pub fn set_control_handle(&self, channel_id: u32, control: ChannelControl) {
self.inner
.control_handles
.lock()
.insert(channel_id, control);
}
/// The control callback for a channel, for routing (the
/// `channel/control` handler). `None` when the channel's ALPN
/// registered no control surface (the op answers
/// `channel:no_control_handle`).
pub fn control_handle(&self, channel_id: u32) -> Option<ChannelControl> {
self.inner.control_handles.lock().get(&channel_id).cloned()
}
fn remove_control_handle(&self, channel_id: u32) {
self.inner.control_handles.lock().remove(&channel_id);
}
/// Install channel 0 (pre-negotiated as `alk/call`, ADR-036). /// Install channel 0 (pre-negotiated as `alk/call`, ADR-036).
/// Channel 0 is special only in that it's pre-allocated (by /// Channel 0 is special only in that it's pre-allocated (by
/// `channels-call`); the `ChannelsAdapter` hands the resulting /// `channels-call`); the `ChannelsAdapter` hands the resulting
@@ -530,6 +604,7 @@ impl ChannelManager {
// Dropping `state.demux_sender` signals EOF to the // Dropping `state.demux_sender` signals EOF to the
// handler's read half (REQ-CH-02). // handler's read half (REQ-CH-02).
drop(state.demux_sender); drop(state.demux_sender);
self.remove_control_handle(channel_id);
Ok(state.handler_task) Ok(state.handler_task)
} }
None => Err(ManagerError::UnknownChannel(channel_id)), None => Err(ManagerError::UnknownChannel(channel_id)),
@@ -550,6 +625,7 @@ impl ChannelManager {
task.abort(); task.abort();
} }
} }
self.inner.control_handles.lock().clear();
// The early-arrival buffers die with the connection too. // The early-arrival buffers die with the connection too.
self.inner.early_arrivals.lock().clear(); self.inner.early_arrivals.lock().clear();
// The opener ledger has the opener PeerIds. // The opener ledger has the opener PeerIds.
+4
View File
@@ -31,6 +31,9 @@
//! as amended by ADR-051): translate hop + `pump_bidi` byte-forward //! as amended by ADR-051): translate hop + `pump_bidi` byte-forward
//! hop for from_call-imported marked specs re-exposed on a hub's //! hop for from_call-imported marked specs re-exposed on a hub's
//! consumer-leg channel-0 registry. //! consumer-leg channel-0 registry.
//! - [`resource`]: `ResourceEnumerators` — the enumerator registry
//! behind `channel/resources/subscribe`'s live subscription
//! (ADR-053).
//! - [`hub_leg`]: `HubLegTemplate` — the hub-leg install template //! - [`hub_leg`]: `HubLegTemplate` — the hub-leg install template
//! (ADR-051 §5): fork + generic channel ops + bootstrap discovery + //! (ADR-051 §5): fork + generic channel ops + bootstrap discovery +
//! plain bundles + relay openables + serving identity, composed into //! plain bundles + relay openables + serving identity, composed into
@@ -53,5 +56,6 @@ pub mod policy;
pub mod pump; pub mod pump;
pub mod reassembly; pub mod reassembly;
pub mod relay; pub mod relay;
pub mod resource;
pub mod source; pub mod source;
pub mod wire; pub mod wire;
+790 -237
View File
File diff suppressed because it is too large. Load diff
+447
View File
@@ -0,0 +1,447 @@
//! The marked-pub open contract gates (review 011 §3; ADR-047
//! amendment 4). The put path end-to-end over a real channels
//! connection: response merge order, the digest-in-result wire
//! contract, publish-chunk rejection, establishment failure
//! (teardown + ledger + EOF-discarded racing bytes), the reserved-key
//! rejections, and adopt parity.
use crate::channels::adapter::ChannelsAdapter;
use crate::channels::client::ChannelClient;
use crate::channels::manager::ChannelManager;
use crate::channels::operations::{ChannelCore, Establishment, OpenEstablisher, PubOpenHandler};
use crate::channels::policy::NoCap;
use crate::core::auth::{AuthContext, Identity, IdentityProvider};
use crate::core::types::Connection;
use crate::protocol::connection::{split_single_stream, CallConnection};
use crate::protocol::dispatch::Dispatcher;
use crate::protocol::wire::CallError;
use crate::registry::discovery::install_bootstrap_discovery;
use crate::registry::registration::OperationRegistry;
use crate::registry::spec::{
AccessControl, ChannelOpenSpec, OperationSpec, OperationType, Visibility,
RESERVED_OPEN_INPUT_KEY,
};
use serde_json::{json, Value};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};
const TEST_ADDR: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321);
const PUT_OP: &str = "channels/blobs/put";
struct NoopIdProvider;
impl IdentityProvider for NoopIdProvider {
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, _: &crate::core::auth::AuthToken) -> Option<Identity> {
None
}
}
fn pub_put_spec() -> OperationSpec {
OperationSpec::new(
PUT_OP,
OperationType::Pub,
Visibility::External,
json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
}),
json!({
"type": "object",
"properties": {
"channel_id": { "type": "integer" },
"token": { "type": "string" },
"digest": { "type": "string" },
"priority": { "type": "string" }
}
}),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/blobs"))
}
/// The put handler: consume the adopted channel's data plane to EOF,
/// return `{ digest, priority }` — the unknown-length put's wire
/// contract (the result computed from the streamed bytes). The
/// `priority` field exists to pin the merge order (the handler's
/// result wins the collision; ADR-047 amendment 4 §4.4).
fn digest_handler() -> PubOpenHandler {
Arc::new(move |_input, _plan, channel_conn, _auth| {
Box::pin(async move {
let mut bidi = channel_conn
.accept_bi()
.await
.map_err(|_| CallError::internal("put handler: the data plane never opened"))?;
let mut buf = Vec::new();
bidi.read_to_end(&mut buf)
.await
.map_err(|e| CallError::internal(format!("put handler read failed: {e}")))?;
Ok(json!({
"digest": format!("len-{}", buf.len()),
"priority": "handler",
}))
})
})
}
/// The establisher: contributes an informative-in-result ack field
/// (`token`) and its own `priority` (the ack — which the handler's
/// result beats on collision).
fn token_establisher() -> OpenEstablisher {
Arc::new(|_input: Value, _auth| {
Box::pin(async {
Ok(Establishment::default().with_reply_field("token", json!("tok-1")))
.map(|e: Establishment| e.with_reply_field("priority", json!("ack")))
})
})
}
/// Build the put-serving side over the duplex's server end: register
/// the marked pub openable (with the given establisher + handler) and
/// run the channel-0 dispatch loop. Returns the captured server
/// manager (the ledger/teardown observability point).
async fn serve_put_side(
server_end: DuplexStream,
establisher: OpenEstablisher,
handler: PubOpenHandler,
) -> Arc<tokio::sync::Mutex<Option<ChannelManager>>> {
let captured = Arc::new(tokio::sync::Mutex::<Option<ChannelManager>>::new(None));
let captured_for_hook = Arc::clone(&captured);
let install_hook: crate::channels::adapter::InstallChannelZero =
Arc::new(move |manager, channel0_conn, auth| {
let establisher = establisher.clone();
let handler = Arc::clone(&handler);
let manager = manager.clone();
let captured = Arc::clone(&captured_for_hook);
tokio::spawn(async move {
let _ = captured.lock().await.replace(manager.clone());
let Ok(channel0_bidi) = channel0_conn.accept_bi().await else {
return;
};
let (writer, reader) = split_single_stream(channel0_bidi);
let core = ChannelCore::new(manager, Arc::new(NoCap));
let registry = Arc::new(OperationRegistry::new());
install_bootstrap_discovery(&registry).expect("bootstrap discovery");
core.register_pub_openable(
pub_put_spec(),
Some(establisher),
handler,
&registry,
auth.clone(),
None,
)
.expect("register the marked pub openable");
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
Dispatcher::new(registry, provider)
.run_loop_single_stream(call_connection, reader, writer)
.await;
})
});
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let server_conn = Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let auth = AuthContext::anonymous(b"alk/channels");
tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
captured
}
#[tokio::test]
async fn put_e2e_merge_order_and_digest() {
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let _captured = serve_put_side(server_end, token_establisher(), digest_handler()).await;
let client = ChannelClient::from_connection(client_conn)
.await
.expect("channel client init");
{
// The known-length shape: offer + bytes → one response whose
// merge order is `{channel_id}` → ack fields → handler result.
let (_id, mut send, _recv, reply) = client
.open_pub_channel(PUT_OP, json!({ "path": "b/one" }), "alk/blobs")
.await
.expect("open_pub_channel");
send.write_all(b"hello").await.expect("stream the bytes");
drop(send);
let merged = reply
.resolve()
.await
.expect("the put resolves with the merged response");
let _ = _id;
assert_eq!(
merged["token"],
json!("tok-1"),
"the ack field rides the result"
);
assert_eq!(
merged["digest"],
json!("len-5"),
"the handler result rides the result"
);
assert_eq!(
merged["priority"],
json!("handler"),
"merge order: handler result wins the collision with the ack (§4.4)"
);
assert!(
merged["channel_id"].as_u64().is_some(),
"channel_id reserved"
);
}
{
// The unknown-length shape: larger than the reassembly buffer
// (crosses chunk boundaries); the handler's digest is computed
// over the streamed bytes.
let (_id2, send2, _recv2, reply2) = client
.open_pub_channel(PUT_OP, json!({ "path": "b/two" }), "alk/blobs")
.await
.expect("open_pub_channel");
let mut send2 = send2;
let chunk = vec![0xAB_u8; 4096];
for _ in 0..8 {
send2.write_all(&chunk).await.expect("stream a chunk");
}
drop(send2);
let merged2 = reply2.resolve().await.expect("unknown-length put");
assert_eq!(merged2["digest"], json!("len-32768"));
assert_eq!(merged2["token"], json!("tok-1"));
}
}
/// The happy-path put's ids: the putter allocates odd (its side is
/// Connect); the responder adopts; the reply carries the putter's id.
#[tokio::test]
async fn put_e2e_client_parity_is_odd() {
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let _captured = serve_put_side(server_end, token_establisher(), digest_handler()).await;
let client = ChannelClient::from_connection(client_conn)
.await
.expect("client");
let (id, mut send, _recv, reply) = client
.open_pub_channel(PUT_OP, json!({ "path": "b/odd" }), "alk/blobs")
.await
.expect("open_pub_channel");
send.write_all(b"zz").await.expect("stream");
drop(send);
let merged = reply.resolve().await.expect("put");
assert_eq!(
merged["channel_id"],
json!(id),
"the reply carries the putter's id"
);
assert_eq!(id % 2, 1, "the connect side allocates odd (ADR-047 §5)");
}
/// Publish-chunk rejection (§4.1): a marked op fed via the call-plane
/// publish path rejects loudly in the initiator's direction and the
/// handler never consumes a chunk.
#[tokio::test]
async fn put_rejects_publish_chunks_loudly() {
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let _captured = serve_put_side(server_end, token_establisher(), digest_handler()).await;
let client = ChannelClient::from_connection(client_conn)
.await
.expect("client");
let conn = Arc::clone(&client.take_call_connection().await.expect("call conn"));
let chunks = Box::pin(futures::stream::iter(vec![
json!({ "chunk": 1 }),
json!({ "chunk": 2 }),
]));
// The reserved key is present (this client misuses the wire
// contract — the open resolves, the chunks are what get rejected).
let response = conn
.publish_with_payload(
json!({ "operationId": PUT_OP, "input": { "path": "b/violating", RESERVED_OPEN_INPUT_KEY: 3 } }),
chunks,
)
.await;
let e = response.result.expect_err("chunks rejected loudly");
assert_eq!(e.code, crate::registry::spec::MARKED_PUB_CHUNKS_REJECTED);
// The put side's own single response may also arrive (the initiator
// already resolved with the rejection); the rejection is what the
// pending call carries — assert it was NOT a success-shaped result
// (done above by expect_err).
let _ = conn;
}
/// Establishment failure (ADR-049 §3 through the pub path): the reply
/// is `channel:open_failed` with the mapped reason; the channel is
/// torn down on both sides; racing bytes EOF-discard (the write
/// completes locally before the failure reply — the adopt entry is
/// gone afterward, ledger un-incremented).
#[tokio::test]
async fn put_establishment_failure_replies_open_failed_and_tears_down() {
use crate::channels::operations::EstablishmentError;
let failing: OpenEstablisher = Arc::new(|_input, _auth| {
Box::pin(async {
Err(EstablishmentError::UnknownResource {
message: "container not found".to_string(),
})
})
});
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let captured = serve_put_side(server_end, failing, digest_handler()).await;
let client = ChannelClient::from_connection(client_conn)
.await
.expect("client");
let (id, mut send, _recv, reply) = client
.open_pub_channel(PUT_OP, json!({ "path": "b/gone" }), "alk/blobs")
.await
.expect("open_pub_channel");
// Racing bytes: written before the failure reply arrives — the
// responder never adopted, so the bytes park and drop (EOF-shaped
// for the putter).
let _ = send.write_all(b"racing").await;
drop(send);
let error = reply
.resolve()
.await
.expect_err("the put resolves with the establishment failure");
assert_eq!(
error.establishment_reason(),
Some("unknown_resource"),
"the spoke establishment reason maps through (ADR-049 §3)"
);
assert_eq!(
error.call_error().map(|e| e.code.as_str()),
Some("channel:open_failed")
);
// The responder's side: no channel lingers (open_failed tears
// down; nothing was adopted anyway — but the client-side
// open-failure teardown removed the allocator-side entry).
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let manager = captured.lock().await.clone().expect("server manager");
assert!(
!manager.has_channel(id),
"a failed establishment leaves no channel (ADR-047 amendment 4 §4.4)"
);
assert_eq!(
manager.opener_ledger().take(id),
None,
"ledger un-incremented"
);
}
/// A marked pub op whose input lacks the reserved `channel_id` key is
/// rejected (`INVALID_INPUT`) — the initiator-allocated id is
/// mandatory (ADR-047 amendment 4 §4.3).
#[tokio::test]
async fn put_missing_reserved_key_is_rejected() {
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let _captured = serve_put_side(server_end, token_establisher(), digest_handler()).await;
let client = ChannelClient::from_connection(client_conn)
.await
.expect("client");
let response = client
.call_open_op(PUT_OP, json!({ "path": "b/no-id" }))
.await;
let e = response.result.expect_err("the reserved key is mandatory");
assert_eq!(e.code, "INVALID_INPUT");
assert!(
e.message.contains(RESERVED_OPEN_INPUT_KEY),
"the error names the key: {}",
e.message
);
}
/// Adopt parity (§4.4 + ADR-047 §5): a reserved id inside the
/// responder's OWN allocation range (even — an id the accept side
/// would allocate itself, violating the split) fails the open with
/// `adopt_failed`; racing bytes are discarded.
#[tokio::test]
async fn put_out_of_range_reserved_id_replies_adopt_failed() {
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let _captured = serve_put_side(server_end, token_establisher(), digest_handler()).await;
let client = ChannelClient::from_connection(client_conn)
.await
.expect("client");
let response = client
.call_open_op(
PUT_OP,
json!({ "path": "b/bad", RESERVED_OPEN_INPUT_KEY: 2 }),
)
.await;
let e = response
.result
.expect_err("the responder refuses its own range");
assert_eq!(e.code, "channel:open_failed");
assert_eq!(
e.details
.as_ref()
.and_then(|d| d.get("reason"))
.and_then(|r| r.as_str()),
Some("adopt_failed"),
"adoption failure → adopt_failed (ADR-047 amendment 4 §4.4): {e:?}"
);
}
/// The responder-side put handler that returns an error: the single
/// response IS that error (the wrapper passthrough; §4.4).
#[tokio::test]
async fn put_handler_error_becomes_the_response() {
let failing_handler: PubOpenHandler = Arc::new(|_input, _plan, channel_conn, _auth| {
Box::pin(async move {
let mut bidi = channel_conn
.accept_bi()
.await
.map_err(|_| CallError::internal("data plane"))?;
let mut buf = [0u8; 3];
let _ = bidi.read_exact(&mut buf).await;
Err(CallError::new(
"blobs:read_only",
"the container is read-only",
false,
))
})
});
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let _captured = serve_put_side(server_end, token_establisher(), failing_handler).await;
let client = ChannelClient::from_connection(client_conn)
.await
.expect("client");
let (_id, mut send, _recv, reply) = client
.open_pub_channel(PUT_OP, json!({ "path": "b/ro" }), "alk/blobs")
.await
.expect("open_pub_channel");
send.write_all(b"abc").await.expect("stream");
drop(send);
let error = reply
.resolve()
.await
.expect_err("the handler error IS the response");
assert_eq!(
error.call_error().map(|e| e.code.clone()),
Some("blobs:read_only".to_string())
);
}
+214 -19
View File
@@ -35,11 +35,13 @@ use crate::registry::spec::{OperationSpec, OperationType};
use super::manager::ChannelManager; use super::manager::ChannelManager;
use super::operations::{ use super::operations::{
ChannelCore, Establishment, EstablishmentError, OpenEstablisher, OpenHandler, ChannelCore, ChannelPlan, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
CHANNEL_OPEN_FAILED, RESERVED_REPLY_KEY, PubOpenHandler, CHANNEL_OPEN_FAILED, RESERVED_REPLY_KEY,
}; };
use super::pump::pump_bidi; use super::pump::pump_bidi;
use super::reassembly::{MpscRecvStream, MpscSendStream}; use super::reassembly::{MpscRecvStream, MpscSendStream};
use tokio::io::AsyncWriteExt;
/// The producer-leg surface the relay holds (ADR-051 §2): the /// The producer-leg surface the relay holds (ADR-051 §2): the
/// `CallConnection` for the translate hop's open call plus the /// `CallConnection` for the translate hop's open call plus the
@@ -81,8 +83,17 @@ struct RelayPlan {
producer_manager: ChannelManager, producer_manager: ChannelManager,
spoke_send: Option<MpscSendStream>, spoke_send: Option<MpscSendStream>,
spoke_recv: Option<MpscRecvStream>, spoke_recv: Option<MpscRecvStream>,
/// The put path (ADR-047 amendment 4 §4.7): the producer leg's
/// open-op response, awaited by the relay pub handler after the
/// consumer EOF (put-acks-in-result — the spoke's response cannot
/// exist before its byte stream completes, so the establisher
/// never awaits it).
pub_reply: Option<tokio::sync::oneshot::Receiver<CallConnectionResponse>>,
} }
/// The producer leg's open-op response, carried on the put path's plan.
type CallConnectionResponse = crate::protocol::wire::ResponseEnvelope;
impl Drop for RelayPlan { impl Drop for RelayPlan {
fn drop(&mut self) { fn drop(&mut self) {
let _ = self.producer_manager.teardown_channel(self.spoke_id); let _ = self.producer_manager.teardown_channel(self.spoke_id);
@@ -120,9 +131,10 @@ impl ChannelRelay {
/// produced open op. /// produced open op.
/// - `spec` must carry the `channel_open` marker (reconstructed /// - `spec` must carry the `channel_open` marker (reconstructed
/// WITH the marker by discovery, ADR-047 amendment 3). A /// WITH the marker by discovery, ADR-047 amendment 3). A
/// `Pub`-typed marked spec is a loud assembly error /// `Pub`-typed marked spec composes through the put-forwarding
/// ([`RelayError::PubTypedOpen`] — the C-08 blocker, ADR-051 /// translation (see [`Self::register_relay_pub_openable`]); an
/// §6); an unmarked spec is likewise an error — branch on the /// unmarked spec is a
/// [`RelayError::UnmarkedSpec`] error — branch on the
/// marker before calling. /// marker before calling.
/// - The translate hop calls the producer leg with the spec's own /// - The translate hop calls the producer leg with the spec's own
/// op name; a hub that re-names the consumer-visible op composes /// op name; a hub that re-names the consumer-visible op composes
@@ -145,14 +157,12 @@ impl ChannelRelay {
}); });
} }
if spec.op_type == OperationType::Pub { if spec.op_type == OperationType::Pub {
return Err(RelayError::PubTypedOpen { return self.register_relay_pub_openable(
message: format!( consumer_core,
"spec `{}` is a Pub-typed channel-open op — the Pub open path is \ consumer_registry,
not implemented (C-08 blocker, `channel:pub_open_not_implemented` \ spec,
class; ADR-051 §6)", consumer_auth,
spec.name );
),
});
} }
let producer_call = Arc::clone(&self.producer_leg.call); let producer_call = Arc::clone(&self.producer_leg.call);
@@ -227,6 +237,76 @@ impl ChannelRelay {
) )
.map_err(|e| RelayError::Registration { message: e }) .map_err(|e| RelayError::Registration { message: e })
} }
/// Register a from_call-imported **marked `Pub`** spec as a relay
/// openable — the put-forwarding translation (ADR-047 amendment 4
/// §4.7). The consumer leg runs the full marked-pub wrapper (adopt
/// → establish → pub handler awaited inline); this side's compose:
///
/// - the establisher allocates the hub's connect-side id on the
/// producer leg (`open_channel` — an odd id, disjoint from the
/// spoke's even allocations), sends the open op **in the
/// background** (the spoke's put response cannot exist before
/// its byte stream completes; an establisher that awaited it
/// deadlocks the leg), and plans the spoke-leg streams + the
/// response's oneshot; the plan's Drop guard reclaims the
/// producer-leg entry on every handler path, as ADR-051 §6 pins.
/// - the handler pumps the consumer-leg bytes → the spoke leg
/// (one direction, pub), EOFs the spoke side on consumer EOF,
/// then awaits the spoke's single response, strips the
/// producer's reserved reply key (the consumer reply carries the
/// hub-allocated consumer-leg id; a spoke `channel_id` merged
/// through would shadow it — the same reserved-key class the
/// wrapper refuses), and returns the remainder — the consumer-leg
/// wrapper merges it with the pinned order (ack fields are none
/// here; the put acks in the spoke's result, §4.5).
///
/// A spoke open-time establishment failure surfaces verbatim in
/// the final response (put-acks-in-result; the spoke's mapped
/// `channel:open_failed` rides the response, not the establisher).
fn register_relay_pub_openable(
&self,
consumer_core: &ChannelCore,
consumer_registry: &OperationRegistry,
spec: OperationSpec,
consumer_auth: AuthContext,
) -> Result<(), RelayError> {
let producer_call = Arc::clone(&self.producer_leg.call);
let producer_manager = self.producer_leg.manager.clone();
let op_name = spec.name.clone();
let establisher: OpenEstablisher = Arc::new(move |input, per_call_auth| {
let producer_call = Arc::clone(&producer_call);
let producer_manager = producer_manager.clone();
let op_name = op_name.clone();
Box::pin(async move {
open_pub_on_producer_leg(
&producer_call,
&producer_manager,
&op_name,
input,
&per_call_auth,
)
.await
})
});
let pub_open_handler: PubOpenHandler =
Arc::new(move |_input, plan, channel_conn, _auth| {
Box::pin(async move { pub_relay_forward(channel_conn, plan).await })
});
consumer_core
.register_pub_openable(
spec,
Some(establisher),
pub_open_handler,
consumer_registry,
consumer_auth,
None,
)
.map_err(|e| RelayError::Registration { message: e })
}
} }
/// The translate hop (ADR-051 §1 layer 1, §2): call the producer leg's /// The translate hop (ADR-051 §1 layer 1, §2): call the producer leg's
@@ -288,6 +368,7 @@ async fn open_on_producer_leg(
producer_manager: producer_manager.clone(), producer_manager: producer_manager.clone(),
spoke_send: Some(spoke_send), spoke_send: Some(spoke_send),
spoke_recv: Some(spoke_recv), spoke_recv: Some(spoke_recv),
pub_reply: None,
}) as super::operations::ChannelPlan); }) as super::operations::ChannelPlan);
Ok(if reply_fields.is_empty() { Ok(if reply_fields.is_empty() {
establishment establishment
@@ -334,6 +415,123 @@ fn map_spoke_error(spoke_error: &CallError) -> EstablishmentError {
} }
} }
/// The put path's translate hop (ADR-047 amendment 4 §4.7): allocate
/// the hub's connect-side channel id on the producer leg, send the
/// spoke's open op **in the background** with the reserved input key
/// set, and plan the spoke-leg streams + the response oneshot. The
/// establisher returns promptly; the spoke's response arrives when the
/// spoke's put completes (or fails) — put-acks-in-result.
async fn open_pub_on_producer_leg(
producer_call: &Arc<CallConnection>,
producer_manager: &ChannelManager,
op_name: &str,
input: Value,
per_call_auth: &AuthContext,
) -> Result<Establishment, EstablishmentError> {
let (spoke_id, spoke_send, spoke_recv) = producer_manager
.open_channel(
"alk/relay",
// The opener ledger is inert on the hub's connect side
// (the connect side does not enforce the per-identity cap,
// ADR-041) — the record is observability only.
"hub-relay",
None,
)
.await
.map_err(|e| EstablishmentError::ResourceShortage {
message: format!("producer leg: channel allocation failed: {e}"),
})?;
let mut hop_input = input.clone();
if let Some(obj) = hop_input.as_object_mut() {
obj.insert(
crate::registry::spec::RESERVED_OPEN_INPUT_KEY.to_string(),
serde_json::json!(spoke_id),
);
}
let payload = build_relay_payload(op_name, hop_input, per_call_auth);
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
let producer_call_task = Arc::clone(producer_call);
tokio::spawn(async move {
let _ = reply_tx.send(producer_call_task.call_with_payload(payload).await);
});
Ok(Establishment::new(Arc::new(RelayPlan {
spoke_id,
producer_manager: producer_manager.clone(),
spoke_send: Some(spoke_send),
spoke_recv: Some(spoke_recv),
pub_reply: Some(reply_rx),
}) as ChannelPlan))
}
/// The relay pub handler (ADR-047 amendment 4 §4.7): pump the
/// consumer-leg bytes → the spoke leg (one direction, pub — the
/// shutdown-on-completion contract, ADR-050's), then await the spoke's
/// single response and strip its reserved reply key. The plan's Drop
/// guard reclaims the producer-leg entry on every path.
async fn pub_relay_forward(
channel_conn: crate::core::types::Connection,
plan: Option<ChannelPlan>,
) -> Result<Value, CallError> {
let Some(plan) = plan else {
return Err(CallError::internal(
"relay: pub establisher produced no plan",
));
};
let Ok(relay_plan_arc) = plan.downcast::<RelayPlan>() else {
return Err(CallError::internal(
"relay: establisher plan of unexpected type",
));
};
let Ok(mut relay_plan) = Arc::try_unwrap(relay_plan_arc) else {
return Err(CallError::internal(
"relay: establisher plan held elsewhere; skipping pump",
));
};
let Some(mut spoke_send) = relay_plan.spoke_send.take() else {
return Err(CallError::internal(
"relay: pub plan missing the spoke send half",
));
};
relay_plan.spoke_recv.take();
let Ok(bidi) = channel_conn.accept_bi().await else {
return Err(CallError::internal(
"relay: consumer leg data plane never opened",
));
};
let (mut read_half, _write_half) = tokio::io::split(bidi);
// The one-direction pump: consumer bytes → spoke; shutdown on
// consumer EOF (the pump contract, ADR-050).
let _copied = tokio::io::copy(&mut read_half, &mut spoke_send)
.await
.unwrap_or(0);
let _ = spoke_send.shutdown().await;
drop(spoke_send);
match relay_plan.pub_reply.take() {
Some(rx) => match rx.await {
Ok(response) => match response.result {
Ok(mut out) => {
if let Some(obj) = out.as_object_mut() {
obj.remove(RESERVED_REPLY_KEY);
}
Ok(out)
}
Err(e) => Err(e),
},
Err(_) => Err(CallError::internal(
"relay: producer-leg open resolved without a response",
)),
},
None => Err(CallError::internal(
"relay: pub plan missing the response receiver",
)),
}
}
/// Build the `call.requested` payload for the translate hop: the hub /// Build the `call.requested` payload for the translate hop: the hub
/// as caller, the consumer as `forwarded_for` (ADR-026 §3, ADR-032 §3 /// as caller, the consumer as `forwarded_for` (ADR-026 §3, ADR-032 §3
/// — the same shape `build_forwarded_payload` produces for the plain /// — the same shape `build_forwarded_payload` produces for the plain
@@ -357,18 +555,15 @@ fn build_relay_payload(op_name: &str, input: Value, per_call_auth: &AuthContext)
/// The assembly-time errors `ChannelRelay::register_relay_openable` /// The assembly-time errors `ChannelRelay::register_relay_openable`
/// produces (ADR-051 §6 — the rejection postures are loud, never /// produces (ADR-051 §6 — the rejection postures are loud, never
/// silent stubs). /// silent stubs; the `PubTypedOpen` variant was removed by ADR-047
/// amendment 4 §4.7 — pub-typed marked specs translate now, never
/// reject-and-strand).
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RelayError { pub enum RelayError {
/// The spec carries no `channel_open` marker — the relay only /// The spec carries no `channel_open` marker — the relay only
/// registers marked specs. /// registers marked specs.
#[error("unmarked spec: {message}")] #[error("unmarked spec: {message}")]
UnmarkedSpec { message: String }, UnmarkedSpec { message: String },
/// A `Pub`-typed marked spec — the Pub open path is not
/// implemented (C-08 blocker; `channel:pub_open_not_implemented`
/// class).
#[error("pub-typed open op: {message}")]
PubTypedOpen { message: String },
/// The consumer-leg registration itself failed (schema compile, /// The consumer-leg registration itself failed (schema compile,
/// handler-kind mismatch). /// handler-kind mismatch).
#[error("registration failed: {message}")] #[error("registration failed: {message}")]
+25 -11
View File
@@ -89,15 +89,21 @@ async fn unmarked_spec_is_rejected_at_registration() {
} }
#[tokio::test] #[tokio::test]
async fn pub_typed_marked_spec_is_rejected_at_registration() { async fn pub_typed_marked_spec_registers_through_the_relay() {
// ADR-047 amendment 4 §4.7: the put-forwarding translation replaced
// the `PubTypedOpen` rejection — a pub-typed marked spec composes
// through `register_pub_openable` (the full marked-pub wrapper
// machinery) and lands on the consumer-leg registry, discoverable.
let producer_manager = manager_for_test();
let relay = ChannelRelay::new(ProducerLeg { let relay = ChannelRelay::new(ProducerLeg {
call: overlay_connection(), call: overlay_connection(),
manager: manager_for_test(), manager: producer_manager,
}); });
let err = relay let registry = OperationRegistry::new();
relay
.register_relay_openable( .register_relay_openable(
&core_for_test(), &core_for_test(),
&OperationRegistry::new(), &registry,
relay_spec( relay_spec(
"channels/x/pub", "channels/x/pub",
OperationType::Pub, OperationType::Pub,
@@ -105,12 +111,10 @@ async fn pub_typed_marked_spec_is_rejected_at_registration() {
), ),
AuthContext::anonymous(b"alk/call"), AuthContext::anonymous(b"alk/call"),
) )
.expect_err("Pub-typed marked spec must be rejected"); .expect("the pub openable registers through the relay");
assert!( assert!(registry.registration("channels/x/pub").is_some());
matches!(err, RelayError::PubTypedOpen { .. }), let registration = registry.registration("channels/x/pub").expect("registered");
"got {err:?}" assert!(registration.spec.channel_open.is_some());
);
assert!(err.to_string().contains("pub_open_not_implemented"));
} }
// --- ADR-051 §3: the reason mapping -------------------------------------- // --- ADR-051 §3: the reason mapping --------------------------------------
@@ -228,6 +232,7 @@ async fn relay_plan_drop_reclaims_the_adopted_producer_leg_entry() {
producer_manager: producer_manager.clone(), producer_manager: producer_manager.clone(),
spoke_send: Some(spoke_send), spoke_send: Some(spoke_send),
spoke_recv: Some(spoke_recv), spoke_recv: Some(spoke_recv),
pub_reply: None,
}; };
drop(plan); drop(plan);
@@ -256,7 +261,16 @@ fn manager_for_test() -> ChannelManager {
tokio::spawn(async move { tokio::spawn(async move {
let _ = runner.run().await; let _ = runner.run().await;
}); });
ChannelManager::with_defaults(handle, None) // The producer leg's manager belongs to the hub, which is the
// connect side of the hub↔spoke channels connection (ADR-051 §4 —
// the spoke's ids are even, the hub's odd).
ChannelManager::new(
handle,
crate::channels::manager::DEFAULT_MAX_CHANNELS,
crate::channels::reassembly::DEFAULT_BUFFER_CAP,
None,
crate::channels::manager::ChannelSide::Connect,
)
} }
fn core_for_test() -> ChannelCore { fn core_for_test() -> ChannelCore {
+70
View File
@@ -0,0 +1,70 @@
//! `ResourceEnumerators` — the registry behind
//! `channel/resources/subscribe`'s live subscription (ADR-053).
//!
//! Each openable-ALPN crate registers one enumerator (its resource
//! truth: which containers are running, which sessions are active);
//! the subscribe handler aggregates them. The channels layer never
//! interprets the payloads — they ride opaquely, merged with the
//! producing ALPN's name (ADR-053 §1).
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use futures::Stream;
use parking_lot::RwLock;
use serde_json::Value;
/// A registered enumerator's item: `(alpn, payload)` — the payload is
/// the ALPN's full current resource state (snapshot-then-changes,
/// ADR-053 §1). The channels layer does not interpret it.
pub type ResourceItem = (String, Value);
/// A registered enumerator's merged stream.
pub type ResourceStream = Pin<Box<dyn Stream<Item = ResourceItem> + Send>>;
/// The resource enumerator contract (ADR-053 §1). The producing crate
/// owns its change detection; the first item on the returned stream is
/// the current snapshot, and each subsequent item is a full snapshot
/// of the changed set.
pub trait ResourceEnumerator: Send + Sync + 'static {
fn subscribe(&self) -> ResourceStream;
}
/// The registry of ALPN resource enumerators (ADR-053 §2). Shared per
/// accepting peer (like `PerIdentityChannelPolicy` — ADR-041): one
/// `Arc<ResourceEnumerators>` across every channels connection that
/// peer accepts. Registered at assembly/install time; the subscribe
/// handler scans it once at subscription start (the scan-once pin —
/// late registrations are invisible to in-flight subscriptions;
/// re-subscribe to see them).
#[derive(Clone, Default)]
pub struct ResourceEnumerators {
inner: Arc<RwLock<HashMap<String, Arc<dyn ResourceEnumerator>>>>,
}
impl ResourceEnumerators {
pub fn new() -> Self {
Self::default()
}
/// Registered at assembly/install time by the composing crate.
pub fn register(&self, alpn: &str, enumerator: Arc<dyn ResourceEnumerator>) {
self.inner.write().insert(alpn.to_string(), enumerator);
}
/// The registered ALPNs (observability).
pub fn alpns(&self) -> Vec<String> {
self.inner.read().keys().cloned().collect()
}
/// Snapshot the registered set (the subscribe handler's scan-once
/// read; ADR-053 §2).
pub(crate) fn snapshot(&self) -> Vec<(String, Arc<dyn ResourceEnumerator>)> {
self.inner
.read()
.iter()
.map(|(alpn, enumerator)| (alpn.clone(), Arc::clone(enumerator)))
.collect()
}
}
+4 -1
View File
@@ -13,7 +13,10 @@ pub use from_call::{from_call, FromCallConfig};
// crate-internal surface for the `op/register` bootstrap path (review // crate-internal surface for the `op/register` bootstrap path (review
// 004 F-05): the forwarding-handler constructor and the spec wire // 004 F-05): the forwarding-handler constructor and the spec wire
// parser are shared with `registry::op_register`. // parser are shared with `registry::op_register`.
pub(crate) use from_call::{make_forwarding_handler, rebuild_spec_for}; pub(crate) use from_call::{
make_forwarding_handler, make_sink_forwarding_handler, make_streaming_forwarding_handler,
rebuild_spec_for,
};
use crate::registry::registration::HandlerRegistration; use crate::registry::registration::HandlerRegistration;
+2 -2
View File
@@ -538,7 +538,7 @@ mod tests {
None, None,
Capabilities::new(), Capabilities::new(),
); );
conn.register_imported(imported); let _error = conn.register_imported(imported);
let peer_identity = Identity { let peer_identity = Identity {
id: "worker-a".to_string(), id: "worker-a".to_string(),
scopes: vec![], scopes: vec![],
@@ -607,7 +607,7 @@ mod tests {
None, None,
Capabilities::new(), Capabilities::new(),
); );
conn.register_imported(imported); let _error = conn.register_imported(imported);
let context = let context =
adapter.build_root_context("req-6".to_string(), "fs/readFile", None, None, &conn); adapter.build_root_context("req-6".to_string(), "fs/readFile", None, None, &conn);
+45 -13
View File
@@ -159,9 +159,27 @@ impl CallConnection {
&self.pending &self.pending
} }
pub fn register_imported(&self, registration: HandlerRegistration) { /// Land a peer-announced/discovered bundle on the connection
/// overlay. Marked (`channel_open`) specs refuse here: a marked op
/// is unreachable by composition (`OperationEnv::invoke` is
/// request/response-only; ADR-046 §6) and its data plane has no
/// overlay path — registration-time loudness, never an advertised
/// dead body (ADR-047 amendment 4 §4.8; ADR-022's 2026-10-13
/// amendment). Relay composition
/// (`ChannelRelay::register_relay_openable`) is the sanctioned
/// marked-op import.
pub fn register_imported(&self, registration: HandlerRegistration) -> Result<(), String> {
if registration.spec.channel_open.is_some() {
return Err(format!(
"register_imported: spec `{}` carries the `channel_open` marker — marked \
specs are not overlay-importable; compose `ChannelRelay` or consume via \
`ChannelClient` (ADR-047 amendment 4 §4.8)",
registration.spec.name
));
}
let name = registration.spec.name.clone(); let name = registration.spec.name.clone();
self.imported_operations.write().insert(name, registration); self.imported_operations.write().insert(name, registration);
Ok(())
} }
/// `true` when the connection overlay holds a registration for /// `true` when the connection overlay holds a registration for
@@ -176,11 +194,24 @@ impl CallConnection {
self.imported_operations.read().get(name).cloned() self.imported_operations.read().get(name).cloned()
} }
pub fn register_imported_all(&self, registrations: Vec<HandlerRegistration>) { pub fn register_imported_all(
&self,
registrations: Vec<HandlerRegistration>,
) -> Result<(), String> {
let mut overlay = self.imported_operations.write(); let mut overlay = self.imported_operations.write();
for reg in registrations { for reg in registrations {
overlay.insert(reg.spec.name.clone(), reg); if reg.spec.channel_open.is_some() {
return Err(format!(
"register_imported_all: spec `{}` carries the `channel_open` marker — \
marked specs are not overlay-importable; compose `ChannelRelay` or \
consume via `ChannelClient` (ADR-047 amendment 4 §4.8)",
reg.spec.name
));
}
let name = reg.spec.name.clone();
overlay.insert(name, reg);
} }
Ok(())
} }
pub fn overlay_env(&self) -> Arc<dyn OperationEnv + Send + Sync> { pub fn overlay_env(&self) -> Arc<dyn OperationEnv + Send + Sync> {
@@ -975,7 +1006,7 @@ mod tests {
assert!(!env.contains("worker/exec")); assert!(!env.contains("worker/exec"));
conn.register_imported(imported_registration("worker/exec")); let _error = conn.register_imported(imported_registration("worker/exec"));
assert!(env.contains("worker/exec")); assert!(env.contains("worker/exec"));
assert!(!env.contains("worker/missing")); assert!(!env.contains("worker/missing"));
@@ -986,11 +1017,12 @@ mod tests {
let conn = CallConnection::new(stub_connection()); let conn = CallConnection::new(stub_connection());
let env = conn.overlay_env(); let env = conn.overlay_env();
conn.register_imported_all(vec![ let registrations_result = conn.register_imported_all(vec![
imported_registration("worker/exec"), imported_registration("worker/exec"),
imported_registration("worker/status"), imported_registration("worker/status"),
imported_registration("fs/readFile"), imported_registration("fs/readFile"),
]); ]);
let _error = registrations_result;
assert!(env.contains("worker/exec")); assert!(env.contains("worker/exec"));
assert!(env.contains("worker/status")); assert!(env.contains("worker/status"));
@@ -1001,7 +1033,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn overlay_env_dispatches_to_imported_op() { async fn overlay_env_dispatches_to_imported_op() {
let conn = CallConnection::new(stub_connection()); let conn = CallConnection::new(stub_connection());
conn.register_imported(imported_registration("worker/exec")); let _error = conn.register_imported(imported_registration("worker/exec"));
let env = conn.overlay_env(); let env = conn.overlay_env();
let scoped = ScopedPeerEnv::new(["worker/exec"]); let scoped = ScopedPeerEnv::new(["worker/exec"]);
@@ -1018,7 +1050,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn overlay_env_contains_returns_false_for_non_imported_op() { async fn overlay_env_contains_returns_false_for_non_imported_op() {
let conn = CallConnection::new(stub_connection()); let conn = CallConnection::new(stub_connection());
conn.register_imported(imported_registration("worker/exec")); let _error = conn.register_imported(imported_registration("worker/exec"));
let env = conn.overlay_env(); let env = conn.overlay_env();
assert!(!env.contains("worker/missing")); assert!(!env.contains("worker/missing"));
@@ -1039,7 +1071,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn overlay_env_reachability_check_returns_not_found_for_disallowed_op() { async fn overlay_env_reachability_check_returns_not_found_for_disallowed_op() {
let conn = CallConnection::new(stub_connection()); let conn = CallConnection::new(stub_connection());
conn.register_imported(imported_registration("worker/exec")); let _error = conn.register_imported(imported_registration("worker/exec"));
let env = conn.overlay_env(); let env = conn.overlay_env();
let scoped = ScopedPeerEnv::empty(); let scoped = ScopedPeerEnv::empty();
@@ -1069,7 +1101,7 @@ mod tests {
}), }),
) )
}); });
conn.register_imported(HandlerRegistration::new( let _error = conn.register_imported(HandlerRegistration::new(
external_spec("worker/exec"), external_spec("worker/exec"),
HandlerKind::Once(inspect_handler), HandlerKind::Once(inspect_handler),
OperationProvenance::FromCall, OperationProvenance::FromCall,
@@ -1115,7 +1147,7 @@ mod tests {
Arc::new(RwLock::new(HashMap::new())); Arc::new(RwLock::new(HashMap::new()));
{ {
let conn = CallConnection::new(stub_connection()); let conn = CallConnection::new(stub_connection());
conn.register_imported(imported_registration("worker/exec")); let _error = conn.register_imported(imported_registration("worker/exec"));
assert!(conn.overlay_env().contains("worker/exec")); assert!(conn.overlay_env().contains("worker/exec"));
std::mem::swap( std::mem::swap(
&mut *captured.write(), &mut *captured.write(),
@@ -1380,14 +1412,14 @@ mod tests {
let conn = CallConnection::new_overlay_only(sample_identity("ws-peer")); let conn = CallConnection::new_overlay_only(sample_identity("ws-peer"));
let env = conn.overlay_env(); let env = conn.overlay_env();
assert!(!env.contains("worker/exec")); assert!(!env.contains("worker/exec"));
conn.register_imported(imported_registration("worker/exec")); let _error = conn.register_imported(imported_registration("worker/exec"));
assert!(env.contains("worker/exec")); assert!(env.contains("worker/exec"));
} }
#[tokio::test] #[tokio::test]
async fn overlay_only_overlay_env_dispatches_imported_op() { async fn overlay_only_overlay_env_dispatches_imported_op() {
let conn = CallConnection::new_overlay_only(sample_identity("ws-peer")); let conn = CallConnection::new_overlay_only(sample_identity("ws-peer"));
conn.register_imported(imported_registration("worker/exec")); let _error = conn.register_imported(imported_registration("worker/exec"));
let env = conn.overlay_env(); let env = conn.overlay_env();
let scoped = ScopedPeerEnv::new(["worker/exec"]); let scoped = ScopedPeerEnv::new(["worker/exec"]);
@@ -1433,7 +1465,7 @@ mod tests {
let streaming_handler = make_streaming_handler(|input, ctx| { let streaming_handler = make_streaming_handler(|input, ctx| {
futures::stream::iter(vec![ResponseEnvelope::ok(ctx.request_id, input)]) futures::stream::iter(vec![ResponseEnvelope::ok(ctx.request_id, input)])
}); });
conn.register_imported(HandlerRegistration::new( let _error = conn.register_imported(HandlerRegistration::new(
OperationSpec::new( OperationSpec::new(
"events/stream", "events/stream",
OperationType::Sub, OperationType::Sub,
+110 -2
View File
@@ -86,6 +86,11 @@ pub struct SinkDispatch {
pub(crate) handler: Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>, pub(crate) handler: Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>,
pub(crate) chunk_tx: mpsc::Sender<Result<Value, CallError>>, pub(crate) chunk_tx: mpsc::Sender<Result<Value, CallError>>,
pub(crate) publish_validator: Option<jsonschema::Validator>, pub(crate) publish_validator: Option<jsonschema::Validator>,
/// Marked (`channel_open`) `Pub` op (ADR-047 amendment 4 §4.1):
/// publish chunks never feed the handler — the first
/// `call.published` chunk is rejected loudly in the initiator's
/// direction and the rest are ignored.
pub(crate) reject_publish: bool,
} }
/// An in-flight Pub (Sink) on the single-stream dispatch loop /// An in-flight Pub (Sink) on the single-stream dispatch loop
@@ -100,6 +105,11 @@ struct InFlightSink {
chunk_tx: mpsc::Sender<Result<Value, CallError>>, chunk_tx: mpsc::Sender<Result<Value, CallError>>,
publish_validator: Option<jsonschema::Validator>, publish_validator: Option<jsonschema::Validator>,
handler_handle: JoinHandle<()>, handler_handle: JoinHandle<()>,
/// Marked (`channel_open`) `Pub` op (ADR-047 amendment 4 §4.1):
/// the first `call.published` chunk is rejected loudly in the
/// initiator's direction and never fed to the handler.
reject_publish: bool,
reject_written: bool,
} }
impl std::fmt::Debug for DispatchResult { impl std::fmt::Debug for DispatchResult {
@@ -356,7 +366,7 @@ impl Dispatcher {
.get("forwarded_for") .get("forwarded_for")
.and_then(|v| serde_json::from_value::<Identity>(v.clone()).ok()); .and_then(|v| serde_json::from_value::<Identity>(v.clone()).ok());
let input = payload.get("input").cloned().unwrap_or(Value::Null); let mut input = payload.get("input").cloned().unwrap_or(Value::Null);
let op_type = self let op_type = self
.registry .registry
@@ -391,9 +401,13 @@ impl Dispatcher {
context.deadline = None; context.deadline = None;
match self match self
.registry .registry
.resolve_sink_handler(&operation_name, &input, &context) .resolve_sink_handler(&operation_name, &mut input, &mut context)
{ {
Ok(sink_handler) => { Ok(sink_handler) => {
let reject_publish = self
.registry
.registration(&operation_name)
.is_some_and(|r| r.spec.channel_open.is_some());
let publish_validator = self.registry.publish_validator(&operation_name); let publish_validator = self.registry.publish_validator(&operation_name);
let (chunk_tx, chunk_rx) = let (chunk_tx, chunk_rx) =
mpsc::channel::<Result<Value, CallError>>(PUBLISH_CHANNEL_BUFFER); mpsc::channel::<Result<Value, CallError>>(PUBLISH_CHANNEL_BUFFER);
@@ -403,6 +417,7 @@ impl Dispatcher {
handler: Box::pin(handler), handler: Box::pin(handler),
chunk_tx, chunk_tx,
publish_validator, publish_validator,
reject_publish,
}) })
} }
Err(envelope) => StartedDispatch::Once(Box::pin(std::future::ready(envelope))), Err(envelope) => StartedDispatch::Once(Box::pin(std::future::ready(envelope))),
@@ -657,6 +672,7 @@ impl Dispatcher {
handler, handler,
mut chunk_tx, mut chunk_tx,
publish_validator, publish_validator,
reject_publish,
} = sink; } = sink;
let (abort_tx, mut abort_rx) = oneshot::channel::<()>(); let (abort_tx, mut abort_rx) = oneshot::channel::<()>();
@@ -668,6 +684,7 @@ impl Dispatcher {
let mut handler = handler.fuse(); let mut handler = handler.fuse();
let mut response: Option<ResponseEnvelope> = None; let mut response: Option<ResponseEnvelope> = None;
let mut aborted = false; let mut aborted = false;
let mut reject_written = false;
loop { loop {
tokio::select! { tokio::select! {
@@ -698,6 +715,31 @@ impl Dispatcher {
} }
match envelope.r#type.as_str() { match envelope.r#type.as_str() {
EVENT_PUBLISHED => { EVENT_PUBLISHED => {
// Marked ops: the data plane is the adopted
// channel's `BiStream` — publish chunks are
// rejected loudly, never consumed (ADR-047
// amendment 4 §4.1). The handler continues on
// the channel plane; the initiator's pending
// call resolves with this rejection.
if reject_publish {
if !reject_written {
reject_written = true;
let rejection: EventEnvelope = ResponseEnvelope::error(
request_id.clone(),
CallError::new(
crate::registry::spec::MARKED_PUB_CHUNKS_REJECTED,
"the data plane of a marked pub op is the adopted \
channel's BiStream — call.published chunks are \
rejected (ADR-047 amendment 4 §4.1)",
false,
),
).into();
if let Err(err) = writer.write_frame(&rejection).await {
warn!(error = %err, request_id = %request_id, "failed to write marked-op publish rejection");
}
}
continue;
}
let chunk = envelope let chunk = envelope
.payload .payload
.get("input") .get("input")
@@ -923,6 +965,7 @@ impl Dispatcher {
handler, handler,
chunk_tx, chunk_tx,
publish_validator, publish_validator,
reject_publish,
} = sink; } = sink;
let handle = Self::spawn_sink_response_writer( let handle = Self::spawn_sink_response_writer(
&writer, &writer,
@@ -935,6 +978,8 @@ impl Dispatcher {
chunk_tx, chunk_tx,
publish_validator, publish_validator,
handler_handle: handle, handler_handle: handle,
reject_publish,
reject_written: false,
}, },
); );
} }
@@ -977,6 +1022,36 @@ impl Dispatcher {
.unwrap_or(Value::Null); .unwrap_or(Value::Null);
let entry = in_flight_sinks.lock().remove(&request_id); let entry = in_flight_sinks.lock().remove(&request_id);
if let Some(mut entry) = entry { if let Some(mut entry) = entry {
if entry.reject_publish {
// Marked ops: the data plane is the adopted
// channel's `BiStream` — publish chunks are
// rejected loudly, never consumed (ADR-047
// amendment 4 §4.1). The loud error is written
// once per request; the entry stays registered
// so the handler's own final response still gets
// written. The handler continues on the channel
// plane; the initiator's pending call resolves
// with this rejection immediately.
if !entry.reject_written {
entry.reject_written = true;
let rejection: EventEnvelope = ResponseEnvelope::error(
request_id.clone(),
CallError::new(
crate::registry::spec::MARKED_PUB_CHUNKS_REJECTED,
"the data plane of a marked pub op is the \
adopted channel's BiStream — call.published \
chunks are rejected (ADR-047 amendment 4 §4.1)",
false,
),
)
.into();
if let Err(err) = writer.write_frame(&rejection).await {
warn!(error = %err, request_id = %request_id, "failed to write marked-op publish rejection");
}
}
in_flight_sinks.lock().insert(request_id, entry);
continue;
}
let validated = match &entry.publish_validator { let validated = match &entry.publish_validator {
Some(validator) => { Some(validator) => {
if validator.is_valid(&chunk) { if validator.is_valid(&chunk) {
@@ -1146,6 +1221,7 @@ impl Dispatcher {
handler, handler,
chunk_tx, chunk_tx,
publish_validator, publish_validator,
reject_publish,
} = sink; } = sink;
let handle = Self::spawn_sink_response_writer( let handle = Self::spawn_sink_response_writer(
&writer, &writer,
@@ -1158,6 +1234,8 @@ impl Dispatcher {
chunk_tx, chunk_tx,
publish_validator, publish_validator,
handler_handle: handle, handler_handle: handle,
reject_publish,
reject_written: false,
}, },
); );
} }
@@ -1200,6 +1278,36 @@ impl Dispatcher {
.unwrap_or(Value::Null); .unwrap_or(Value::Null);
let entry = in_flight_sinks.lock().remove(&request_id); let entry = in_flight_sinks.lock().remove(&request_id);
if let Some(mut entry) = entry { if let Some(mut entry) = entry {
if entry.reject_publish {
// Marked ops: the data plane is the adopted
// channel's `BiStream` — publish chunks are
// rejected loudly, never consumed (ADR-047
// amendment 4 §4.1). The loud error is written
// once per request; the entry stays registered
// so the handler's own final response still gets
// written. The handler continues on the channel
// plane; the initiator's pending call resolves
// with this rejection immediately.
if !entry.reject_written {
entry.reject_written = true;
let rejection: EventEnvelope = ResponseEnvelope::error(
request_id.clone(),
CallError::new(
crate::registry::spec::MARKED_PUB_CHUNKS_REJECTED,
"the data plane of a marked pub op is the \
adopted channel's BiStream — call.published \
chunks are rejected (ADR-047 amendment 4 §4.1)",
false,
),
)
.into();
if let Err(err) = writer.write_frame(&rejection).await {
warn!(error = %err, request_id = %request_id, "failed to write marked-op publish rejection");
}
}
in_flight_sinks.lock().insert(request_id, entry);
continue;
}
let validated = match &entry.publish_validator { let validated = match &entry.publish_validator {
Some(validator) => { Some(validator) => {
if validator.is_valid(&chunk) { if validator.is_valid(&chunk) {
+151 -22
View File
@@ -178,16 +178,37 @@ pub fn op_register_handler(
} }
let mut spec = request.spec; let mut spec = request.spec;
if spec.channel_open.is_some() {
return ResponseEnvelope::error(
context.request_id,
CallError::new(
"MARKED_SPEC_UNSUPPORTED",
format!(
"op/register: `{}` carries the `channel_open` marker — marked ops \
are channel-machinery ops (the data plane is the allocated \
channel's `BiStream`, ADR-047 amendment 4); forward them with \
`ChannelRelay::register_relay_openable` (a relaying hub) or \
consume them directly (`ChannelClient`), never as an \
announcement",
spec.name
),
false,
),
);
}
spec.visibility = Visibility::Internal; spec.visibility = Visibility::Internal;
let remote_name = spec.name.clone(); let remote_name = spec.name.clone();
let handler = forwarding_stub_for_announced_op( let handler = forwarding_kind_for_announced_op(
Arc::clone(&connection), Arc::clone(&connection),
remote_name, remote_name,
spec.op_type, spec.op_type,
); );
connection.register_imported(HandlerRegistration::new( // The marked-refusal arm is unreachable here (marked
// announcements were refused above); an invariant break
// maps to the wire rather than a panic.
let registered = connection.register_imported(HandlerRegistration::new(
spec.clone(), spec.clone(),
handler, handler,
OperationProvenance::FromCall, OperationProvenance::FromCall,
@@ -195,6 +216,12 @@ pub fn op_register_handler(
None, None,
Capabilities::new(), Capabilities::new(),
)); ));
if let Err(e) = registered {
return ResponseEnvelope::error(
context.request_id,
CallError::internal(format!("op/register: overlay landing refused: {e}")),
);
}
ResponseEnvelope::ok( ResponseEnvelope::ok(
context.request_id, context.request_id,
@@ -204,15 +231,14 @@ pub fn op_register_handler(
}) })
} }
/// The forwarding stub for a peer-announced op. Query/Mutation ops get /// The forwarding handler kind for a peer-announced op. Query/Mutation
/// the `from_call` forwarding shape (nested `call.requested` back over /// get the `from_call` forwarding shape (nested `call.requested` back
/// channel 0, `forwarded_for` populated per ADR-032 §3). Announced /// over channel 0, `forwarded_for` populated per ADR-032 §3); unmarked
/// Sub/Pub ops are registered as stubs that return /// `Sub`/`Pub` get the real streaming/sink forwarding shapes (review
/// `INVALID_OPERATION_TYPE` on invocation — nested composition is /// 010 S-04's remedy: never accept-then-fail — the handler actually
/// request/response-only (`OverlayOperationEnv`'s contract); the /// forwards). Marked specs refuse at this boundary (ADR-047 amendment
/// streaming/sink forwarding shapes ride on the `from_call` import /// 4 — checked before this function is reached).
/// path, which the announcing side can use in the other direction. fn forwarding_kind_for_announced_op(
fn forwarding_stub_for_announced_op(
connection: Arc<CallConnection>, connection: Arc<CallConnection>,
remote_name: String, remote_name: String,
op_type: OperationType, op_type: OperationType,
@@ -221,17 +247,13 @@ fn forwarding_stub_for_announced_op(
OperationType::Query | OperationType::Mutation => HandlerKind::Once( OperationType::Query | OperationType::Mutation => HandlerKind::Once(
crate::client::make_forwarding_handler(connection, remote_name), crate::client::make_forwarding_handler(connection, remote_name),
), ),
OperationType::Sub | OperationType::Pub => { OperationType::Sub => HandlerKind::Stream(
HandlerKind::Once(make_handler(|_input, context| async move { crate::client::make_streaming_forwarding_handler(connection, remote_name),
ResponseEnvelope::error( ),
context.request_id, OperationType::Pub => HandlerKind::Sink(crate::client::make_sink_forwarding_handler(
CallError::invalid_operation_type( connection,
"peer-announced Sub/Pub ops are not invocable over nested \ remote_name,
composition (request/response only)", )),
),
)
}))
}
} }
} }
@@ -757,4 +779,111 @@ mod tests {
.expect("marker survives the announce"); .expect("marker survives the announce");
assert_eq!(marker.alpn, "alk/tunnel"); assert_eq!(marker.alpn, "alk/tunnel");
} }
// --- review 010 S-04's remedy (ADR-022, 2026-10-13 amendment) --------
/// Forward-through: an announced unmarked `Sub` op registers the
/// real streaming forwarding shape, not an always-failing stub.
#[tokio::test]
async fn announced_unmarked_sub_registers_real_streaming_forward() {
let conn = Arc::new(CallConnection::new(stub_connection()));
let handler = op_register_handler(Arc::clone(&conn), Arc::new(OperationRegistry::new()));
let input = OpRegisterRequest {
spec: OperationSpec::new(
"worker/events",
OperationType::Sub,
Visibility::External,
json!({}),
json!({}),
vec![],
AccessControl::default(),
None,
),
replace: false,
}
.to_json();
let response = handler(input, test_context("req-s04-1")).await;
assert!(response.result.is_ok(), "got {:?}", response.result);
let registration = conn
.overlay_registration("worker/events")
.expect("registered");
assert!(
matches!(registration.handler, HandlerKind::Stream(_)),
"the announced sub op is a real forwarding handler, not a stub"
);
}
/// Forward-through: an announced unmarked `Pub` op registers the
/// real sink forwarding shape.
#[tokio::test]
async fn announced_unmarked_pub_registers_real_sink_forward() {
let conn = Arc::new(CallConnection::new(stub_connection()));
let handler = op_register_handler(Arc::clone(&conn), Arc::new(OperationRegistry::new()));
let input = OpRegisterRequest {
spec: OperationSpec::new(
"worker/ingest",
OperationType::Pub,
Visibility::External,
json!({}),
json!({}),
vec![],
AccessControl::default(),
None,
),
replace: false,
}
.to_json();
let response = handler(input, test_context("req-s04-2")).await;
assert!(response.result.is_ok(), "got {:?}", response.result);
let registration = conn
.overlay_registration("worker/ingest")
.expect("registered");
assert!(
matches!(registration.handler, HandlerKind::Sink(_)),
"the announced pub op is a real forwarding handler, not a stub"
);
}
/// Marked-spec refusal (ADR-047 amendment 4 §4.8's wire side): a
/// marked announcement is rejected with the typed code, no overlay
/// entry, and the message names the sanctioned compositions.
#[tokio::test]
async fn announced_marked_spec_is_refused() {
use crate::registry::spec::ChannelOpenSpec;
let conn = Arc::new(CallConnection::new(stub_connection()));
let handler = op_register_handler(Arc::clone(&conn), Arc::new(OperationRegistry::new()));
let input = OpRegisterRequest {
spec: OperationSpec::new(
"channels/tunnel/direct",
OperationType::Sub,
Visibility::External,
json!({}),
json!({}),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/tunnel")),
replace: false,
}
.to_json();
let response = handler(input, test_context("req-s04-3")).await;
let e = response.result.expect_err("marked announcement refused");
assert_eq!(e.code, "MARKED_SPEC_UNSUPPORTED");
assert!(
e.message.contains("ChannelRelay"),
"the refusal names the sanctioned composition: {}",
e.message
);
assert!(
!conn.overlay_contains("channels/tunnel/direct"),
"the refused announcement lands nowhere"
);
}
} }
+170 -8
View File
@@ -84,6 +84,12 @@ pub struct HandlerRegistration {
pub composition_authority: Option<CompositionAuthority>, pub composition_authority: Option<CompositionAuthority>,
pub scoped_env: Option<ScopedPeerEnv>, pub scoped_env: Option<ScopedPeerEnv>,
pub capabilities: Capabilities, pub capabilities: Capabilities,
/// Set only by the channels-layer wrapper builders — a marked
/// (`channel_open`) spec is registered only through the
/// channel-machinery seam (`register_openable*`, `register_pub_openable`,
/// the relay). Generic registrations of marked specs are refused
/// (ADR-047 amendment 4 §4.8 — the registered-but-incapable class).
pub(crate) channel_wrapper: bool,
} }
impl HandlerRegistration { impl HandlerRegistration {
@@ -102,8 +108,17 @@ impl HandlerRegistration {
composition_authority, composition_authority,
scoped_env, scoped_env,
capabilities, capabilities,
channel_wrapper: false,
} }
} }
/// The channel-machinery wrapper family's constructor seam
/// (crate-internal — the only sanctioned registrations of marked
/// specs).
pub(crate) fn with_channel_wrapper(mut self) -> Self {
self.channel_wrapper = true;
self
}
} }
pub struct OperationRegistry { pub struct OperationRegistry {
@@ -215,6 +230,23 @@ impl OperationRegistry {
registration.spec.op_type, expected, actual registration.spec.op_type, expected, actual
)); ));
} }
if registration.spec.channel_open.is_some() && !registration.channel_wrapper {
return Err(format!(
"spec `{}` carries the `channel_open` marker — marked ops are registered \
only through the channel-machinery seam (`ChannelCore::register_openable*`, \
`ChannelCore::register_pub_openable`, `ChannelRelay::register_relay_openable`); \
a generic registration cannot serve the marker's data plane (ADR-047 amendment 4 §4.8)",
registration.spec.name
));
}
if registration.spec.channel_open.is_some() && registration.spec.publish_schema.is_some() {
return Err(format!(
"spec `{}` carries both the `channel_open` marker and a `publish_schema` — \
publish chunks never exist on a marked op (the data plane is the adopted \
channel's `BiStream`); the field is dead machinery here (ADR-047 amendment 4 §4.2)",
registration.spec.name
));
}
Self::validate_and_cache_publish_schema(&mut self.validators.write(), &registration.spec)?; Self::validate_and_cache_publish_schema(&mut self.validators.write(), &registration.spec)?;
Self::validate_and_cache_input_schema( Self::validate_and_cache_input_schema(
&mut self.input_validators.write(), &mut self.input_validators.write(),
@@ -436,12 +468,21 @@ impl OperationRegistry {
/// dispatch path (`Dispatcher::dispatch` Pub branch) call it, so the /// dispatch path (`Dispatcher::dispatch` Pub branch) call it, so the
/// two paths cannot diverge (P-08: the `invoke_sink` path IS the /// two paths cannot diverge (P-08: the `invoke_sink` path IS the
/// wire path). /// wire path).
///
/// Marked `Pub` ops (ADR-047 amendment 4 §4.3): the
/// wrapper-reserved input key ([`crate::registry::spec::
/// RESERVED_OPEN_INPUT_KEY`]) is stripped **before**
/// `input_schema` validation — the producing crate's schema never
/// contains it — and the initiator-allocated channel id is handed
/// to the wrapper via `context.metadata`. A marked pub op whose
/// input lacks the reserved key is rejected here (`INVALID_INPUT`)
/// — the id is mandatory, not optional plumbing.
#[allow(clippy::result_large_err)] #[allow(clippy::result_large_err)]
pub(crate) fn resolve_sink_handler( pub(crate) fn resolve_sink_handler(
&self, &self,
name: &str, name: &str,
input: &Value, input: &mut Value,
context: &OperationContext, context: &mut OperationContext,
) -> Result<SinkHandler, ResponseEnvelope> { ) -> Result<SinkHandler, ResponseEnvelope> {
let request_id = context.request_id.clone(); let request_id = context.request_id.clone();
let registration = match self.registration(name) { let registration = match self.registration(name) {
@@ -477,6 +518,43 @@ impl OperationRegistry {
return Err(ResponseEnvelope::forbidden(request_id, message)); return Err(ResponseEnvelope::forbidden(request_id, message));
} }
if registration.spec.channel_open.is_some()
&& registration.spec.op_type == OperationType::Pub
{
let reserved_id = match input
.as_object_mut()
.and_then(|obj| obj.remove(crate::registry::spec::RESERVED_OPEN_INPUT_KEY))
{
Some(value) => match value.as_u64() {
Some(id) if id <= u32::MAX as u64 => id as u32,
_ => {
return Err(ResponseEnvelope::error(
request_id,
CallError::invalid_input(format!(
"marked pub open op: the reserved `{}` key must be a u32 \
channel id (the initiator-allocated id; ADR-047 amendment 4 §4.3)",
crate::registry::spec::RESERVED_OPEN_INPUT_KEY
)),
));
}
},
None => {
return Err(ResponseEnvelope::error(
request_id,
CallError::invalid_input(format!(
"marked pub open op input missing the reserved `{}` key \
(the initiator-allocated channel id; ADR-047 amendment 4 §4.3)",
crate::registry::spec::RESERVED_OPEN_INPUT_KEY
)),
));
}
};
context.metadata.insert(
crate::registry::spec::RESERVED_OPEN_METADATA_KEY.to_string(),
serde_json::json!(reserved_id),
);
}
self.check_input_schema(&registration, input, &request_id)?; self.check_input_schema(&registration, input, &request_id)?;
match &registration.handler { match &registration.handler {
@@ -505,14 +583,16 @@ impl OperationRegistry {
pub async fn invoke_sink( pub async fn invoke_sink(
&self, &self,
name: &str, name: &str,
input: Value, mut input: Value,
publish_stream: PublishStream, publish_stream: PublishStream,
context: OperationContext, mut context: OperationContext,
) -> ResponseEnvelope { ) -> ResponseEnvelope {
match self.resolve_sink_handler(name, &input, &context) { let publish_stream_resolved =
Err(envelope) => envelope, match self.resolve_sink_handler(name, &mut input, &mut context) {
Ok(sink_handler) => sink_handler(input, context, publish_stream).await, Err(envelope) => return envelope,
} Ok(sink_handler) => sink_handler,
};
publish_stream_resolved(input, context, publish_stream).await
} }
} }
@@ -2942,3 +3022,85 @@ mod tests {
assert_eq!(err.code, "INVALID_INPUT"); assert_eq!(err.code, "INVALID_INPUT");
} }
} }
#[cfg(test)]
mod marked_spec_gates {
use super::*;
fn marked_spec(name: &str, op_type: OperationType) -> OperationSpec {
OperationSpec::new(
name,
op_type,
crate::registry::spec::Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
crate::registry::spec::AccessControl::default(),
None,
)
.with_channel_open(crate::registry::spec::ChannelOpenSpec::new("alk/x"))
}
/// ADR-047 amendment 4 §4.2 gate: `publish_schema` on a marked
/// spec refuses at registration — publish chunks never exist on a
/// marked op. (Registered through the sanctioned wrapper seam, so
/// the §4.8 invariant is satisfied and this refusal is the one
/// that fires.)
#[test]
fn marked_spec_with_publish_schema_refused() {
let registry = OperationRegistry::new();
let spec = marked_spec("channels/x/pub", OperationType::Pub)
.with_publish_schema(serde_json::json!({ "type": "object" }));
let registration = HandlerRegistration::new(
spec,
HandlerKind::Sink(make_sink_handler(|_input, _ctx, _stream| async {
ResponseEnvelope::ok("r", serde_json::json!({}))
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
)
.with_channel_wrapper();
let e = registry.register(registration).expect_err("refused");
assert!(e.contains("publish_schema"), "got: {e}");
}
/// ADR-047 amendment 4 §4.8 gate: a marked spec through the
/// generic registration path is refused — the registered-but-
/// incapable class review 010 outlaws; the sanctioned seam is the
/// channel wrapper family.
#[test]
fn marked_spec_refused_outside_the_wrapper_seam() {
let registry = OperationRegistry::new();
let spec = marked_spec("channels/x/pub", OperationType::Pub);
let registration = HandlerRegistration::new(
spec,
HandlerKind::Sink(make_sink_handler(|_input, _ctx, _stream| async {
ResponseEnvelope::ok("r", serde_json::json!({}))
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
);
let e = registry.register(registration).expect_err("refused");
assert!(e.contains("channel-machinery seam"), "got: {e}");
// A marked `Sub` spec — the same class.
let registry2 = OperationRegistry::new();
let spec2 = marked_spec("channels/x/sub", OperationType::Sub);
let registration2 = HandlerRegistration::new(
spec2,
HandlerKind::Stream(make_streaming_handler(|_input, _ctx| {
Box::pin(futures::stream::empty())
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
);
let e2 = registry2.register(registration2).expect_err("refused");
assert!(e2.contains("channel-machinery seam"), "got: {e2}");
}
}
+22
View File
@@ -49,6 +49,28 @@ pub struct ChannelOpenSpec {
pub alpn: Cow<'static, str>, pub alpn: Cow<'static, str>,
} }
/// The wrapper-reserved input key on a **marked `Pub` op's** input
/// (ADR-047 amendment 4 §4.3): the initiator-allocated `channel_id` the
/// responder adopts. The same key the open wrapper owns on the reply
/// side (the channels layer's `RESERVED_REPLY_KEY`); the wrapper owns
/// `channel_id` on both sides of a marked open op. Stripped
/// pre-validation in the sink resolve path and delivered to the
/// wrapper via `OperationContext.metadata`
/// ([`RESERVED_OPEN_METADATA_KEY`]).
pub const RESERVED_OPEN_INPUT_KEY: &str = "channel_id";
/// The `OperationContext.metadata` key the sink resolve path uses to
/// hand the stripped reserved input value (the initiator-allocated
/// channel id) to the marked-pub open wrapper (ADR-047 amendment 4
/// §4.3).
pub const RESERVED_OPEN_METADATA_KEY: &str = "channel_open_channel_id";
/// The wire error code for a `call.published` chunk arriving on a
/// marked (`channel_open`) `Pub` op (ADR-047 amendment 4 §4.1): the
/// marked op's data plane is the adopted channel's `BiStream`; publish
/// chunks are rejected loudly and never consumed.
pub const MARKED_PUB_CHUNKS_REJECTED: &str = "channel:publish_chunks_rejected";
impl ChannelOpenSpec { impl ChannelOpenSpec {
pub fn new(alpn: impl Into<Cow<'static, str>>) -> Self { pub fn new(alpn: impl Into<Cow<'static, str>>) -> Self {
Self { alpn: alpn.into() } Self { alpn: alpn.into() }
+49
View File
@@ -0,0 +1,49 @@
//! The stub guard (review 010 action 3): no `*:not_implemented` path
//! may exist in `src/`. A handler that registers successfully and
//! returns an error of this form on every invocation is a bug, not a
//! documented limitation — shipping one is a hard failure per the
//! repo ruling (reviews 010/011; four stubs shipped across nine
//! published versions before this guard existed).
#[test]
fn source_tree_has_no_not_implemented_path() {
let manifest = env!("CARGO_MANIFEST_DIR");
let src = std::path::Path::new(manifest).join("src");
let mut offenders = Vec::new();
walk(&src, &mut offenders);
assert!(
offenders.is_empty(),
"no `not_implemented` string may appear in src/ — a registered surface \
that always fails is the stub class (review 010 S-01..S-04); offended \
by: {offenders:?}"
);
}
fn walk(dir: &std::path::Path, offenders: &mut Vec<String>) {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, offenders);
} else if path.extension().is_some_and(|e| e == "rs") {
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
for (line_no, line) in contents.lines().enumerate() {
if line.contains("not_implemented") {
offenders.push(format!(
"{}:{}",
path.strip_prefix(env!("CARGO_MANIFEST_DIR"))
.unwrap_or(&path)
.display(),
line_no + 1
));
}
}
}
}
}