Gateway (:8888) routing across the rask backend fleet — which service owns an `/api/*` prefix, plus ports and `RASK_*_URL` overrides for compute, controlplane, ingest and the explorer trio (viewer/search/annotator). Use when an `/api/*` call returns `404 no upstream`, `502 upstream unreachable` or a 403; when adding or moving an endpoint or a gateway route row; when changing a port, `RASK_API_PREFIX` or a `RASK_*_URL`; or when reading `scripts/dev-micro.sh`.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Gateway (:8888) routing across the rask backend fleet — which service owns an `/api/*` prefix, plus ports and `RASK_*_URL` overrides for compute, controlplane, ingest and the explorer trio (viewer/search/annotator). Use when an `/api/*` call returns `404 no upstream`, `502 upstream unreachable` or a 403; when adding or moving an endpoint or a gateway route row; when changing a port, `RASK_API_PREFIX` or a `RASK_*_URL`; or when reading `scripts/dev-micro.sh`.
The day-to-day backend map. The gateway on :8888 is a stateless reverse proxy that path-routes /api/* to per-domain services. The old viewer monolith is gone; the batches/orchestrator plane died at P7a; and the R6/R20 media wave (2026-07-28) retired core-api, search-api, and volumes-api — the S3 object browser now lives in the explorer viewer (/api/explorer/object*), and lines/EAD FTS re-land as catalog-governed Lance tables behind /api/explorer/search (docs/architecture/lance-ns-merge.md). scripts/dev-micro.sh is the source of truth for the process list + ports.
⚠️ The frontend's dev proxy is per-zone and inconsistent — there is no single "the SPA targets :8888":
Zones
/api proxies to
compute, studio, models
VIEWER_BACKEND → :8888, the gateway
home, lakehouse
LANCE_BACKEND → :8001, the lineage service — and nothing in dev-micro.sh serves :8001
explorer, annotator
no /api proxy at all; they reach :8101/:8102/:8103 through their own BFF
So a /api/* call that works in compute can 404 or hang in lakehouse. See rask-frontend for the matching SSR base-URL split.
For FastAPI app/router/lifespan idioms see fastapi. This skill is only the topology + invariants.
When to use
Adding or moving an endpoint — pick the owning service and confirm the gateway prefix routes to it.
Debugging a 404 no upstream or 502 upstream unreachable seen through the SPA.
Changing a port or pointing the gateway at a remote backend via RASK_*_URL.
Reading/editing scripts/dev-micro.sh or wiring a new service into the fleet.
Fixed port map + env overrides
scripts/dev-micro.sh exports *_PORT defaults; the gateway reads RASK_*_URL (localhost defaults below) so you can point it at remote/containerized backends without touching code.
Service
Port
Gateway override env
Lifespan builds
gateway
8888
— (it is the proxy)
httpx.AsyncClient + route table only
compute
8804
RASK_COMPUTE_URL
dashboard httpx client + Ray Job SDK client
controlplane
8820
RASK_CONTROLPLANE_URL
k8s client (read-only Project CRs for the home picker)
explorer viewer
8101
RASK_EXPLORER_VIEWER_URL
lazy DatasetRegistry; the S3 object browser builds its client per store from the catalog's storage registry (RASK_STORES) — a store declaring a secret gets those creds from the Dapr secret store, fail-closed and lru_cached, never the process env (the old env-only s3_client() read the external raw tier against the warehouse and listed it as empty). FGA-gated since #90 — see invariant 10
explorer search
8102
RASK_EXPLORER_SEARCH_URL
descriptor-driven Lance search
annotator
8103
RASK_EXPLORER_ANNOTATOR_URL
annotations plane
ingest
8830
RASK_INGEST_URL
the pre-bronze acquisition plane (control API + workers + the lander) — dev-micro.sh does NOT start it, so /api/ingest/* answers 502 upstream unreachable against the local fleet
flows
8840
RASK_FLOWS_URL
the studio flow-builder's server half (/api/flows/{catalog,validate,runs}): an httpx client + an in-memory run store, and a Dapr WorkflowRuntime that starts only when DAPR_GRPC_PORT is set (no sidecar → the inline lane, logged once). dev-micro.sh DOES start it. Its row is prefix-interpolated (), not a literal — the ingest lesson applied rather than restated
The gateway also carries the lakehouse rows (/api/catalog, /api/lineage, /api/produce, /api/train) plus the ingest row /api/ingest → the ingest plane (RASK_INGEST_URL, :8830) — see gateway/__init__.py::_routes(). Two traps in that row. It rewrites to /api, not /v1: the ingest module's own docstrings say /v1/ingests, which is the ROUTER's path beforemake_service_app prepends settings.api_prefix, and the /v1 version that shipped 404'd every call through the gateway. And the /api/ingest-iiif row is GONE (corrected 2026-08-09 — this skill described it as a live deprecated sibling long after A12 removed it). A12 deleted the medallion route it pointed at, so keeping the row made it 502 rather than 404 — the worse failure of the two, because it names a backend as broken instead of the path as absent. The ORDERING PROPERTY it demonstrated is still load-bearing and still tested: _pick_route requires path == prefix or path.startswith(prefix + "/"), so a /api/ingest-iiif row could never have matched the /api/ingest row anyway — the next character is -, not /. services/gateway/tests/test_routing.py pins that.
Load-bearing invariants
No fleet service owns relational state. The batches table + Alembic lineage were deleted at P7a; the only databases left are the chart-managed lineage (AGE) and OpenFGA stores, owned by the lance services. Never add a DB engine to a fleet lifespan.
Each service builds only its own app.state subset in its own lifespan. The compute service opens only the dashboard/job clients. Don't widen a lifespan to grab resources the service doesn't use.
Longest-prefix-first routing, NO catch-all.gateway/__init__.py::_routes() returns prefixes most-specific-first; _pick_route returns the first whose path == prefix or path.startswith(prefix + "/"). Order: the deep explorer rows (/api/explorer/search, /api/explorer/annotations) before /api/explorer, then the lakehouse rows, then /api/ingest, /api/train, {prefix}/ray, {prefix}/projects, {prefix}/flows, {prefix}/notifications, /api/serve. There is no bare /api row since R6/R20 — an unmatched /api/* 404s with no upstream. A new public prefix needs its own route row.
/api/serve and /api/ray both go to the compute service (the URL namespace names the Ray cluster, not the service — R22: the SERVICE is compute on every surface — uv member, import, k8s/dapr/image — while the public paths stay /api/ray + /api/serve), but for different reasons: domain routers mount under RASK_API_PREFIX (/api/v1), while its proxy_router mounts at the root (no prefix) so /api/serve/* reaches the Ray Serve status API. Routers vs proxy_router is the make_service_app distinction.
502 contract. On httpx.RequestError (upstream not started / crashed / wrong port) the gateway raises HTTPException(502, "upstream ... unreachable") — a clean 502, never a 500 traceback. An unmatched path is a 404 no upstream. Use the 502 to tell "backend down" from "wrong route."
Hop-by-hop headers are stripped both ways (: connection, keep-alive, te, trailers, transfer-encoding, upgrade, host, proxy-*) per RFC 7230 §6.1. Responses stream back via . Don't re-add /.
Gotchas
RASK_API_PREFIX's code default is /api/v1, and nothing uses it. Every deployment sets /api (chart/values.yaml under config:; scripts/dev-micro.sh; .env.example). Leave it unset and /api/ray and /api/projects silently move to /api/v1/... — off the paths every frontend client hardcodes. Gateway routing is built from the same value, off GatewaySettings (services/gateway/src/gateway/config.py) — a pydantic-settings model with env_file=".env", so it reads the same .env the services do; keep them in sync. It used to be sixteen raw os.environ.get() reads with a load_dotenv() in front of only some of them, which is why RASK_DOCS in a .env was silently ignored (FLEET-ENV-SCATTER).
scripts/dev-micro.sh deliberately does NOT bash-source .env. Each service loads it via python-dotenv so JSON-list settings like RASK_CORS_ORIGINS=["..."] parse correctly; bash sourcing strips the quotes. Export only vars not in .env.
f"{prefix}/flows"
/api/flows
/v1
notifications
8850
RASK_NOTIFICATIONS_URL
the per-subject inbox behind the bell (/api/notifications/inbox{,/unread,/seen,/dismiss}): one Dapr InboxActor per subject holding claim-check pointers, fed by two ingresses: a lineage.events.v1 subscription on its own pubsub component, and — because the ingest service, Ray TRAIN and every external OpenLineage producer emit over HTTP only and never reach the topic — a bindings.cron tick (notifications-reconcile-cron, @every 30s) that walks lineage's durable GET /events down from a persisted cursor. Lineage self-prunes that feed inline on every ingest, and the prune cannot consult this cursor — it lives in notifications' Dapr state store, which lineage is not scoped to — so GET /events reports oldest_seq (the floor it still retains) and the reconciler compares it against its own mark. A mark below that floor means rows were deleted before this lane read them: ReconcileResult.gapped, an ERROR (lineage_feed_pruned_below_cursor) and the notifications.feed.gaps counter. Distinct from truncated, which is the walk running out of PAGES — the pruned case exits through the success door (next_cursor: None reads as "caught up") and was invisible before. That cron route is root-mounted, not under the api prefix: Dapr delivers an input binding to POST /<component name> at the pod root, so the Component name, RASK_NOTIFICATIONS_BINDING_NAME and the served path are one string (all three rendered from services.notifications.reconcileBindingName, and pinned together by tests/unit/test_invariants.py). Row is prefix-interpolated like flows'. dev-micro.sh DOES start it, but with no sidecar — require_actor_plane then answers every inbox route 503 with the reason rather than 500ing, so the badge is honestly empty, not broken. In-cluster it needs four separate values entries or it is silently useless: notifications in stateStore.scopes (no actor state store → actor hosting disabled → a healthy pod with a permanently empty bell), daprIngest: true (no APP_API_TOKEN → the bus handler's assert_app_token_configuredcrash-loops the pod), its row in dapr-resiliency.yaml (no sidecar retry, no dead-lettering), and env.RASK_LINEAGE_SERVICE_IDENTITY (the ONE declaration services.yaml scans to build lineage's LINEAGE_SERVICE_SUBJECTS allowlist and the claim the reconciler sends — omit it and every feed tick 401s, which reads as a credential fault rather than a service never admitted). Admission is only half: the feed is governed, so a subject that is allowlisted but granted nothing gets a reconciler that runs cleanly and reconciles nothing
_HOP_BY_HOP
StreamingResponse(aiter_raw(), background=aclose)
Host
Transfer-Encoding
Merged /docs. The gateway intercepts {prefix}/openapi.json and {prefix}/docs itself: _merged_openapi fans out to every distinct upstream's openapi.json and merges paths+components.schemas into one spec, skipping unreachable backends (logged, not fatal). So the gateway's /docs shows the whole fleet.
The storage browser's chain is BFF-shaped: lakehouse zone /lakehouse/api/explorer/* (SvelteKit route) → gateway /api/explorer/* → viewer /api/* (/api/explorer/objects → /api/objects). Dev needs the viewer running (dev-micro.sh starts it); in-cluster it needs explorer.enabled + the viewer's rustfs netpol allowlist entry — and, with auth.enabled, a bearer whose subject holds can_browse_storage on RASK_FGA_ROOT_OBJECT (owner/estate tier, deliberately not per-store: the shipped default stores come from DEFAULT_STORES in code and would never get tuples). chart/templates/explorer.yaml sets RASK_OIDC_* + RASK_FGA_* for all three explorer services — it was if and (eq $name "annotator") auth.enabled, so the viewer streamed page images and browsed S3 wide open on an auth-enabled estate; the vars change behaviour only where a route declares an auth dependency, so search is unaffected. The lakehouse proxy forwards the signed-in user's bearer but does not requireSession, so an anonymous browse arrives credential-less and is denied at the viewer, not at the BFF. Dev stays open — RASK_FGA_ENABLED unset ⇒ the checker is permissive by construction.
Paths are canonicalized before matching._normalize_path (gateway/__init__.py) collapses ., .., and duplicate slashes, preserving a trailing slash — so ../// variants can neither dodge the 403 blocklist nor slip past a longer prefix into a shorter one. This replaced nginx's merge_slashes + URI normalization.
A 403 has two possible authors, and only one of them is the gateway. The gateway's own 403 is lineage_sidecar_guard (gateway/__init__.py), which prefix-matches the normalized, case-folded path against _lineage_sidecar_only_routes() and returns 403 {"detail": "sidecar-only lineage route: <route>"} before the /api/lineage proxy runs — the nginx lance.lineageSidecarOnlyRoutes blocklist rewritten in Python, with the services' own app-api-token check still the load-bearing guard. Every OTHER 403 through the gateway is a proxied FGA denial: since #90 the viewer gates /api/datasets + /api/pages on can_get_metadata, /api/page (image bytes) on can_read_data, and /api/object{,s,/download} on can_browse_storage against RASK_FGA_ROOT_OBJECT (viewer/api/security.py), and the annotator gates its task plane. Authorization is not hop-by-hop, so the gateway forwards the bearer the BFF attached untouched and the service is what verifies it. Read the detail to tell them apart: the sidecar guard names a route; an FGA denial reads <subject> lacks <relation> on <object>.
The annotator mounts two planes, and the gateway publishes ONE ROW of one of them. Edge-reachability is the gateway table's call, never the path's shape: the only annotator row is /api/explorer/annotations → /api/annotations, so /api/assist and /api/jobs carry the /api prefix and are in-cluster only (jobs.py states this correctly). /projects and /tasks are the actor plane — no gateway row, deliberately, because the annotator zone's SSR calls them directly in-cluster on ANNOTATOR_PROJECTS_API (frontend/microfrontends/annotator/src/lib/server/doors.ts). require_actor_plane is attached to the /tasks router alone (tasks.py:84), so an unregistered actor plane is a 503 there and a 500 on /projects. Do not "unify" the prefixes: /api/projects already belongs to the controlplane (invariant 3's {prefix}/projects row), and publishing the actor plane at the edge is a separate decision a prefix move would not make. services/annotator/tests/test_route_prefixes_are_declared_in_one_place.py pins the mounted set and refuses a third shape.