feat(tests): the in-process alkcall harness — the serving assembly and the client side over one duplex pair (wave 7)

- the harness lands in src/ops.rs's test tree (#\[cfg(test)] under
  feature ops): serving assembly (OperationRegistryBuilder + CallAdapter
  run loop) and client side (CallClient + spawn_dispatch; ChannelClient)
- the loopback connection source: Connection::from_source with a
  pre-wired multi-stream BidiStreamSource — the QUIC-shaped in-memory
  stand-in stream-per-request needs (a single from_bidi half cannot
  serve spawn_dispatch's call path)
- the channel shape: ChannelsAdapter install hook forks the assembled
  registry, registers the stub openable through
  register_openable_with_establisher (the awaited Establishment phase),
  dispatches via run_loop_single_stream
- the identity/ACL wiring: TestIdentityProvider (token → identity) +
  TestOwnership (namespace → identities → granted actions,
  closed-by-default per OQ-09); call shape rides auth_token resolution,
  channel shape rides the transport-identity seam
- proven with the stub ops: JSON round-trip call → envelope; the ACL
  stub denies anonymous/ungranted and admits granted; the channel stub
  establishes and bytes flow both directions; the refusal arms prove
  ACL-before-establishment and the channel:open_failed/unknown_resource
  reply shape
- dev-dep: async-trait 0.1 (the BidiStreamSource impl); lib tree
  unchanged

verified: cargo test --features ops (88/19/3 green), cargo test
(default, harness compiled out — 73/19/3 green), cargo clippy
--all-targets -- -D warnings on both shapes, cargo fmt --check,
cargo doc --no-deps clean
This commit is contained in:
glm-5.3-flash committed 2026-10-13 00:04:09 +00:00
1 parent 6feaa2ac72
commit bc6da37e35
3 files changed
+813 -8

No files matched your search

+1
View File
@@ -42,4 +42,5 @@ sha2 = "0.10"
tokio-postgres = { version = "0.7", optional = true } tokio-postgres = { version = "0.7", optional = true }
[dev-dependencies] [dev-dependencies]
async-trait = "0.1"
tokio = { version = "1", default-features = false, features = ["rt-multi-thread"] } tokio = { version = "1", default-features = false, features = ["rt-multi-thread"] }
+717
View File
@@ -1294,3 +1294,720 @@ mod tests {
); );
} }
} }
/// The in-process alkcall harness (ops-surface.md §Why alkcall — the
/// embedder's dial-and-hand-over shape, realized server-less): the
/// serving assembly (`OperationRegistryBuilder` receiving the ops
/// family's spec/handler pairs → the dispatch serving stack → the
/// dispatch run loop over one duplex end) plus the client side
/// ([`alkcall::client::CallClient`] + `spawn_dispatch`, and
/// [`alkcall::channels::client::ChannelClient`] for the binary ops'
/// `channel_open` path) and the test identity/ACL wiring the handler
/// tasks exercise their `AccessControl` gates against — namespace →
/// identities → granted actions, closed by default (OQ-09).
///
/// The stub ops registered through this harness (echo, namespace
/// probe, channel open) live only in this test tree; nothing here
/// enters the shipped module surface.
#[cfg(test)]
mod harness {
use super::*;
use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
use std::future::{pending, Future};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::Duration;
use alkcall::channels::adapter::{ChannelsAdapter, InstallChannelZero};
use alkcall::channels::client::ChannelClient;
use alkcall::channels::operations::{
ChannelCore, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
};
use alkcall::channels::policy::NoCap;
use alkcall::client::CallClient;
use alkcall::core::auth::{AuthContext, AuthToken, Identity, IdentityProvider};
use alkcall::core::ownership::OwnershipProvider;
use alkcall::core::types::{
BiStream, BidiStreamSource, Capabilities, Connection, ProtocolHandler, StreamError,
};
use alkcall::protocol::adapter::CallAdapter;
use alkcall::protocol::connection::{split_single_stream, CallConnection};
use alkcall::protocol::dispatch::Dispatcher;
use alkcall::protocol::wire::ResponseEnvelope;
use alkcall::registry::registration::{make_handler, Handler, OperationRegistry};
use alkcall::registry::spec::{
AccessControl, ChannelOpenSpec, OperationSpec, OperationType, Visibility,
};
use alkcall::OperationRegistryBuilder;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
const DUPLEX_BUFFER: usize = 64 * 1024;
const CALL_STREAMS: usize = 8;
const TEST_TIMEOUT: Duration = Duration::from_secs(15);
/// The harness's loopback stream source (the ADR-008 extension
/// point): the QUIC-shaped in-memory stand-in — `open_bi` hands out
/// pre-wired stream pairs (the stream-per-request model the
/// top-level `alk/call` protocol runs; a single `from_bidi` half
/// cannot serve it), `accept_bi` at idle parks (an open connection
/// with no inbound streams), drained queues end it.
struct LoopbackSource {
open: parking_lot::Mutex<VecDeque<BiStream>>,
accept: parking_lot::Mutex<VecDeque<BiStream>>,
addr: Option<SocketAddr>,
}
#[async_trait::async_trait]
impl BidiStreamSource for LoopbackSource {
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
let next = self.accept.lock().pop_front();
match next {
Some(stream) => Ok(stream),
None => pending::<Result<BiStream, StreamError>>().await,
}
}
async fn open_bi(&self) -> Result<BiStream, StreamError> {
self.open
.lock()
.pop_front()
.ok_or(StreamError::StreamClosed)
}
fn remote_addr(&self) -> Option<SocketAddr> {
self.addr
}
fn close(&self, _code: u32, _reason: &str) {
self.open.lock().clear();
self.accept.lock().clear();
}
}
/// One duplex pair per pre-wired call stream: the client's `open_bi`
/// yields end A, the serving side's `accept_bi` yields end B — the
/// wire shape alkcall's own e2e tests run, extended from one stream
/// to many so the harness client can call repeatedly.
fn loopback_call_pair(streams: usize) -> (Connection, Connection) {
let addr = Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 43_21));
let mut client_open = VecDeque::new();
let mut server_accept = VecDeque::new();
for _ in 0..streams {
let (client_end, server_end) = tokio::io::duplex(DUPLEX_BUFFER);
let (client_read, client_write) = tokio::io::split(client_end);
let (server_read, server_write) = tokio::io::split(server_end);
client_open.push_back(BiStream::from_joined(client_read, client_write));
server_accept.push_back(BiStream::from_joined(server_read, server_write));
}
(
Connection::from_source(
LoopbackSource {
open: parking_lot::Mutex::new(client_open),
accept: parking_lot::Mutex::new(VecDeque::new()),
addr,
},
b"alk/call".to_vec(),
),
Connection::from_source(
LoopbackSource {
open: parking_lot::Mutex::new(VecDeque::new()),
accept: parking_lot::Mutex::new(server_accept),
addr,
},
b"alk/call".to_vec(),
),
)
}
fn identity_named(id: &str) -> Identity {
Identity {
id: id.to_owned(),
scopes: Vec::new(),
resources: HashMap::new(),
}
}
fn auth_for(identity: Option<Identity>, alpn: &[u8]) -> AuthContext {
AuthContext {
identity,
alpn: alpn.to_vec(),
remote_addr: None,
tls_client_fingerprint: None,
}
}
/// The test identity provider: token → identity (the alkcall
/// identity seam's token-resolution half; the fingerprint half
/// resolves nothing here).
#[derive(Default)]
struct TestIdentityProvider {
tokens: parking_lot::Mutex<BTreeMap<String, Identity>>,
}
impl IdentityProvider for TestIdentityProvider {
fn resolve_from_fingerprint(&self, _fingerprint: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
self.tokens
.lock()
.get(String::from_utf8_lossy(&token.raw).as_ref())
.cloned()
}
}
/// The test ownership provider — the embedder-registry stand-in:
/// namespace → identities → granted actions, **closed by default**
/// (OQ-09): a namespace with no grants denies every unlisted caller.
#[derive(Default)]
struct TestOwnership {
grants: parking_lot::Mutex<BTreeMap<String, BTreeMap<String, BTreeSet<String>>>>,
}
impl TestOwnership {
fn grant(&self, identity_id: &str, namespace: &str, actions: &[&str]) {
let mut grants = self.grants.lock();
let namespaces = grants.entry(identity_id.to_owned()).or_default();
let granted_actions = namespaces.entry(namespace.to_owned()).or_default();
for action in actions {
granted_actions.insert((*action).to_owned());
}
}
}
impl OwnershipProvider for TestOwnership {
fn owns(
&self,
identity: &Identity,
_resource_type: &str,
resource_id: &str,
action: &str,
) -> bool {
self.grants
.lock()
.get(&identity.id)
.and_then(|namespaces| namespaces.get(resource_id))
.is_some_and(|actions| actions.contains(action))
}
fn owned_resources(&self, identity: &Identity, _resource_type: &str) -> Vec<String> {
self.grants
.lock()
.get(&identity.id)
.map(|namespaces| namespaces.keys().cloned().collect())
.unwrap_or_default()
}
fn owns_any(&self, identity: &Identity, _resource_type: &str) -> bool {
self.grants
.lock()
.get(&identity.id)
.is_some_and(|namespaces| !namespaces.is_empty())
}
}
/// A channel-open op's registration bundle (ADR-047 §3 as amended by
/// ADR-049): the spec (carrying the `channel_open` marker), the
/// establisher (the awaited Establishment phase), and the open
/// handler (the spawned pump over the channel's `Connection`).
#[derive(Clone)]
struct ChannelOpenable {
spec: OperationSpec,
establisher: OpenEstablisher,
open_handler: OpenHandler,
}
struct OpsHarness {
registry: Arc<OperationRegistry>,
identity_provider: Arc<TestIdentityProvider>,
ownership: Arc<TestOwnership>,
}
impl OpsHarness {
/// Assemble over the ops family's spec/handler pairs — the
/// `OperationRegistryBuilder`-mediated registration the embedder
/// assembly runs; the harness serves whatever is registered and
/// nothing else.
fn assemble(specs: Vec<(OperationSpec, Handler)>) -> OpsHarness {
let mut builder = OperationRegistryBuilder::new();
for (spec, handler) in specs {
builder = builder
.with_local(spec, handler, None, None, Capabilities::new())
.expect("stub op registration compiles");
}
OpsHarness {
registry: Arc::new(builder.build()),
identity_provider: Arc::new(TestIdentityProvider::default()),
ownership: Arc::new(TestOwnership::default()),
}
}
fn with_token(&self, token: &str, identity: Identity) {
self.identity_provider
.tokens
.lock()
.insert(token.to_owned(), identity);
}
fn grant(&self, identity_id: &str, namespace: &str, actions: &[&str]) {
self.ownership.grant(identity_id, namespace, actions);
}
/// The call shape's halves over one loopback connection: the
/// serving side runs [`CallAdapter`]'s dispatch run loop
/// (the identity + ownership provider wired); the client side
/// runs [`CallClient::spawn_dispatch`] over the other end.
fn spawn_call_halves(&self) -> CallConnection {
let (client_conn, server_conn) = loopback_call_pair(CALL_STREAMS);
let adapter = CallAdapter::new(
Arc::clone(&self.registry),
Arc::clone(&self.identity_provider) as Arc<dyn IdentityProvider>,
)
.with_ownership_provider(Arc::clone(&self.ownership) as Arc<dyn OwnershipProvider>);
tokio::spawn(async move {
let _ =
ProtocolHandler::handle(&adapter, server_conn, &auth_for(None, b"alk/call"))
.await;
});
let client = CallClient::new(
Arc::clone(&self.registry),
Arc::clone(&self.identity_provider) as Arc<dyn IdentityProvider>,
);
client.spawn_dispatch(client_conn)
}
/// The channel shape's halves over one duplex pair: the serving
/// side installs channel 0 through a [`ChannelsAdapter`], forks
/// the assembled registry (ADR-047 §4's per-connection seam),
/// registers the openables through
/// `register_openable_with_establisher`, and dispatches over the
/// fork — the transport-authenticated identity
/// [`Connection::set_identity`] on channel 0 when given. The
/// client side is [`ChannelClient::from_connection`].
async fn spawn_channel_halves(
&self,
openables: Vec<ChannelOpenable>,
served_identity: Option<Identity>,
) -> ChannelClient {
let (client_end, server_end) = tokio::io::duplex(DUPLEX_BUFFER);
let server_conn = Connection::from_bidi(server_end, b"alk/channels".to_vec(), None);
let install: InstallChannelZero = {
let registry = Arc::clone(&self.registry);
let provider = Arc::clone(&self.identity_provider) as Arc<dyn IdentityProvider>;
let ownership = Arc::clone(&self.ownership) as Arc<dyn OwnershipProvider>;
Arc::new(move |manager, channel0_conn, install_auth| {
let registry = Arc::clone(&registry);
let openables = openables.clone();
let provider = Arc::clone(&provider);
let ownership = Arc::clone(&ownership);
let auth = install_auth;
tokio::spawn(async move {
if let Some(identity) = auth.identity.clone() {
let _ = channel0_conn.set_identity(identity);
}
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(bidi) => bidi,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
let fork = registry.fork();
let core = ChannelCore::new(manager, Arc::new(NoCap));
for openable in &openables {
core.register_openable_with_establisher(
openable.spec.clone(),
Some(Arc::clone(&openable.establisher)),
Arc::clone(&openable.open_handler),
&fork,
auth.clone(),
None,
)
.expect("stub openable registration compiles");
}
let dispatcher = Dispatcher::new(Arc::new(fork), provider)
.with_ownership_provider(ownership);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
dispatcher
.run_loop_single_stream(call_connection, reader, writer)
.await;
})
})
};
let adapter = ChannelsAdapter::new(install, Arc::new(NoCap));
let install_auth = auth_for(served_identity, b"alk/channels");
tokio::spawn(async move {
let _ = ProtocolHandler::handle(&adapter, server_conn, &install_auth).await;
});
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), None);
ChannelClient::from_connection(client_conn)
.await
.expect("channel client establishes")
}
}
/// The stub JSON op — no ACL, echoes the input back. The trivial
/// shape every real call op's envelope ride is proven against.
fn stub_echo() -> (OperationSpec, Handler) {
(
OperationSpec::new(
"blobs/stub-echo",
OperationType::Query,
Visibility::External,
serde_json::json!({"type": "object"}),
serde_json::json!({"type": "object"}),
Vec::new(),
AccessControl::default(),
None,
),
make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
}),
)
}
/// The stub ACL-carrying op — the ops surface's namespace gate in
/// miniature (ops-surface.md §ACL mapping): resource
/// `blob-namespace`, action `read`, resource id selected from the
/// payload's `namespace` field; the ACL verdict is the alkcall
/// dispatch's, evaluated against the harness's ownership wiring.
fn stub_namespace_probe() -> (OperationSpec, Handler) {
(
OperationSpec::new(
"blobs/stub-ns-probe",
OperationType::Query,
Visibility::External,
serde_json::json!({
"type": "object",
"properties": { "namespace": { "type": "string" } },
"required": ["namespace"]
}),
serde_json::json!({"type": "object"}),
Vec::new(),
AccessControl {
resource_type: Some("blob-namespace".to_owned()),
resource_action: Some("read".to_owned()),
..AccessControl::default()
},
Some("$.namespace".to_owned()),
),
make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
}),
)
}
/// The stub channel-open op (the fetch shape: `Sub`, ADR-021/047):
/// `channels/stub/sub` over the `alk/stub` data-plane ALPN. The
/// establisher is the awaited Establishment phase — it records its
/// invocation (so the tests prove it ran ahead of the open reply)
/// and admits `stub-ns` only (`UnknownResource` otherwise, ADR-049
/// §3's open-failed vocabulary), returning [`Establishment::default`]
/// (validation-only, no plan). The pump reads one client→server
/// chunk, records it, writes `b"pong"` back, and ends — the R-02
/// duty (the handler task tracks the data plane) in one act.
fn stub_channel_openable(
establishments: Arc<parking_lot::Mutex<Vec<String>>>,
received: Arc<parking_lot::Mutex<Vec<Vec<u8>>>>,
) -> ChannelOpenable {
let establisher: OpenEstablisher = {
let establishments = Arc::clone(&establishments);
Arc::new(move |input, _auth| {
let namespace = input
.get("namespace")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_owned();
let establishments = Arc::clone(&establishments);
Box::pin(async move {
establishments.lock().push(namespace.clone());
if namespace == "stub-ns" {
Ok(Establishment::default())
} else {
Err(EstablishmentError::UnknownResource {
message: format!("no serving source for namespace {namespace}"),
})
}
})
})
};
let open_handler: OpenHandler = {
let received = Arc::clone(&received);
Arc::new(move |_input, _plan, channel_conn, _auth| {
let received = Arc::clone(&received);
tokio::spawn(async move {
let mut stream = match channel_conn.accept_bi().await {
Ok(stream) => stream,
Err(_) => return,
};
let mut chunk = [0u8; 4];
if stream.read_exact(&mut chunk).await.is_err() {
return;
}
received.lock().push(chunk.to_vec());
let _ = stream.write_all(b"pong").await;
})
})
};
ChannelOpenable {
spec: OperationSpec::new(
"channels/stub/sub",
OperationType::Sub,
Visibility::External,
serde_json::json!({
"type": "object",
"properties": { "namespace": { "type": "string" } },
"required": ["namespace"]
}),
serde_json::json!({
"type": "object",
"properties": { "channel_id": { "type": "integer" } }
}),
Vec::new(),
AccessControl::default(),
Some("$.namespace".to_owned()),
)
.with_channel_open(ChannelOpenSpec::new("alk/stub")),
establisher,
open_handler,
}
}
async fn await_call(fut: impl Future<Output = ResponseEnvelope>) -> ResponseEnvelope {
tokio::time::timeout(TEST_TIMEOUT, fut)
.await
.expect("call resolves")
}
/// The JSON stub op round-trips through the two halves: the
/// dispatch run loop serves, the client calls, the response
/// envelope arrives.
#[tokio::test]
async fn json_stub_op_round_trips_call_to_response_envelope() {
let harness = OpsHarness::assemble(vec![stub_echo()]);
let client = harness.spawn_call_halves();
let response = await_call(client.call(
"blobs/stub-echo",
serde_json::json!({
"message": "round-trip"
}),
))
.await;
assert!(
response.result.is_ok(),
"the stub echo resolves, got {:?}",
response.result
);
assert_eq!(
response.result.unwrap(),
serde_json::json!({ "message": "round-trip" })
);
}
/// The ACL path end-to-end over the call halves (criterion: the
/// identity/ownership wiring exercisable pre-handler): closed by
/// default (OQ-09) — no token → `authentication required`; an
/// identity with no grant → `not owner`; the granted identity →
/// admitted and the handler runs.
#[tokio::test]
async fn grant_gated_stub_denies_ungranted_identities_and_admits_the_granted_one() {
let harness = OpsHarness::assemble(vec![stub_namespace_probe()]);
harness.with_token("token:alice", identity_named("alice"));
harness.with_token("token:stranger", identity_named("stranger"));
harness.grant("alice", "ns-1", &["read"]);
let client = harness.spawn_call_halves();
let payload = |token: Option<&str>| match token {
Some(token) => serde_json::json!({
"operationId": "blobs/stub-ns-probe",
"input": { "namespace": "ns-1" },
"auth_token": token,
}),
None => serde_json::json!({
"operationId": "blobs/stub-ns-probe",
"input": { "namespace": "ns-1" },
}),
};
let anonymous = await_call(client.call_with_payload(payload(None))).await;
match anonymous.result {
Err(error) => {
assert_eq!(error.code, "FORBIDDEN");
assert_eq!(error.message, "authentication required");
}
other => panic!("the unauthenticated probe is denied, got {other:?}"),
}
let stranger = await_call(client.call_with_payload(payload(Some("token:stranger")))).await;
match stranger.result {
Err(error) => {
assert_eq!(error.code, "FORBIDDEN");
assert_eq!(
error.message, "not owner of resource: blob-namespace/ns-1",
"closed-by-default: a resolved identity without the grant is denied"
);
}
other => panic!("the ungranted identity is denied, got {other:?}"),
}
let admitted = await_call(client.call_with_payload(payload(Some("token:alice")))).await;
assert!(
admitted.result.is_ok(),
"the granted identity is admitted, got {:?}",
admitted.result
);
assert_eq!(
admitted.result.unwrap(),
serde_json::json!({ "namespace": "ns-1" }),
"the handler ran with input intact"
);
}
/// The channel path's pipe proven (criterion: the stub
/// `channel_open` op establishes through the awaited Establishment
/// phase and bytes flow both directions): the establisher runs
/// ahead of the open reply, the open op answers `channel_id`, and
/// the client's write crosses to the pump (recorded) and the
/// pump's echo comes back.
#[tokio::test]
async fn channel_stub_op_establishes_through_the_establishment_phase_and_bytes_flow() {
let establishments = Arc::new(parking_lot::Mutex::new(Vec::new()));
let received = Arc::new(parking_lot::Mutex::new(Vec::new()));
let harness = OpsHarness::assemble(vec![stub_echo()]);
harness.with_token("token:alice", identity_named("alice"));
harness.grant("alice", "stub-ns", &["read"]);
let client = harness
.spawn_channel_halves(
vec![stub_channel_openable(
Arc::clone(&establishments),
Arc::clone(&received),
)],
Some(identity_named("alice")),
)
.await;
let (channel_id, mut send, mut recv) = tokio::time::timeout(
TEST_TIMEOUT,
client.open_channel(
"channels/stub/sub",
serde_json::json!({ "namespace": "stub-ns" }),
"alk/stub",
),
)
.await
.expect("the open resolves")
.expect("the stub channel establishes");
assert!(channel_id > 0, "a data channel, not channel 0 itself");
assert_eq!(
*establishments.lock(),
vec!["stub-ns".to_owned()],
"the Establishment phase ran before the open reply"
);
send.write_all(b"ping")
.await
.expect("the client's write crosses");
let mut back = [0u8; 4];
tokio::time::timeout(TEST_TIMEOUT, recv.read_exact(&mut back))
.await
.expect("the echo arrives")
.expect("the echo reads");
assert_eq!(
&back, b"pong",
"bytes flowed client→server and server→client"
);
assert_eq!(
*received.lock(),
vec![b"ping".to_vec()],
"the pump received the client's chunk"
);
}
/// The channel path's refusals (criterion: the ACL path is
/// exercisable on the binary ops' `channel_open` seam too, and the
/// Establishment phase's failure answers in the open reply):
/// an ungranted identity is denied by the registry's ACL gate
/// before the establisher ever runs (establishments empty); a
/// granted identity against a namespace the establisher refuses
/// answers `channel:open_failed` with reason `unknown_resource`
/// through [`ChannelOpenError`]'s typed shape (ADR-049 §3/§4).
#[tokio::test]
async fn channel_open_refusals_deny_before_establishment_and_answer_open_failed() {
let establishments = Arc::new(parking_lot::Mutex::new(Vec::new()));
let received = Arc::new(parking_lot::Mutex::new(Vec::new()));
let mut spec = stub_channel_openable(Arc::clone(&establishments), Arc::clone(&received));
spec.spec.access_control = AccessControl {
resource_type: Some("blob-namespace".to_owned()),
resource_action: Some("read".to_owned()),
..AccessControl::default()
};
let harness = OpsHarness::assemble(vec![stub_echo()]);
harness.with_token("token:stranger", identity_named("stranger"));
let client = harness
.spawn_channel_halves(vec![spec], Some(identity_named("stranger")))
.await;
let denied = tokio::time::timeout(
TEST_TIMEOUT,
client.call_open_op(
"channels/stub/sub",
serde_json::json!({ "namespace": "stub-ns" }),
),
)
.await
.expect("the denied open resolves");
match denied.result {
Err(error) => {
assert_eq!(error.code, "FORBIDDEN", "the ACL gate denies first");
}
other => panic!("the ungranted opener is denied, got {other:?}"),
}
assert!(
establishments.lock().is_empty(),
"the ACL gate runs before the Establishment phase"
);
let second = harness
.spawn_channel_halves(
vec![stub_channel_openable(
Arc::clone(&establishments),
Arc::clone(&received),
)],
Some(identity_named("stranger")),
)
.await;
let refused = tokio::time::timeout(
TEST_TIMEOUT,
second.open_channel(
"channels/stub/sub",
serde_json::json!({ "namespace": "no-such-ns" }),
"alk/stub",
),
)
.await
.expect("the refused open resolves");
match refused {
Err(error) => {
assert_eq!(
error
.call_error()
.map(|call_error| call_error.code.as_str()),
Some("channel:open_failed"),
"the establisher's refusal"
);
assert_eq!(
error.establishment_reason(),
Some("unknown_resource"),
"the refusal reason reaches the typed open error"
);
}
Ok(_) => panic!("the unknown resource was refused, got a channel"),
}
assert!(
received.lock().is_empty(),
"no channel opened; the pump never saw bytes"
);
}
}
+95 -8
View File
@@ -1,7 +1,7 @@
--- ---
id: ops-call-harness id: ops-call-harness
name: the in-process alkcall harness — the serving assembly and the client side over one duplex pair name: the in-process alkcall harness — the serving assembly and the client side over one duplex pair
status: pending status: completed
depends_on: [ops-foundation] depends_on: [ops-foundation]
scope: narrow scope: narrow
risk: low risk: low
@@ -45,17 +45,17 @@ surface is testable server-less.
## Acceptance Criteria ## Acceptance Criteria
- [ ] The harness lives in the ops module's test tree - [x] The harness lives in the ops module's test tree
(`#[cfg(test)]` under feature `ops`), parameterized over the (`#[cfg(test)]` under feature `ops`), parameterized over the
assembled registry (ops register; the harness serves) assembled registry (ops register; the harness serves)
- [ ] Server + client halves over one duplex pair; the dispatch run - [x] Server + client halves over one duplex pair; the dispatch run
loop serves; the client calls and receives envelopes loop serves; the client calls and receives envelopes
- [ ] The identity/ownership wiring exercisable: a stub op carrying - [x] The identity/ownership wiring exercisable: a stub op carrying
an `AccessControl` denies an ungranted identity and admits a an `AccessControl` denies an ungranted identity and admits a
granted one (the ACL path proven end-to-end pre-handler) granted one (the ACL path proven end-to-end pre-handler)
- [ ] The channel path proven: a stub `channel_open` op establishes - [x] The channel path proven: a stub `channel_open` op establishes
(the `Establishment` phase) and bytes flow both directions (the `Establishment` phase) and bytes flow both directions
- [ ] `cargo test --features ops` green; the default build unaffected - [x] `cargo test --features ops` green; the default build unaffected
(the harness is feature-gated test code); (the harness is feature-gated test code);
`cargo clippy --all-targets -- -D warnings` and `cargo clippy --all-targets -- -D warnings` and
`cargo fmt --check` green on both shapes `cargo fmt --check` green on both shapes
@@ -78,8 +78,95 @@ surface is testable server-less.
## Notes ## Notes
> To be filled by implementation agent > Landed as a `#[cfg(test)] mod harness` appended to
> `src/ops.rs` (the ops module's test tree — feature-gated by
> construction, the default build compiles it out; the shipped module
> surface is untouched). All wiring is alkcall's public API:
>
> - **The loopback connection source** (`LoopbackSource` + `Connection::from_source`,
> the ADR-008 extension point): the QUIC-shaped in-memory stand-in the
> top-level `alk/call` shape needs — `open_bi` hands out pre-wired
> stream pairs (one `tokio::io::duplex` pair per stream, joined via
> the public `BiStream::from_joined`), `open_bi` at exhaustion answers
> `StreamClosed`, `accept_bi` at idle parks (an open connection with
> no inbound streams), `close` drains. This was the one place the
> task text needed a ruling: **`spawn_dispatch` + stream-per-request
> cannot complete a call over a single `from_bidi` duplex end**
> (the accept loop consumes the one stream and `call()`'s `open_bi`
> then fails; alkcall's own e2e shape is the same pre-wired
> `BidiStreamSource` stand-in, `pub(crate)` there — reimplemented
> here as the documented downstream seam, N streams wide so the
> client can call repeatedly). The channel shape uses
> `Connection::from_bidi` halves directly (the channels protocol
> multiplexes internally).
> - **The serving assembly**: `OperationRegistryBuilder` receiving the
> stub ops family's spec/handler pairs (`with_local`) →
> `CallAdapter::new(...).with_ownership_provider(...)` →
> `ProtocolHandler::handle` (the dispatch run loop) over its
> `from_source` half; client side
> `CallClient::new(...).spawn_dispatch(...)` over the other.
> `GatewayDispatch` (alkcall's `gateway`-gated transport-neutral
> spine) was not wired — no gateway front-end rides the duplex; the
> wire-serving stack is the `CallAdapter` half.
> - **The channel assembly**: `ChannelsAdapter::new(install_hook,\ NoCap)`
> — the install hook (the ADR-036 amendment shape): sets the
> transport-authenticated identity on channel 0's `Connection`
> (`Connection::set_identity`, the CF-005 (b) path), forks the
> assembled registry (ADR-047 §4's per-connection registration), and
> registers the stub openables through
> `ChannelCore::register_openable_with_establisher` — the awaited
> **Establishment phase** (establisher records its invocation,
> admits `stub-ns` only, returns `Establishment::default()`; refusal
> → `channel:open_failed`/`unknown_resource`) — then
> `Dispatcher::run_loop_single_stream` over
> `CallConnection::new_single_stream`. Client side:
> `ChannelClient::from_connection`, `open_channel` for the binary
> data plane, `call_open_op` for the refusal arms.
> - **The identity/ACL wiring** (`TestIdentityProvider` — token →
> identity — and `TestOwnership` — namespace → identities → granted
> actions, closed by default): the call shape exercises the
> `auth_token` → `IdentityProvider` route (payload `auth_token` via
> `call_with_payload`, alkcall ADR-017 §7's precedence); the channel
> shape exercises the transport-identity route (identity set on
> channel 0's connection in the install hook). Both resolve through
> the dispatch's `AccessControl::check` with the ownership provider
> in context — the ops surface's namespace-as-resource mapping
> (`blob-namespace`, action `read`, `resource_id_path: "$.namespace"`),
> never a re-implemented check (convention 6).
> - **The stubs**: `blobs/stub-echo` (no ACL, echo), `blobs/stub-ns-probe`
> (the ACL op above), `channels/stub/sub` (the channel_open op, over
> the `alk/stub` data-plane ALPN, whose pump reads one chunk, records
> it, and echoes `b"pong"` — the R-02 duty in one act). All live in
> the test tree; nothing registers into the shipped surface.
>
> One dev-dependency entered: `async-trait = "0.1"` (the
> `BidiStreamSource` impl's annotation; cargo dedupes to alkcall's
> version). The lib dependency tree is unchanged.
## Summary ## Summary
> To be filled on completion > Completed 2026-10-13 (wave 7). The in-process alkcall harness landed in
> `src/ops.rs`'s test tree: two wire shapes over in-memory connections —
> the call shape (stub JSON ops registered through
> `OperationRegistryBuilder`; serving side `CallAdapter` with the
> identity + ownership providers wired; client side `CallClient` +
> `spawn_dispatch` over the loopback pair's stream-per-request source)
> and the channel shape (stub openable registered per-connection through
> `ChannelCore::register_openable_with_establisher` — the awaited
> Establishment phase; serving side `ChannelsAdapter`'s install hook
> forking the assembled registry and running `run_loop_single_stream`;
> client side `ChannelClient::from_connection` + `open_channel`).
> Proven by the stub ops: the JSON stub round-trips call → response
> envelope; the ACL stub (namespace = `blob-namespace` resource) denies
> anonymous and ungranted identities (closed-by-default, OQ-09) and
> admits the granted one; the channel stub establishes and bytes flow
> both directions (`ping`/`pong`); the refusals arms prove the ACL gate
> runs before the establisher and that an establisher refusal answers
> `channel:open_failed`/`unknown_resource` through the typed
> `ChannelOpenError`. Four harness tests green (lib 88 with `--features
> ops`, 73 default; all conformance/interop columns unchanged). Gates
> green: `cargo test --features ops`, `cargo test` (default, harness
> compiled out), `cargo clippy --all-targets -- -D warnings` on both
> shapes, `cargo fmt --check`, `cargo doc --no-deps` clean. The
> handler tasks (ops-json-ops, ops-fetch, ops-put) now register their
> spec/handler pairs through `OpsHarness` instead of inventing wiring.