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
/// `channel:open_failed` error (`dial_failed`, `unknown_resource`,
/// `resource_shortage`, `handler_error`, `timeout`). `None` for
/// any other failure shape.
/// `resource_shortage`, `handler_error`, `timeout`, `adopt_failed`).
/// `None` for any other failure shape.
pub fn establishment_reason(&self) -> Option<&str> {
match self {
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
/// [`ChannelClient::from_connection`] from an established
/// `Connection`. Holds the `ChannelManager` (for the demux/mux state)
@@ -382,6 +406,110 @@ impl ChannelClient {
.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
/// imported ops (`from_call`) on the connection's overlay. After
/// this, `call_open_op` returns an error (the connection is owned