feat(gateway): thread the ownership provider through the dispatch spine (review 012)

- GatewayDispatch carries ownership_provider: Option<Arc<dyn
  OwnershipProvider>> + with_ownership_provider (dispatcher-identical
  builder); the shared root-context builder threads it, covering
  invoke / invoke_streaming / invoke_sink. Default stays None (the
  ADR-011 static fallback) — additive and opt-in, no context shape
  change, no behavior change for existing consumers.
- Door contract documented in the ADR-048 amendment: doors serving ops
  whose authorization truth is a revocable grant table must wire the
  same provider the wire path consults (visible-surface =
  authorized-surface). Anchored downstream by alkgit's LFS POC.
- The loud default (refuse resource_id_path ops with no provider
  wired) is deferred, not decided — tracked in review 012, ruled on
  after the door blast-radius survey; it belongs in the same release
  batch.
- Release placement: committed unreleased — additive work rides the
  next breaking batch (0.10.0's class) instead of publishing 0.9.1,
  keeping the six-consumer downstream churn to one coordinated update.

Verification:
- cargo test --all-features (717 + stub_guard); fuzz/shared corpus
  replay (5); clippy --all-features --all-targets -D warnings; fmt;
  cargo doc --no-deps
- in-crate regression tests pin static-fallback behavior, live-table
  override, revocation live on the gateway surface
- POC-L tests/gateway_gap.rs re-run against published 0.9.0 — green
This commit is contained in:
glm-5.3-flash committed 2026-10-17 08:28:44 +00:00
1 parent 03a6689c99
commit d0b8124064
3 files changed
+304 -13

No files matched your search

@@ -2,7 +2,7 @@
## Status
Accepted
Accepted (amended 2026-10-17 — ownership provider threading; review 012)
## Context
@@ -101,6 +101,72 @@ identity-aware refinement. When an unauthenticated caller hits both,
copy; until then the two implementations coexist (byte-identical in
behavior, one in each crate).
### Ownership provider threading (amendment 2026-10-17 — review 012)
ADR-048's enumeration of what the spine resolves into the root context
omitted the ownership provider: `GatewayDispatch`'s context builder
hardwired `ownership: None` (review 012, anchor
`src/gateway/dispatch.rs`). The promoted spine predates ADR-011's
`Dispatcher::with_ownership_provider` threading on the protocol
dispatch path, so the two surfaces drifted: the same op, same caller,
ran through the dynamic (revocable) grant table on the wire and
through the static `Identity.resources` fallback at a door —
admission-by-claim where the family's posture is admission-by-live-grant.
Downstream evidence: alkgit's LFS POC (POC-L), whose gateway-gap test
pinned the behavior with a real `git-lfs` client against a
namespace-ACL-gated `blobs/stat` (alkblobs).
Amended:
- `GatewayDispatch` carries `ownership_provider: Option<Arc<dyn
OwnershipProvider>>` — private field, wired by
`with_ownership_provider` (the dispatcher-identical builder), read
by the shared root-context builder so all three dispatch entry
points (`invoke`, `invoke_streaming`, `invoke_sink`) thread it.
`None` (the default) keeps the ADR-011 static fallback. No context
shape change (`OperationContext.ownership` stays `Option`-typed,
unchanged) and no behavior change for existing consumers — the
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.
- **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.
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).
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.
### What the spine deliberately does NOT include
- **No HTTP mapping.** `CallError` → status/body is the consumer's
@@ -2,14 +2,37 @@
## Status
Open — filed 2026-10-17. Report-only consumer input; the resolution
(session-owned) either threads an ownership provider through the
gateway dispatch path or blesses/documents the door-side compensation
pattern as the durable answer. Filed here rather than in the consumer
because the anchor is this crate's surface
(`src/gateway/dispatch.rs`), and because the family precedent exists
(review 011 — downstream input filed in the crate that owns the
seam).
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.
Two decisions recorded here as required by the resolution session:
1. **Adopted:** shape 1 — thread an ownership provider through the
dispatch path (`GatewayDispatch::with_ownership_provider`, default
`None` = ADR-011 static fallback), plus the door-contract doc line
from shape 2 folded into the ADR-048 amendment. No context shape
change (`OperationContext.ownership` stays `Option`-typed,
unchanged); the `alkauth`-style shared-vocabulary question for the
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.
Input basis:
+206 -4
View File
@@ -8,8 +8,10 @@
//! every transport (`internal: false` — ACL runs against the caller's
//! identity, not a handler's composition authority; `forwarded_for:
//! None` — wire-ingress only), resolves the registration's
//! composition authority / capabilities / scoped env into it, and
//! bounds Once-ops and sink dispatch with a configurable deadline
//! 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
//! and sink dispatch with a configurable deadline
//! while leaving streaming subscriptions unbounded (ADR-021:
//! subscriptions are long-lived).
//!
@@ -26,6 +28,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::core::auth::Identity;
use crate::core::ownership::OwnershipProvider;
use crate::core::types::Capabilities;
use crate::protocol::wire::{CallError, ResponseEnvelope};
use crate::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
@@ -46,12 +49,16 @@ pub const DEFAULT_DEADLINE: Duration = Duration::from_secs(30);
/// invokes operations for the neutral `ResponseEnvelope` result shape
/// any transport maps to its own wire format. Identity arrives
/// per-call as `Option<Identity>` — bearer resolution, transport
/// framing, and error presentation happen upstream in the consumer.
/// framing, and error presentation happen upstream in the consumer;
/// the ownership provider (review 012 / ADR-048 amendment) arrives
/// once per spine via [`GatewayDispatch::with_ownership_provider`] and
/// is carried by every root context this spine builds.
///
/// See the [module docs](crate::gateway) and ADR-048.
pub struct GatewayDispatch {
registry: Arc<OperationRegistry>,
deadline: Option<Duration>,
ownership_provider: Option<Arc<dyn OwnershipProvider>>,
invoke_count: AtomicUsize,
}
@@ -62,10 +69,28 @@ impl GatewayDispatch {
Self {
registry,
deadline: Some(DEFAULT_DEADLINE),
ownership_provider: None,
invoke_count: AtomicUsize::new(0),
}
}
/// 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.
pub fn with_ownership_provider(mut self, provider: Arc<dyn OwnershipProvider>) -> Self {
self.ownership_provider = Some(provider);
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
@@ -242,7 +267,7 @@ impl GatewayDispatch {
env,
abort_policy: AbortPolicy::default(),
internal: false,
ownership: None,
ownership: self.ownership_provider.clone(),
}
}
}
@@ -784,4 +809,181 @@ mod tests {
"default-ACL ops are fetchable unauthenticated"
);
}
// --- Review 012 / ADR-048 amendment: ownership threading ------------
use crate::core::ownership::{InMemoryOwnershipStore, OwnershipStore};
fn namespace_acl() -> AccessControl {
AccessControl {
resource_type: Some("blob-namespace".to_string()),
resource_action: Some("read".to_string()),
..Default::default()
}
}
fn namespace_registry() -> Arc<OperationRegistry> {
let registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
namespace_spec("ns/stat"),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
fn namespace_spec(name: &str) -> OperationSpec {
let mut spec = spec(name, Visibility::External, OperationType::Query);
spec.access_control = namespace_acl();
spec.resource_id_path = Some("$.namespace".to_string());
spec
}
fn identity_with_static_claim(id: &str, claimed: bool) -> Identity {
let mut resources = HashMap::new();
if claimed {
resources.insert("blob-namespace".to_string(), vec!["read".to_string()]);
}
Identity {
id: id.to_string(),
scopes: Vec::new(),
resources,
}
}
#[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:?}"),
}
}
#[tokio::test]
async fn gateway_with_provider_consults_the_live_grant_table() {
let store = Arc::new(InMemoryOwnershipStore::new());
store
.record(
&identity_with_static_claim("tenant-a", false),
"blob-namespace",
"ns-1",
)
.await
.unwrap();
let dispatch = GatewayDispatch::new(namespace_registry())
.with_ownership_provider(Arc::clone(&store) as Arc<dyn OwnershipProvider>);
let input = serde_json::json!({ "namespace": "ns-1" });
let envelope = dispatch
.invoke(
Some(identity_with_static_claim("tenant-a", false)),
"ns/stat",
input.clone(),
)
.await;
assert!(
envelope.result.is_ok(),
"a live grant admits without any static claim on the identity, got {envelope:?}"
);
let envelope = dispatch
.invoke(
Some(identity_with_static_claim("token-carrier", true)),
"ns/stat",
input.clone(),
)
.await;
match envelope.result {
Err(error) => assert_eq!(error.code, "FORBIDDEN"),
Ok(v) => panic!("a wired provider must override the static fallback, got {v:?}"),
}
store.revoke("blob-namespace", "ns-1").await.unwrap();
let envelope = dispatch
.invoke(
Some(identity_with_static_claim("tenant-a", false)),
"ns/stat",
input,
)
.await;
match envelope.result {
Err(error) => {
assert_eq!(
error.code, "FORBIDDEN",
"revocation is live on the gateway surface, got {error:?}"
);
}
Ok(v) => panic!("revocation must apply on the gateway surface, got {v:?}"),
}
}
#[tokio::test]
async fn gateway_streaming_with_provider_denies_a_static_claim_only_identity() {
let registry = OperationRegistry::new();
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();
let dispatch = GatewayDispatch::new(Arc::new(registry))
.with_ownership_provider(Arc::new(InMemoryOwnershipStore::new()));
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 denied op yields one error envelope"
);
match &envelopes[0].result {
Err(error) => assert_eq!(error.code, "FORBIDDEN"),
Ok(v) => panic!("the provider gate applies to the streaming path too, got {v:?}"),
}
}
}