- 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)
7.3 KiB
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:
- Adopted: shape 1 — thread an ownership provider through the
dispatch path (
GatewayDispatch::with_ownership_provider, defaultNone= ADR-011 static fallback), plus the door-contract doc line from shape 2 folded into the ADR-048 amendment. No context shape change (OperationContext.ownershipstaysOption-typed, unchanged); thealkauth-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. - 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_typewhen 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 liveresource_typeop 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 isresource_typenotresource_id_path(thelistcase silently falls back too), and the loud default carries an explicitwith_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 istests/gateway_gap.rsin/workspace/lfs-poc) — a realgit-lfsclient 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::checkfor a namespace-ACL-gated op (resource_idextracted from the payload — alkblobs'blobs/statwithresource_id_path: $.namespace, External visibility) falls back to the staticIdentity.resourcesmodel 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)
- Thread an ownership provider through the dispatch path — an
Optionon the gateway dispatch builder (per-dispatch config or a connection-establishment property),Noneonly 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). - Bless the compensation: document the door-side pattern (door
builds its own context with ownership wired; never route
External namespace-ACL ops through
GatewayDispatch::invokewithout 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. - 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.