feat(gateway): the loud ownership-wiring default (review 012 ruling)

- GatewayDispatch now refuses (FORBIDDEN, pre-dispatch) any op whose
  spec gates on access_control.resource_type when no ownership
  provider is wired — invoke, invoke_streaming, and invoke_sink alike
- Predicate is resource_type, not resource_id_path as review 012
  first drafted: AccessControl::check consults the provider whenever
  the resource type is set, including the list/owns_any case; the
  refusal message is operator-directed and names both opt-ins
- Exemptions: resource_action-only ACLs (no provider is consulted on
  the wire either) and Internal-visibility ops (spec-404, no
  acknowledgment leak)
- with_static_ownership_fallback(): declared opt-out to the ADR-011
  static fallback for deployments with no runtime ownership model —
  the door contract becomes enforceable, not just documented
- Superseded the pre-ruling pin
  gateway_without_provider_admits_only_the_static_claim with refusal/
  opt-out/exemption tests (all three entry points covered)
- ADR-048 amendment records the ruling; review 012 status updated

Blast-radius survey: alkhttp is the only live GatewayDispatch
consumer (unpublished, three unwired sites); alkblobs the only live
resource_type op producer (serves native-wire, providers wired);
no published 0.9.0 consumer relies on the silent fallback for such ops.
Ships in the same unreleased batch as the review-012 amendment.

Verification: cargo test (697 pass), cargo test --features gateway
(720 pass), cargo clippy --all-features --all-targets -- -D warnings,
cargo fmt --check, fuzz corpus replay (5 pass)
This commit is contained in:
glm-5.3-flash committed 2026-10-17 14:09:16 +00:00
1 parent d0b8124064
commit 9899b37a82
4 files changed
+406 -89

No files matched your search

