5 Commits
Author SHA1 Message Date
glm-5.3-flash 1a1e4eb7c0 chore(release): 0.10.0 — the gateway ownership seam
The review-012 batch rides this tag: the dispatch-spine ownership
provider threading (with_ownership_provider) and the loud default
(FORBIDDEN, pre-dispatch, for resource_type-gated ops on an unwired
spine) with the with_static_ownership_fallback escape hatch.

Verified: 698 tests (incl. stub guard), clippy --all-targets
--all-features -D warnings, fmt --check, cargo doc, wasm32
check+clippy, fuzz corpus replay 5/5, semver-checks (additive API diff;
two new builder methods only), publish --dry-run clean.
2026-10-17 14:28:57 +00:00
glm-5.3-flash 1fda0d8587 fix(gateway): review-012 regression-pass cleanup on the loud ownership default
- deduplicate the thrice-repeated refusal block into
  GatewayDispatch::ownership_wiring_refusal(&self, op)
- drop the dead unwrap_or("") branch in the refusal predicate
  (let-else: the internal-visibility exemption and the resource_type
  predicate are now separate lets)
- pin the predicate refinement review 012 ruled on: a list-case op
  (resource_type set, resource_id_path None) is refused on an unwired
  spine and served through owns_any when a provider is wired

Verification: cargo test (697), cargo test --all-features (721),
clippy all-targets both configs, fmt --check
2026-10-17 14:17:06 +00:00
glm-5.3-flash 9899b37a82 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)
2026-10-17 14:09:16 +00:00
glm-5.3-flash d0b8124064 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
2026-10-17 08:28:44 +00:00
glm-5.3-flash 03a6689c99 docs(review): 012 — gateway ownership seam, consumer input from alkgit's LFS POC
Report-only: GatewayDispatch::invoke hardwires ownership: None, so
External namespace-ACL ops through the HTTP gateway fall back to the
static Identity.resources model — token-carried claims admit without
consulting revocable grant tables (POC-L tests/gateway_gap.rs).
Three resolution shapes recorded; disposition is session-owned.
2026-10-17 07:28:44 +00:00
7 changed files with 844 additions and 8 deletions

No files matched your search

