Files
alkcall/docs/reviews/012-gateway-ownership-seam-consumer-input.md
T
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

7.3 KiB
Raw Permalink Blame History

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.