fix: Unit 8 — Pub protocol semantics (P-03, P-04, P-07)

The publish path's decided-but-unimplemented behaviors land: per-chunk
schema validation, initiator call.error terminates the stream, and an
early handler return short-circuits the feed. alktype::validation::
build_validator is now used on both dispatch paths (the dependency was
declared and unused); jsonschema was added as a direct dependency
because its Validator type is part of alktype's public API.

P-03 — OperationSpec::publish_schema was declared, had a builder,
defaulted to None, and was never read. It now round-trips through
discovery: spec_to_json emits "publish_schema" when Some,
operation_spec_schema() advertises the property, and rebuild_spec_for
parses it back (null/absent treated as None, so an imported Pub op no
longer loses the schema). Both dispatch paths (pump_sink and
run_loop_single_stream) validate each call.published chunk's input
against the schema before yielding it to the SinkHandler; on
validation failure an Err(CallError::invalid_input(...)) with the
offending chunk in `details` is injected and the feed terminates,
matching the SinkHandler contract that an Err terminates the stream.
The validator is built once at dispatch time and carried in the
SinkDispatch (and, for the single-stream path, in a new InFlightSink
struct alongside chunk_tx); a schema that fails to compile logs a
warn and falls back to no validation. When publish_schema is None,
chunks are yielded as-is (unchanged behavior).

P-04 — pump_sink matched only call.published/call.completed/call.
aborted and dropped everything else into a debug-log ignore branch; a
call.error from the initiator was silently ignored. Both dispatch
paths now match call.error, parse the CallError from the payload,
inject it as Err(call_error) into the sink's chunk_tx, and terminate
the feed — the handler sees the initiator's error, not a synthetic
"aborted" message. A malformed payload falls back to
CallError::internal("publish error from initiator (malformed)").