+70
View File
@@ -4,6 +4,75 @@ All notable changes to this crate are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
this crate adheres to [Semantic Versioning](https://semver.org/).
## [0.10.0] - 2026-10-17
The gateway ownership seam (review 012, downstream evidence from
alkgit's LFS POC — POC-L): the gateway dispatch spine can now consult a
runtime ownership provider, and the previously-silent static fallback
becomes loud — `FORBIDDEN` rather than admission-by-claim — unless the
deployment wires the provider or declares the static fallback
explicitly. This closes the two-authorization-paths drift: a
namespace-ACL-gated op served through a door ran against the
token-carried static claim, while the same op on the native wire
consulted the revocable grant table. One behavior break (the loud
default, below); the wire surface and `OperationContext` shape are
unchanged.
### Added
- **`GatewayDispatch::with_ownership_provider` (ADR-048 amendment
"Ownership provider threading").** An `Option<Arc<dyn
OwnershipProvider>>` on the spine, wired by the dispatcher-identical
builder and read by the shared root-context builder so all three
dispatch entry points (`invoke`, `invoke_streaming`, `invoke_sink`)
thread it into `OperationContext.ownership`. `None` (the default)
previously hardwired the static fallback; now it activates the loud
default below. Regression tests:
`gateway_with_provider_consults_the_live_grant_table` (revocation is
live on the gateway surface) and
`gateway_streaming_with_provider_denies_a_static_claim_only_identity`.
- **`GatewayDispatch::with_static_ownership_fallback`** — the declared
opt-in to the ADR-011 static `Identity.resources` fallback, for
deployments that genuinely have no runtime ownership model. 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. Regression test:
`gateway_static_fallback_opt_out_serves_resource_gated_ops`.
### Changed
- **The loud ownership-wiring default (review 012 decision 2; ADR-048
"The loud default").** The spine refuses (an error envelope,
`FORBIDDEN`, pre-dispatch, all three entry points) an op whose spec
carries `access_control.resource_type` when no ownership provider is
wired and no static fallback is declared. **Behavior break for any
deployment serving `resource_type`-gated ops through an unwired
spine** — the survey (review 012) found no published consumer relies
on the silent fallback (alkhttp, the only live spine consumer, is
unpublished; alkblobs, the only live `resource_type` op producer,
serves native-wire with providers wired). The predicate is
`resource_type` (covering the `list`/`owns_any` case where
`resource_id_path` is `None`), not `resource_id_path`;
`resource_action`-only ACLs and Internal-visibility ops are exempt
(no provider is consulted for those even on the native wire — static
resolution there is family-wide semantics). The refusal message is
operator-directed (names the wired opt-ins) and indistinguishable in
code from a normal ACL denial. Regression tests:
`gateway_refuses_a_resource_gated_op_without_a_provider`,
`gateway_streaming_and_sink_refuse_a_resource_gated_op_without_a_provider`,
and `gateway_refusal_spares_action_only_internal_and_unknown_ops`.
Specs: ADR-048's "Ownership provider threading" and "The loud
default" sections; the finding is
`docs/reviews/012-gateway-ownership-seam-consumer-input.md`.
### Verified
- 698 tests pass (including the stub guard); `clippy --all-targets
--all-features -- -D warnings`, `fmt --check`, `cargo doc`,
wasm32-unknown-unknown check + clippy, fuzz corpus replay
(`cargo test --manifest-path fuzz/shared/Cargo.toml`, 5/5),
`cargo semver-checks`, and `cargo publish --dry-run` clean.
## [0.9.0] - 2026-10-14
The stub sweep: four registered-but-always-failing stubs shipped across
@@ -834,6 +903,7 @@ Vendored core types (`Connection`, `ProtocolHandler`, `BiStream`,
(ADR-046), the channels protocol with openable-ALPNs-as-operations
(ADR-047), and the `ChannelClient` transport-agnostic client.
[0.10.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.10.0
[0.9.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.9.0
[0.8.1]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.8.1
[0.8.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.8.0
Generated
+1 -1
View File
@@ -27,7 +27,7 @@ dependencies = [
[[package]]
name = "alkcall"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"async-trait",
"bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "alkcall"
version = "0.9.0"
version = "0.10.0"
edition = "2021"
rust-version = "1.88"
license = "MIT OR Apache-2.0"
@@ -2,7 +2,7 @@
## Status
Accepted
Accepted (amended 2026-10-17 — ownership provider threading; review 012)
## Context
@@ -101,6 +101,102 @@ 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 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.
- **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_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. 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
- **No HTTP mapping.** `CallError` → status/body is the consumer's
@@ -0,0 +1,139 @@
# Review 012 — The gateway dispatch's hardwired `ownership: None`: downstream evidence from alkgit's LFS POC
## Status
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:
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. **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:
- **POC-L** (alkgit, 2026-10-16; findings at
`/workspace/@alkdev/alkgit/docs/research/pocl-findings.md`; the
evidence test is `tests/gateway_gap.rs` in `/workspace/lfs-poc`) —
a real `git-lfs` client served through an alkhttp extras door
mapping the LFS batch API onto alkblobs' `ops` (`blobs/stat`,
namespace-ACL-gated, External visibility).
- The ask ledger: alkgit's `substrate-recomposition.md`
§Upstream-push candidates #6 (the "alkhttp gateway ownership seam"
entry — filed here under the crate that owns the dispatch spine).
---
## 1. The finding
`GatewayDispatch::invoke`'s `OperationContext` builder hardwires
`ownership: None` when it assembles an external identity's context
(`src/gateway/dispatch.rs`, the builder around lines 215–249; the
`ownership: None` field assignment verified this session). Downstream
consequence, demonstrated live in POC-L:
- `AccessControl::check` for a namespace-ACL-gated op (`resource_id`
extracted from the payload — alkblobs' `blobs/stat` with
`resource_id_path: $.namespace`, External visibility) falls back
to the **static** `Identity.resources` model when no ownership
provider is present in the context.
- Therefore a token-**carried static claim** — e.g. an LFS-style
bearer JWT embedding `{"blob-namespace": ["read"]}` in its
resources map — **is admitted through the gateway with no runtime
provider consulted**, while the same op served through the native
call protocol consults the dynamic (revocable) grant table and
admits only live grants.
- The dynamic grant table is *unreachable* from this dispatch spine:
no thread of the gateway path carries an ownership provider, so a
deployment whose authorization truth is a revocable table gets
stale-at-birth answers on the HTTP-gateway surface — revocation
and grant changes never apply there.
The security frame is the family's standing invariant
(visible-surface = authorized-surface): an External op must not have
two authorization paths with different revocation semantics
depending on which door the caller walked through. The static
fallback is admission-by-claim at the exact point where the family's
posture wants admission-by-live-grant.
## 2. What it means for the consumer (and what they did about it)
The POC-L door cannot use the gateway dispatch spine for these ops.
Its compensation: build the `OperationContext` itself (identity from
the resolved HTTP identity + the ownership provider wired
explicitly) and call `OperationRegistry::invoke("blobs/stat", …)`
directly — after which the namespace ACL runs exactly as it would on
the native wire, against live grant sets (POC-L finding 2, the batch
binding section). The compensation is correct and verified, but it
is per-door hand-assembly of what the dispatch spine exists to do —
every future door (alkhttp's `git` feature, the LFS door proper,
alkssh's surfaces) inherits the same manual step and the same trap of
forgetting it (the spine *works* with the fallback, silently).
## 3. Resolution shapes (recommendation only — own session)
1. **Thread an ownership provider through the dispatch path** — an
`Option` on the gateway dispatch builder (per-dispatch config or
a connection-establishment property), `None` only where a
deployment genuinely has no ownership model. Preserves the
single-spine posture; needs an ADR if the context shape is
frozen-published (alkcall is 0.9, published — the context builder
surface's semver class decides).
2. **Bless the compensation**: document the door-side pattern (door
builds its own context with ownership wired; never route
External namespace-ACL ops through `GatewayDispatch::invoke`
without it) as a *door contract* line in the gateway docs, plus
a loud static-fallback warning at the hardwired site. Cheap, but
the trap stays structural — silent-by-default.
3. **Hybrid**: doc contract now (2), seam threading as the ADR'd
follow-up (1). Probably where the session lands, given 0.9 is
published and the ADR round for the context shape is not open.
**Verification status**: the `ownership: None` anchor and the
builder's context assembly were re-read this session; the admission
behavior (static `Identity.resources` fallback, token-carried claim
admitted) is POC-L's `tests/gateway_gap.rs` result, witnessed in the
findings doc. Re-verify the behavior during resolution by re-running
that test against a current alkcall.
## 4. Non-goal note
Not asking for anything on the native call-protocol path: the
channel-path dispatch carries ownership correctly (the ops wire path
is what the whole family gates through). The finding is scoped to
the gateway dispatch spine used by HTTP-door routing.
+1 -1
View File
@@ -27,7 +27,7 @@ dependencies = [
[[package]]
name = "alkcall"
version = "0.8.1"
version = "0.9.0"
dependencies = [
"async-trait",
"bytes",
+535 -4
View File
@@ -8,8 +8,12 @@
//! 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), 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).
//!
@@ -26,6 +30,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 +51,17 @@ 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>>,
static_ownership_fallback: bool,
invoke_count: AtomicUsize,
}
@@ -62,10 +72,45 @@ impl GatewayDispatch {
Self {
registry,
deadline: Some(DEFAULT_DEADLINE),
ownership_provider: None,
static_ownership_fallback: false,
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) — 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
@@ -106,6 +151,9 @@ 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) = self.ownership_wiring_refusal(&operation_name) {
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 {
@@ -141,6 +189,11 @@ impl GatewayDispatch {
}));
}
let request_id = uuid::Uuid::new_v4().to_string();
if let Some(error) = self.ownership_wiring_refusal(&operation_name) {
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)
@@ -161,6 +214,9 @@ 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) = self.ownership_wiring_refusal(&operation_name) {
return ResponseEnvelope::error(request_id, error);
}
let context = self.build_root_context_sink(&request_id, &operation_name, identity);
let fut = self
.registry
@@ -179,6 +235,15 @@ impl GatewayDispatch {
}
}
fn ownership_wiring_refusal(&self, operation: &str) -> Option<CallError> {
ownership_wiring_refusal(
&self.registry,
operation,
self.ownership_provider.as_ref(),
self.static_ownership_fallback,
)
}
fn build_root_context_sink(
&self,
request_id: &str,
@@ -242,7 +307,7 @@ impl GatewayDispatch {
env,
abort_policy: AbortPolicy::default(),
internal: false,
ownership: None,
ownership: self.ownership_provider.clone(),
}
}
}
@@ -272,6 +337,43 @@ 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 spec = &registry.registration(operation)?.spec;
if spec.visibility == Visibility::Internal {
return None;
}
let resource_type = spec.access_control.resource_type.as_deref()?;
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
@@ -784,4 +886,433 @@ 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,
}
}
// 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() {
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:?}"),
}
}
// --- 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_list_case_resource_gated_ops_are_refused_unwired_and_served_via_owns_any() {
let registry = OperationRegistry::new();
let mut list_spec = spec("ns/list", Visibility::External, OperationType::Query);
list_spec.access_control = namespace_acl();
registry
.register(HandlerRegistration::new(
list_spec,
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let registry = Arc::new(registry);
let envelope = GatewayDispatch::new(Arc::clone(&registry))
.invoke(
Some(identity_with_static_claim("token-carrier", true)),
"ns/list",
serde_json::json!({}),
)
.await;
match envelope.result {
Err(error) => assert_eq!(
error.code, "FORBIDDEN",
"the list case (resource_id_path None) is refused unwired too, got {error:?}"
),
Ok(v) => panic!(
"a token-carried static claim must not admit the list case on an unwired spine, \
got {v:?}"
),
}
let store = Arc::new(InMemoryOwnershipStore::new());
store
.record(
&identity_with_static_claim("tenant-a", false),
"blob-namespace",
"ns-9",
)
.await
.unwrap();
let envelope = GatewayDispatch::new(Arc::clone(&registry))
.with_ownership_provider(Arc::clone(&store) as Arc<dyn OwnershipProvider>)
.invoke(
Some(identity_with_static_claim("tenant-a", false)),
"ns/list",
serde_json::json!({}),
)
.await;
assert!(
envelope.result.is_ok(),
"the wired spine serves the list case through owns_any, got {envelope:?}"
);
}
#[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:?}"),
}
}
}