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`.
Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.
Quelldateien prüfen
Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.
Mit Codex oder Claude installieren Kopieren Sie diesen Prompt, fügen Sie ihn in Codex, Claude oder einen anderen Assistant ein und lassen Sie die Skill-Seite prüfen und installieren.
Ein direkter Befehl überspringt den Prüf-Prompt. Prüfen Sie die Quelle, bevor Sie ihn ausführen.
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.