P-07 — pump_sink used tokio::join!(feed_fut, handler) and only wrote
the response after both completed; an early-returning handler (e.g.
rejects after chunk 1) had its response deferred until the initiator
finished publishing or the next chunk_tx.send failed. Replaced with a
tokio::select! loop over handler.fuse() and reader.read_frame(): when
the handler completes first, the response is captured and the loop
breaks immediately, the feed is short-circuited (chunk_tx dropped,
the handler's PublishStream sees EOF), and the response is written
without waiting for the feed. The feed-wins branch (natural end /
abort / error / read failure) awaits the handler after the loop as
before. This removes the unbounded stall on a path ADR-046 intends
for long-lived streams.

Tests (15 new, 480 total):
- discovery: spec_to_json emits/omits publish_schema; operation_spec_
  schema documents the property (3).
- from_call: rebuild_spec_for parses publish_schema (present/omitted/
  null) and round-trips with spec_to_json (4).
- dispatch (stream-per-request): publish_schema rejects invalid chunk
  + passes valid chunks + no-schema yields as-is (3); initiator
  call.error terminates with the initiator's error + malformed
  fallback (2); early handler return short-circuits a slow feed and
  the response is not deferred (1).
- channels/client (single-stream): publish_schema rejects invalid
  chunk over channel 0; initiator call.error terminates the publish
  over channel 0 (drives run_loop_single_stream directly with crafted
  frames since the public publish() API takes Stream<Item = Value> and
  cannot emit an initiator call.error) (2).

Verification:
- cargo test              → 480 passed, 0 failed (was 465; +15 new tests)
- cargo clippy --all-targets -- -D warnings → clean
- cargo fmt --check       → clean
- cargo doc --no-deps     → 0 warnings
This commit is contained in:
glm-5.2 committed 2026-08-13 09:43:39 +00:00
1 parent da12d65a03
commit f362c9e596
7 files changed
+994 -55

No files matched your search

+256
View File
@@ -246,6 +246,47 @@ mod tests {
}
}
/// Read one length-prefixed `EventEnvelope` frame off `reader` and
/// parse it into a `ResponseEnvelope`. Used by the single-stream
/// direct-drive tests to read the responder's single
/// `call.responded`/`call.error` frame for a Pub.
async fn read_single_stream_response(
reader: &mut (impl tokio::io::AsyncRead + Unpin),
) -> ResponseEnvelope {
use tokio::io::AsyncReadExt;
let mut len_buf = [0u8; 4];
reader
.read_exact(&mut len_buf)
.await
.expect("read response length");
let len = u32::from_be_bytes(len_buf) as usize;
let mut body = vec![0u8; len];
reader
.read_exact(&mut body)
.await
.expect("read response body");
let env: crate::protocol::wire::EventEnvelope =
serde_json::from_slice(&body).expect("parse response envelope");
let request_id = env.id.clone();
match env.r#type.as_str() {
"call.responded" => ResponseEnvelope::ok(
request_id,
env.payload
.get("output")
.cloned()
.unwrap_or(serde_json::Value::Null),
),
"call.error" => {
let err: crate::protocol::wire::CallError = serde_json::from_value(env.payload)
.unwrap_or_else(|_| {
crate::protocol::wire::CallError::internal("malformed error payload")
});
ResponseEnvelope::error(request_id, err)
}
other => panic!("expected call.responded or call.error, got {other}"),
}
}
/// Build the `install_channel_zero` hook for the accept side: it
/// yields channel 0's `BiStream` once, splits it into the shared
/// writer + reader, constructs a single-stream `CallConnection`,
@@ -485,6 +526,221 @@ mod tests {
);
}
/// Unit 8 / P-03 (single-stream path) — `publish_schema` validation
/// fires on the single-stream dispatch loop: a chunk that violates
/// the schema is injected as an `Err(INVALID_INPUT)` into the
/// `SinkHandler`'s stream, terminating it. The handler observes the
/// valid chunks before the violation plus the error, and its
/// response reflects that. This proves the `EVENT_PUBLISHED` arm of
/// `run_loop_single_stream` validates against the
/// `InFlightSink.publish_validator` before yielding `Ok(chunk)`.
#[tokio::test]
async fn channel_0_publish_schema_rejects_invalid_chunk_on_single_stream() {
use futures::stream::StreamExt;
let publish_schema = serde_json::json!({
"type": "object",
"properties": { "bytes": { "type": "string" } },
"required": ["bytes"]
});
let recording_sink = crate::registry::registration::make_sink_handler(
|_input, ctx, mut stream| async move {
let mut oks: Vec<serde_json::Value> = Vec::new();
let mut err: Option<crate::protocol::wire::CallError> = None;
while let Some(item) = stream.next().await {
match item {
Ok(v) => oks.push(v),
Err(e) => {
err = Some(e);
break;
}
}
}
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({ "ok": oks, "err": err }))
},
);
let mut registry = crate::registry::registration::OperationRegistry::new();
registry
.register(HandlerRegistration::new(
OperationSpec::new(
"fs/upload",
OperationType::Pub,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
)
.with_publish_schema(publish_schema),
HandlerKind::Sink(recording_sink),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let registry = Arc::new(registry);
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alknet/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alknet/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(
make_install_channel_zero(Arc::clone(&registry)),
Arc::new(NoCap),
);
let auth = AuthContext::anonymous(b"alknet/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection(client_conn)
.await
.expect("channel client init");
let call_conn = client
.take_call_connection()
.await
.expect("call connection present");
let chunks = vec![
serde_json::json!({"bytes": "hello"}),
serde_json::json!({"bytes": 42}),
];
let stream: std::pin::Pin<
Box<dyn futures::stream::Stream<Item = serde_json::Value> + Send>,
> = Box::pin(futures::stream::iter(chunks));
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
call_conn.publish("fs/upload", serde_json::json!({}), stream),
)
.await
.expect("channel-0 publish timed out");
let out = response.result.expect("publish ok");
let oks = out.get("ok").and_then(|v| v.as_array()).expect("ok array");
assert_eq!(oks.len(), 1, "only the valid chunk reached the handler");
assert_eq!(oks[0], serde_json::json!({"bytes": "hello"}));
let err = out.get("err").expect("err field");
assert_eq!(
err.get("code").and_then(|c| c.as_str()),
Some("INVALID_INPUT"),
"invalid chunk injects INVALID_INPUT on the single-stream path"
);
}
/// Unit 8 / P-04 (single-stream path) — an initiator-side
/// `call.error` during a publish over channel 0 is routed to the
/// matching in-flight sink's `chunk_tx` as `Err(call_error)`,
/// terminating the stream. The handler observes the chunks before
/// the error plus the initiator's `CallError` (not a synthetic
/// "aborted"). This drives `run_loop_single_stream` directly with
/// crafted frames (a `call.requested`, one `call.published`, then a
/// `call.error`) because the public `publish()` API takes
/// `Stream<Item = Value>` and cannot emit an initiator `call.error`
/// — that path is for a future `Stream<Item = Result<Value,
/// CallError>>` publish API. The `call.error` is injected manually
/// on the wire to prove the single-stream `EVENT_ERROR` arm parses
/// the payload and routes it to the right in-flight sink.
#[tokio::test]
async fn channel_0_initiator_call_error_terminates_publish_on_single_stream() {
use crate::protocol::connection::split_single_stream;
use crate::protocol::dispatch::Dispatcher;
use crate::protocol::wire::EventEnvelope;
use futures::stream::StreamExt;
let recording_sink = crate::registry::registration::make_sink_handler(
|_input, ctx, mut stream| async move {
let mut oks: Vec<serde_json::Value> = Vec::new();
let mut err: Option<crate::protocol::wire::CallError> = None;
while let Some(item) = stream.next().await {
match item {
Ok(v) => oks.push(v),
Err(e) => {
err = Some(e);
break;
}
}
}
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({ "ok": oks, "err": err }))
},
);
let mut registry = crate::registry::registration::OperationRegistry::new();
registry
.register(HandlerRegistration::new(
OperationSpec::new(
"fs/upload",
OperationType::Pub,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
),
HandlerKind::Sink(recording_sink),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let registry = Arc::new(registry);
let request = EventEnvelope::requested(
"pub-ss-err-1",
serde_json::json!({
"operationId": "fs/upload",
"input": {},
}),
);
let published = EventEnvelope::published("pub-ss-err-1", serde_json::json!({"chunk": 1}));
let initiator_error = EventEnvelope::error(
"pub-ss-err-1",
&crate::protocol::wire::CallError::new("PUBLISH_FAILED", "disk full", false),
);
let mut frame_buf = Vec::new();
for env in [&request, &published, &initiator_error] {
let body = serde_json::to_vec(env).expect("serialize envelope");
frame_buf.extend_from_slice(&(body.len() as u32).to_be_bytes());
frame_buf.extend_from_slice(&body);
}
let (response_send, mut response_recv) = tokio::io::duplex(64 * 1024);
let bidi = crate::core::types::BiStream::from_joined(
tokio::io::BufReader::new(std::io::Cursor::new(frame_buf)),
response_send,
);
let (writer, reader) = split_single_stream(bidi);
let call_connection =
Arc::new(CallConnection::new(crate::protocol::sink_empty_connection()));
let dp = Dispatcher::new(registry, Arc::new(NoopIdProvider));
let _server_handle = tokio::spawn(async move {
dp.run_loop_single_stream(call_connection, reader, writer)
.await;
});
let response = read_single_stream_response(&mut response_recv).await;
let out = response.result.expect("handler ok");
let oks = out.get("ok").and_then(|v| v.as_array()).expect("ok array");
assert_eq!(
oks.len(),
1,
"the chunk before the error reached the handler"
);
assert_eq!(oks[0], serde_json::json!({"chunk": 1}));
let err = out.get("err").expect("err field");
assert_eq!(
err.get("code").and_then(|c| c.as_str()),
Some("PUBLISH_FAILED"),
"the initiator's CallError code reaches the handler on the single-stream path"
);
}
/// C-02 / C-03 — the end-to-end acceptance gate for Unit 3
/// (`register_openable`, ADR-047 §3 as amended 2026-08-13 —
/// per-connection registration). Wires `ChannelClient` (connect