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

+110 -2
View File
@@ -86,6 +86,11 @@ pub struct SinkDispatch {
pub(crate) handler: Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>,
pub(crate) chunk_tx: mpsc::Sender<Result<Value, CallError>>,
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
@@ -100,6 +105,11 @@ struct InFlightSink {
chunk_tx: mpsc::Sender<Result<Value, CallError>>,
publish_validator: Option<jsonschema::Validator>,
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 {
@@ -356,7 +366,7 @@ impl Dispatcher {
.get("forwarded_for")
.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
.registry
@@ -391,9 +401,13 @@ impl Dispatcher {
context.deadline = None;
match self
.registry
.resolve_sink_handler(&operation_name, &input, &context)
.resolve_sink_handler(&operation_name, &mut input, &mut context)
{
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 (chunk_tx, chunk_rx) =
mpsc::channel::<Result<Value, CallError>>(PUBLISH_CHANNEL_BUFFER);
@@ -403,6 +417,7 @@ impl Dispatcher {
handler: Box::pin(handler),
chunk_tx,
publish_validator,
reject_publish,
})
}
Err(envelope) => StartedDispatch::Once(Box::pin(std::future::ready(envelope))),
@@ -657,6 +672,7 @@ impl Dispatcher {
handler,
mut chunk_tx,
publish_validator,
reject_publish,
} = sink;
let (abort_tx, mut abort_rx) = oneshot::channel::<()>();
@@ -668,6 +684,7 @@ impl Dispatcher {
let mut handler = handler.fuse();
let mut response: Option<ResponseEnvelope> = None;
let mut aborted = false;
let mut reject_written = false;
loop {
tokio::select! {
@@ -698,6 +715,31 @@ impl Dispatcher {
}
match envelope.r#type.as_str() {
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
.payload
.get("input")
@@ -923,6 +965,7 @@ impl Dispatcher {
handler,
chunk_tx,
publish_validator,
reject_publish,
} = sink;
let handle = Self::spawn_sink_response_writer(
&writer,
@@ -935,6 +978,8 @@ impl Dispatcher {
chunk_tx,
publish_validator,
handler_handle: handle,
reject_publish,
reject_written: false,
},
);
}
@@ -977,6 +1022,36 @@ impl Dispatcher {
.unwrap_or(Value::Null);
let entry = in_flight_sinks.lock().remove(&request_id);
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 {
Some(validator) => {
if validator.is_valid(&chunk) {
@@ -1146,6 +1221,7 @@ impl Dispatcher {
handler,
chunk_tx,
publish_validator,
reject_publish,
} = sink;
let handle = Self::spawn_sink_response_writer(
&writer,
@@ -1158,6 +1234,8 @@ impl Dispatcher {
chunk_tx,
publish_validator,
handler_handle: handle,
reject_publish,
reject_written: false,
},
);
}
@@ -1200,6 +1278,36 @@ impl Dispatcher {
.unwrap_or(Value::Null);
let entry = in_flight_sinks.lock().remove(&request_id);
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 {
Some(validator) => {
if validator.is_valid(&chunk) {