@@ -129,43 +129,73 @@ Amended:
threading is opt-in, additive.
- **The door contract** (review 012's compensation pattern, blessed
as a doc-contract line rather than accepted as the durable answer
for anything beyond the no-ownership-model case): the ADR-011
static fallback is correct *only* where a deployment genuinely has
no runtime ownership model. A door (alkhttp feature doors, the LFS
and `git` doors, hub/relay surfaces) serving an op whose
authorization truth is a revocable grant table MUST wire the same
provider the native wire path consults — visible-surface =
authorized-surface. Without a wiring, `GatewayDispatch` on an
External namespace-ACL op silently admits token-carried static
claims: the spine cannot distinguish "no ownership model in this
deployment" from "forgot to wire it", which is why the contract is
a doc line and the loud default below is a separate, surveyed
decision.
as a doc-contract line and — since the loud default below ruled —
enforced by the spine): the ADR-011 static fallback is correct
*only* where a deployment genuinely has no runtime ownership model.
A door (alkhttp feature doors, the LFS and `git` doors, hub/relay
surfaces) serving an op whose authorization truth is a revocable
grant table MUST wire the same provider the native wire path
consults — visible-surface = authorized-surface.
- **Deferred, not decided here — the loud default:** making the spine
refuse (an error envelope) an op whose spec carries
`resource_id_path` when no provider is wired. The refusal is
mechanically precise (the spine can see the spec), but it changes
default outcomes for ownership-relevant ops — consumer-visible —
and needs the blast radius surveyed (which doors route such ops
through `GatewayDispatch` today) before it is ruled on. Tracked in
review 012; decided after the survey. Until then the door contract
above is the answer in force.
- **The loud default (ruled 2026-10-17 — review 012):** the spine
refuses (an error envelope, `FORBIDDEN`) a dispatched op whose spec
carries `access_control.resource_type` when no ownership provider is
wired — `invoke`, `invoke_streaming`, and `invoke_sink` alike,
pre-dispatch. The survey (this session) found the blast radius
minimal: alkhttp is the only live `GatewayDispatch` consumer in the
family and it is unpublished, its three construction sites wire no
provider, and alkblobs is the only live op producer with
`resource_type` ACLs (its own serving sides are native-wire and
correctly wired). The refusal message is operator-directed (names
the wired opt-ins); fail-closed and indistinguishable in code from a
normal ACL denial.
**Predicate refinement:** the refusal predicate is
`access_control.resource_type.is_some()`, not
`resource_id_path.is_some()` as review 012 first drafted.
`AccessControl::check` consults the provider whenever the resource
type is set — including the `list` case (`owns_any`) where
`resource_id_path` is `None`, which silently admits any
token-carried static claim too. `resource_action`-only ACLs are
exempt: no provider is consulted for those even on the native wire,
so static resolution there is family-wide semantics, not gateway
drift. Internal-visibility ops are also exempt (the registry answers
the spec-404; the refusal must not acknowledge a never-visible op).
**The escape hatch:** `with_static_ownership_fallback()` — a
declared opt-in to the ADR-011 static `Identity.resources` fallback
for deployments that genuinely have no runtime ownership model. This
turns the door contract from a doc line into an enforceable
construct: the static fallback survives only as a code-review-visible
declaration, never as the accidental default. The default spine is
loud for `resource_type`-gated ops; wiring
`with_ownership_provider` keeps the live grant table.
Verification: the in-crate regression tests
(`gateway_without_provider_admits_only_the_static_claim`,
`gateway_with_provider_consults_the_live_grant_table`,
`gateway_streaming_with_provider_denies_a_static_claim_only_identity`,
all in `src/gateway/dispatch.rs`) pin the static-fallback behavior,
the live-table override, and revocation live on the gateway surface;
POC-L's `tests/gateway_gap.rs` re-run against the published 0.9.0
remains green (the default path is unchanged).
(`gateway_with_provider_consults_the_live_grant_table`,
`gateway_streaming_with_provider_denies_a_static_claim_only_identity`)
pin the live-table override and revocation live on the gateway
surface; `gateway_refuses_a_resource_gated_op_without_a_provider`,
`gateway_streaming_and_sink_refuse_a_resource_gated_op_without_a_provider`,
and `gateway_static_fallback_opt_out_serves_resource_gated_ops` pin
the refusal (all three entry points), the opt-out, and the preserved
static-fallback mechanics under the declaration;
`gateway_refusal_spares_action_only_internal_and_unknown_ops` pins the
predicate exemptions (the pre-ruling pin
`gateway_without_provider_admits_only_the_static_claim` is superseded —
its unwired-spine-admits behavior is now the refusal and the opt-out
tests). POC-L's `tests/gateway_gap.rs`, written against published
0.9.0, pinned the pre-ruling gap: under this ruling its
unwired-spine second branch now answers `FORBIDDEN` pre-dispatch —
the POC is archival evidence of the hole, not a living conformance
suite; re-run it against the wired or opt-out spine if it is revived.
Semver note: additive — committed to main without a release (no
0.9.x publish); it rides the next release batch (0.10.0's class),
keeping the per-alkcall-release downstream churn to one coordinated
update.
update. The loud default ships in the same unreleased batch as the
review-012 amendment, so no published alkcall version ever carried the
silent unwired-spine answer past 0.9.0.
### What the spine deliberately does NOT include
@@ -2,14 +2,22 @@
## Status
Resolved — filed 2026-10-17, resolved same session. Report-only
consumer input; resolution shape 1 is implemented this session
(ADR-048 amendment "Ownership provider threading"; the threading,
the in-crate regression tests, and the re-run of POC-L's
`tests/gateway_gap.rs` against published 0.9.0 are listed there).
Committed unreleased — additive work rides the next release batch
(0.10.0's class), keeping the six-consumer downstream churn to one
coordinated update.
Resolved — filed 2026-10-17, resolved in two sessions. Report-only
consumer input; resolution shape 1 is implemented (ADR-048 amendment
"Ownership provider threading"; the threading, the in-crate regression
tests, and the re-run of POC-L's `tests/gateway_gap.rs` against
published 0.9.0 are listed there). The deferred loud-default ruling
(shape below, decision 2) was ruled in the follow-up session, after
the blast-radius survey: **adopt, with a refined predicate and an
explicit opt-out** — the spine refuses (`FORBIDDEN`, pre-dispatch, all
three entry points) an op whose spec gates on `access_control.resource_type`
when no provider is wired and no static fallback is declared; the
predicate is `resource_type` (covering the `list`/`owns_any` case the
draft wording missed), and `with_static_ownership_fallback()` is the
declared escape hatch for genuinely-static deployments. Implemented in
the same unreleased batch (ADR-048 "The loud default" section; commit
rides the 0.10.0 release class), keeping the six-consumer downstream
churn to one coordinated update.
Two decisions recorded here as required by the resolution session:
@@ -22,17 +30,20 @@ Two decisions recorded here as required by the resolution session:
record-carried-grants pattern (alkgit ADR-011, alkfs, alkdocker)
is explicitly **not** an alkcall item — pattern-level now,
micro-crate if a third consumer shows up.
2. **Deferred, not decided:** the loud default — refusing (an error
envelope) an op whose spec has `resource_id_path` when no provider
is wired inside the dispatch spine. It is mechanically precise
(the spine can see the spec) but consumer-visible; the blast
radius must be surveyed first (which doors route
`resource_id_path` ops through `GatewayDispatch` today — known
case: alkblobs' `blobs/stat` via alkhttp). Ruled on in a
subsequent session after that survey; until then the door
contract (wire the provider, or hand-assemble the context) is the
answer in force. This ruling, once made, belongs in the same
release batch — the reason it is decided before 0.10.0 publishes.
2. **Adopted (ruled in the follow-up session, superseding the
original deferral):** the loud default — refusing (an error
envelope) an op whose spec gates on `resource_type` when no
provider is wired inside the dispatch spine. Survey outcome: the
blast radius is minimal (alkhttp is the only live spine consumer
and is unpublished; alkblobs is the only live `resource_type` op
producer and serves those ops native-wire with its providers
wired; no published consumer of 0.9.0's spine relies on the
silent fallback for such ops). Two refinements against the draft
wording: the predicate is `resource_type` not `resource_id_path`
(the `list` case silently falls back too), and the loud default
carries an explicit `with_static_ownership_fallback()` opt-out so
the door contract is enforceable, not just documented. Full ruling
in ADR-048's "The loud default" section.
Input basis:
+1 -1
View File
@@ -27,7 +27,7 @@ dependencies = [
[[package]]
name = "alkcall"
version = "0.8.1"
version = "0.9.0"
dependencies = [
"async-trait",
"bytes",
+315 -39
View File
@@ -10,7 +10,9 @@
//! None` — wire-ingress only), resolves the registration's
//! composition authority / capabilities / scoped env into it, carries
//! the spine-wide ownership provider when one is wired (ADR-011; the
//! ADR-048 amendment — review 012's door contract), and bounds Once-ops
//! ADR-048 amendment — review 012's door contract), refuses a
//! `resource_type`-gated op served without a provider or a declared
//! static fallback (review 012's loud-default ruling), and bounds Once-ops
//! and sink dispatch with a configurable deadline
//! while leaving streaming subscriptions unbounded (ADR-021:
//! subscriptions are long-lived).
@@ -59,6 +61,7 @@ pub struct GatewayDispatch {
registry: Arc<OperationRegistry>,
deadline: Option<Duration>,
ownership_provider: Option<Arc<dyn OwnershipProvider>>,
static_ownership_fallback: bool,
invoke_count: AtomicUsize,
}
@@ -70,6 +73,7 @@ impl GatewayDispatch {
registry,
deadline: Some(DEFAULT_DEADLINE),
ownership_provider: None,
static_ownership_fallback: false,
invoke_count: AtomicUsize::new(0),
}
}
@@ -77,20 +81,36 @@ impl GatewayDispatch {
/// Wire the ownership provider the spine's root contexts carry
/// (ADR-011): an op whose `access_control` gates on a
/// `resource_type` then consults this provider with the
/// `resource_id_path`-extracted resource ID at check time — the
/// live, revocable grant table — instead of the static
/// `Identity.resources` fallback. The door contract: any door
/// serving ops whose authorization truth is a dynamic grant table
/// MUST wire the same provider the native wire path consults —
/// visible-surface = authorized-surface (review 012 / ADR-048
/// amendment). The default ([`GatewayDispatch::new`]) is `None` —
/// the ADR-011 static fallback, correct only where the deployment
/// genuinely has no runtime ownership model.
/// `resource_id_path`-extracted resource ID at check time (the
/// live, revocable grant table) — or `owns_any` for the `list`
/// case — instead of the static `Identity.resources` fallback.
/// The door contract: any door serving ops whose authorization
/// truth is a dynamic grant table MUST wire the same provider the
/// native wire path consults — visible-surface =
/// authorized-surface (review 012 / ADR-048 amendment). The
/// default ([`GatewayDispatch::new`]) is `None`, and the default
/// policy is loud: without a provider or a declared
/// [`GatewayDispatch::with_static_ownership_fallback`], a
/// `resource_type`-gated op is refused (an error envelope,
/// review 012's ruling).
pub fn with_ownership_provider(mut self, provider: Arc<dyn OwnershipProvider>) -> Self {
self.ownership_provider = Some(provider);
self
}
/// Declare this deployment's static `Identity.resources` fallback
/// as a deliberate choice: ops whose `access_control` gates on a
/// `resource_type` are served through the ADR-011 static-fallback
/// check (token-carried claims), not refused for the missing
/// provider wiring. Use this only where the deployment genuinely
/// has no runtime ownership model — review 012's door contract
/// makes this a code-review-visible decision, not the accidental
/// default.
pub fn with_static_ownership_fallback(mut self) -> Self {
self.static_ownership_fallback = true;
self
}
/// Override the Once/sink deadline: `Some(duration)` bounds every
/// [`GatewayDispatch::invoke`] and [`GatewayDispatch::invoke_sink`]
/// dispatch (a hung handler surfaces as a retryable `TIMEOUT` error
@@ -131,6 +151,14 @@ impl GatewayDispatch {
return ResponseEnvelope::error(uuid::Uuid::new_v4().to_string(), error);
}
let request_id = uuid::Uuid::new_v4().to_string();
if let Some(error) = ownership_wiring_refusal(
&self.registry,
&operation_name,
self.ownership_provider.as_ref(),
self.static_ownership_fallback,
) {
return ResponseEnvelope::error(request_id, error);
}
let context = self.build_root_context(&request_id, &operation_name, identity);
let fut = self.registry.invoke(&operation_name, input, context);
match self.deadline {
@@ -166,6 +194,16 @@ impl GatewayDispatch {
}));
}
let request_id = uuid::Uuid::new_v4().to_string();
if let Some(error) = ownership_wiring_refusal(
&self.registry,
&operation_name,
self.ownership_provider.as_ref(),
self.static_ownership_fallback,
) {
return Box::pin(futures::stream::once(async move {
ResponseEnvelope::error(request_id, error)
}));
}
let context = self.build_root_context_streaming(&request_id, &operation_name, identity);
self.registry
.invoke_streaming(&operation_name, input, context)
@@ -186,6 +224,14 @@ impl GatewayDispatch {
) -> ResponseEnvelope {
let operation_name = strip_leading_slash(op).to_string();
let request_id = uuid::Uuid::new_v4().to_string();
if let Some(error) = ownership_wiring_refusal(
&self.registry,
&operation_name,
self.ownership_provider.as_ref(),
self.static_ownership_fallback,
) {
return ResponseEnvelope::error(request_id, error);
}
let context = self.build_root_context_sink(&request_id, &operation_name, identity);
let fut = self
.registry
@@ -297,6 +343,44 @@ fn schema_via_call_denial(
schema_disclosure_denial(registry, name, identity)
}
/// The loud ownership-wiring default (review 012's ruling, ADR-048
/// amendment): when the dispatched op's `access_control` gates on a
/// `resource_type` and no ownership provider is wired on the spine,
/// refuse with `FORBIDDEN` rather than silently applying the ADR-011
/// static `Identity.resources` fallback — admission-by-claim at a door
/// is exactly the visible-surface ≠ authorized-surface drift. The
/// predicate is `resource_type` (not `resource_id_path`): `AccessControl::check`
/// consults the provider whenever the resource type is set, including
/// the `list` case (`owns_any`) where `resource_id_path` is `None`.
/// `resource_action`-only ACLs are exempt — no provider is consulted
/// for those even on the native wire, so static resolution there is
/// family-wide semantics, not gateway drift. Internal-visibility ops
/// are exempt too (the registry's dispatch answers `NOT_FOUND`; the
/// refusal must not acknowledge a never-visible op). The
/// [`GatewayDispatch::with_static_ownership_fallback`] declaration is
/// the deliberate escape hatch.
fn ownership_wiring_refusal(
registry: &OperationRegistry,
operation: &str,
ownership_provider: Option<&Arc<dyn OwnershipProvider>>,
static_ownership_fallback: bool,
) -> Option<CallError> {
if ownership_provider.is_some() || static_ownership_fallback {
return None;
}
let registration = registry.registration(operation)?;
let spec = &registration.spec;
if spec.visibility == Visibility::Internal || spec.access_control.resource_type.is_none() {
return None;
}
let resource_type = spec.access_control.resource_type.as_deref().unwrap_or("");
Some(CallError::forbidden(format!(
"operation '{operation}' gates on resource ownership ({resource_type}), but no ownership \
provider is wired on this dispatch spine; the deployment must wire \
GatewayDispatch::with_ownership_provider or declare with_static_ownership_fallback"
)))
}
/// The schema-disclosure check shared by every transport surface:
/// Internal ops are invisible (`NOT_FOUND` regardless of caller);
/// ACL-forbidden ops are denied with `FORBIDDEN`. One implementation so
@@ -858,35 +942,13 @@ mod tests {
}
}
#[tokio::test]
async fn gateway_without_provider_admits_only_the_static_claim() {
let dispatch = GatewayDispatch::new(namespace_registry());
let input = serde_json::json!({ "namespace": "ns-1" });
let envelope = dispatch
.invoke(
Some(identity_with_static_claim("token-carrier", true)),
"ns/stat",
input.clone(),
)
.await;
assert!(
envelope.result.is_ok(),
"the static fallback admits the token-carried claim, got {envelope:?}"
);
let envelope = dispatch
.invoke(
Some(identity_with_static_claim("stranger", false)),
"ns/stat",
input,
)
.await;
match envelope.result {
Err(error) => assert_eq!(error.code, "FORBIDDEN"),
Ok(v) => panic!("the static fallback denies a claimless caller, got {v:?}"),
}
}
// Review 012's ruling replaces the pre-ruling pin
// (`gateway_without_provider_admits_only_the_static_claim`): the
// loud default refuses the unwired spine (see
// `gateway_refuses_a_resource_gated_op_without_a_provider`), the
// opt-out keeps the fallback (see
// `gateway_static_fallback_opt_out_serves_resource_gated_ops`),
// and the wired provider keeps the live-table behavior below.
#[tokio::test]
async fn gateway_with_provider_consults_the_live_grant_table() {
@@ -986,4 +1048,218 @@ mod tests {
Ok(v) => panic!("the provider gate applies to the streaming path too, got {v:?}"),
}
}
// --- Review 012 ruling: the loud ownership-wiring default ------------
#[tokio::test]
async fn gateway_refuses_a_resource_gated_op_without_a_provider() {
let dispatch = GatewayDispatch::new(namespace_registry());
let envelope = dispatch
.invoke(
Some(identity_with_static_claim("token-carrier", true)),
"ns/stat",
serde_json::json!({ "namespace": "ns-1" }),
)
.await;
match envelope.result {
Err(error) => {
assert_eq!(error.code, "FORBIDDEN");
assert!(
error.message.contains("with_static_ownership_fallback"),
"the refusal names the deliberate opt-out, got {error:?}"
);
assert!(
error.message.contains("blob-namespace"),
"the refusal names the resource type, got {error:?}"
);
}
Ok(v) => panic!(
"the loud default must refuse a resource-gated op with no wired provider, got {v:?}"
),
}
}
#[tokio::test]
async fn gateway_streaming_and_sink_refuse_a_resource_gated_op_without_a_provider() {
let registry = OperationRegistry::new();
let registry = {
let mut sink_spec = namespace_spec("ns/put");
sink_spec.op_type = OperationType::Pub;
let mut stream_spec = namespace_spec("ns/stream");
stream_spec.op_type = OperationType::Sub;
registry
.register(HandlerRegistration::new(
stream_spec,
HandlerKind::Stream(make_streaming_handler(|_input, ctx| {
let request_id = ctx.request_id.clone();
futures::stream::once(async move {
ResponseEnvelope::ok(request_id, serde_json::json!({}))
})
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
sink_spec,
HandlerKind::Sink(make_sink_handler(|_input, ctx, _chunks| async move {
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({}))
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
};
let dispatch = GatewayDispatch::new(Arc::clone(&registry));
let mut stream = dispatch.invoke_streaming(
Some(identity_with_static_claim("token-carrier", true)),
"ns/stream",
serde_json::json!({ "namespace": "ns-1" }),
);
let envelopes: Vec<ResponseEnvelope> = stream.by_ref().collect().await;
assert_eq!(envelopes.len(), 1, "the refusal is the only envelope");
match &envelopes[0].result {
Err(error) => assert_eq!(error.code, "FORBIDDEN"),
Ok(v) => panic!("the streaming path refuses unwired resource-gated ops, got {v:?}"),
}
let chunks: crate::registry::registration::PublishStream =
Box::pin(futures::stream::empty());
let envelope = dispatch
.invoke_sink(
Some(identity_with_static_claim("token-carrier", true)),
"ns/put",
serde_json::json!({ "namespace": "ns-1" }),
chunks,
)
.await;
match envelope.result {
Err(error) => assert_eq!(error.code, "FORBIDDEN"),
Ok(v) => panic!("the sink path refuses unwired resource-gated ops, got {v:?}"),
}
}
#[tokio::test]
async fn gateway_static_fallback_opt_out_serves_resource_gated_ops() {
let dispatch = GatewayDispatch::new(namespace_registry()).with_static_ownership_fallback();
let envelope = dispatch
.invoke(
Some(identity_with_static_claim("token-carrier", true)),
"ns/stat",
serde_json::json!({ "namespace": "ns-1" }),
)
.await;
assert!(
envelope.result.is_ok(),
"the declared static fallback serves the op through the ADR-011 fallback, got {envelope:?}"
);
let envelope = dispatch
.invoke(
Some(identity_with_static_claim("stranger", false)),
"ns/stat",
serde_json::json!({ "namespace": "ns-1" }),
)
.await;
match envelope.result {
Err(error) => assert_eq!(error.code, "FORBIDDEN"),
Ok(v) => panic!("the static fallback still denies a claimless caller, got {v:?}"),
}
}
#[tokio::test]
async fn gateway_refusal_spares_action_only_internal_and_unknown_ops() {
let registry = OperationRegistry::new();
let mut action_only = spec("act/run", Visibility::External, OperationType::Query);
action_only.access_control = AccessControl {
resource_action: Some("read".to_string()),
..Default::default()
};
let mut internal_gated = namespace_spec("internal/hidden");
internal_gated.visibility = Visibility::Internal;
registry
.register(HandlerRegistration::new(
action_only,
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
internal_gated,
HandlerKind::Once(make_handler(|_input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({}))
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let dispatch = GatewayDispatch::new(Arc::new(registry));
let mut action_identity_resources = HashMap::new();
action_identity_resources.insert("some-resource".to_string(), vec!["read".to_string()]);
let envelope = dispatch
.invoke(
Some(Identity {
id: "action-holder".to_string(),
scopes: Vec::new(),
resources: action_identity_resources,
}),
"act/run",
serde_json::json!({}),
)
.await;
assert!(
envelope.result.is_ok(),
"a resource_action-only ACL never consults a provider on the wire either — no refusal, got {envelope:?}"
);
let envelope = dispatch
.invoke(
Some(identity_with_static_claim("snooping", false)),
"internal/hidden",
serde_json::json!({ "namespace": "ns-1" }),
)
.await;
match envelope.result {
Err(error) => {
assert_eq!(
error.code, "NOT_FOUND",
"an Internal op stays unacknowledged instead of the refusal"
);
assert!(
!error.message.contains("with_static_ownership_fallback")
&& !error.message.contains("blob-namespace"),
"the refusal text must not leak on an Internal op, got {error:?}"
);
}
Ok(v) => panic!("an Internal op must never run, got {v:?}"),
}
let envelope = dispatch
.invoke(
Some(identity_with_static_claim("anyone", false)),
"missing/op",
serde_json::json!({}),
)
.await;
match envelope.result {
Err(error) => assert_eq!(error.code, "NOT_FOUND"),
Ok(v) => panic!("an unknown op keeps its NOT_FOUND, got {v:?}"),
}
}
}