diff --git a/docs/architecture/decisions/048-dispatch-spine-gateway-module.md b/docs/architecture/decisions/048-dispatch-spine-gateway-module.md index 001aeaa..ae45422 100644 --- a/docs/architecture/decisions/048-dispatch-spine-gateway-module.md +++ b/docs/architecture/decisions/048-dispatch-spine-gateway-module.md @@ -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>` — 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 diff --git a/docs/reviews/012-gateway-ownership-seam-consumer-input.md b/docs/reviews/012-gateway-ownership-seam-consumer-input.md index bfd812a..657b714 100644 --- a/docs/reviews/012-gateway-ownership-seam-consumer-input.md +++ b/docs/reviews/012-gateway-ownership-seam-consumer-input.md @@ -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: diff --git a/src/gateway/dispatch.rs b/src/gateway/dispatch.rs index 785080f..72ce4db 100644 --- a/src/gateway/dispatch.rs +++ b/src/gateway/dispatch.rs @@ -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` — 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, deadline: Option, + ownership_provider: Option>, 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) -> 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 { + 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); + + 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 = 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:?}"), + } + } }