rask frontend
Every JS/TS file lives under frontend/, its own bun 1.3.14 + Turborepo 2.10.7 workspace root (package.json, bun.lock, turbo.json, knip.json, .oxlintrc.json, .oxfmtrc.json, patches/, assets/). Invoke everything scoped: bun --cwd=frontend run <task>. The --cwd= form matters — bun --cwd <path> with a space silently no-ops.
Workspace membership is globbed (microfrontends/*, packages/*), so a directory carrying a package.json is enrolled automatically and one without it is silently skipped — bun prints "Done!" and the package is never installed, built, or linted.
Styling and component authoring live in rask-styling. Svelte 5 and SvelteKit idioms live in svelte-skills:* and the svelte MCP. This skill is the plane above them: zones, data, composition, gates.
The seven zones
The roster DRIFTED once and cost a wrong estate-wide audit: this table listed
trainand omittedmodelsafter the swap. It is now checkable in one line —git ls-files frontend/microfrontends | cut -d/ -f3 | sort -u— and must agree withMakefileZONES,home/microfrontends.jsonandchart/values.yamlfrontend.apps.
Package name equals directory name for all seven (manifest.test.ts:53). Base is a bare /<zone> — no /default/ segment exists, and cross-zone-reload.test.ts:38 asserts /default/lakehouse is not a zone path.
| zone | base | dev port | nav label | what it is |
|---|---|---|---|---|
home |
'' catch-all |
5273 | Home | The ESTATE LEVEL: / (an insights landing, scaffold-badged), /projects (+ /projects/<id>, the gallery/table list, create, and one project's overview) and /settings (estate config, admin-gated SERVER-side) — plus the OIDC BFF (/auth/{login,callback,logout}) |
lakehouse |
/lakehouse |
5174 | Lakehouse | The big one (dock at /lakehouse/workbench) — areas catalog, lineage, models, admin, workbench (governance went to home's /settings/ at #105); storage is not an area, it is /lakehouse/catalog/storage. The zone ROOT is an Overview of the active project (#109 — hierarchy + its warehouses, ZoneNav.root, the compute precedent), not the 307 into /lakehouse/catalog it used to be. 47 route files, 7 +server.ts routes — 4 keep-bytes (Arrow query/insert, blob bytes + the #113 commit log, the /api/explorer/** storage-browser seam), 1 keep-flow (capi/v1/me), 2 catch-alls; 15 .remote.ts modules carry the rest; the estate's only requestJSON residual — see § Fetching data (b) |
explorer |
/explorer |
5173 | Explorer | Corpus search workbench (with a dock at /explorer/workbench): FTS/vector/hybrid, WebGPU atlas, Cypher KG, Svelte-Flow editor; 6 +server.ts routes (was 13) — 4 keep-bytes + api/search/api/atlas/chunks, which keep their route (multipart / rowid-list POST) but answer Arrow IPC; every JSON value surface rides one of 5 .remote.ts modules (the transport ruling area 3) |
annotator |
/annotator |
5177 | Annotate | PixiJS/WebGPU canvas over Arrow-backed rows, plus a /browse corpus surface; 4 +server.ts routes (was 9) — the Arrow annotations transport, the Arrow annotation-IMPORT proxy (api/tasks/[task_id]/import, requireSession), capi/v1/me, the viewer catch-all; every JSON value surface rides one of 6 .remote.ts modules |
compute |
/compute |
5175 | Compute | Ray/Serve observability + a dock at /compute/workbench. Observability only — it runs nothing. Its one acting surface, the /compute/inference playground (#131's move of /models/playground), was deleted 2026-08-07 along with its api/infer route and COMPUTE_SERVE_URL: it could reach exactly ONE Serve app and its UI was HTR end to end, so studio's flow canvas — ?app=&path= + a typed payload, against the external Ray cluster — was already its general form. Inference now has one door, studio's. Deliberately NOT replaced by a cross-zone leaf: the top navbar owns cross-zone hops and nav-truth.test.ts pins the cross-zone sidebar set as EMPTY |
models |
/models |
5178 | Models | The model plane: the registry at the zone root plus experiments, submit, runs, monitoring, analysis (6 pages). It carried two more and carries neither now: playground never existed as a route in this zone (#131 moved inference to /compute/inference, itself deleted 2026-08-07 — the navbar advertised /models/playground into a 404 the whole time), and pipeline was DELETED 2026-08-07 (a button that fired the medallion cascade head + a train request — an operation on the lakehouse, not a fact about a model, and a second manual writer to an event-driven plane; its medallion.remote.ts, the MEDALLION_API env and the chart row all went with it). It replaced train, on train's own port — train has zero tracked files and is gone from ZONES, microfrontends.json and the chart. A stale untracked microfrontends/train/ (and microfrontends/media/) may still sit on a dev host as build/ + node_modules/ residue; neither is a workspace member (no package.json, so bun skips them SILENTLY) and neither reaches an image (frontend.dockerfile copies one named zone). git clean them, do not read them as zones |
studio |
/studio |
5176 | Studio | The flow builder, and nothing else — a node-based online sandbox over live Ray Serve endpoints AT THE ZONE ROOT ($lib/flows, ssr = false, one +server.ts keep-bytes inference route over @rask/api/serve-proxy, two .remote.ts for Serve/engine discovery). Three panes: the RAIL is the node library (shell sidebarContent, RAY SERVE's live deployments first — each drops a Model node preset to that app), the CANVAS is the graph, the RIGHT pane is the per-node inspector (metadata, wiring, payload preview). The mini-app launcher and the GSAP animation A/B were deleted 2026-08-05, so the zone is ONE surface and its navbar entry is a plain link again — and its nav.ts carries ONE leaf, which is why zone-shell.test.ts's leaf gate now reads the shell's real condition (leaves > 1 || sidebarContent !== undefined) |
Nav labels name the JOB, not the directory — but they agree with it wherever they can: explorer was relabelled from Search once the directory itself was renamed (nav-config.ts:480-491). annotator → Annotate is the one deliberate split left.
The nine packages
Only @rask/ui has a build (svelte-package → dist/); the rest are consumed JIT as raw TS.
| package | what it is |
|---|---|
@rask/ui |
Design system + @rask/ui/shell. → rask-styling |
@rask/api |
Gateway client (ray, ingest, projects, me) plus the OIDC/BFF plane (bff.ts, oidc.ts), the lineage client, and @rask/api/dock-layout + dock-views |
@rask/dockview |
Svelte 5 binding over dockview 7 — the docked workbenches. → § Workbenches |
@rask/flow |
Generic Svelte Flow binding: GraphCanvas, StaticFlow, FlowAutoFit, depths/layout. Mechanism only — domain graphs (LineageGraph, FGA) stay in their zones |
@rask/explorer-api |
Arrow-backed explorer/viewer client (media bytes, Arrow batches) |
@rask/engine |
Framework-agnostic PixiJS/WebGPU annotation canvas (ra-anno lineage) |
@rask/labeling |
The LabelOp model + annotator Arrow-IPC transport |
@rask/zone-contract |
Gates + the dev tooling they guard — the vitest suite on the estate's shape (counts and the scanner self-consistency guards in § Gates), plus two dev scripts that live here so a package's lint/fmt tasks actually cover them: src/proxy.ts (dev:proxy, a hand-rolled composition proxy nothing invokes) and src/dev-zone.ts (dev:zone, behind make dev-zone). It ships no runtime code — nothing imports it |
@rask/config |
One shared tsconfig.base.json — weaker than the inlined copy; see § TypeScript strictness is split |
A frontend/packages/* entry is a LIBRARY, never a domain slice. A zone's panels, stores and
graphs are the zone — moving them into a shared package hollows the zone, couples releases, and
cuts them off from their live stores and per-app remote functions (tried once, reversed:
docs/architecture/global-workbench.md). Cross-zone composition was tried twice and
retired twice — as a shared package (@rask/panels, hollowed the zones) and as runtime custom
elements (the global workbench, starved the panels of remote functions and $app). Extract the
mechanism into a library (@rask/dockview, @rask/flow); keep the domain, its data and its
components in the zone that owns them.
Workbenches — @rask/dockview
A dock lives INSIDE its zone, at ZONE level — /<zone>/workbench (the record is
docs/architecture/global-workbench.md). THREE ship, each composing that zone's OWN components over
its own stores and remotes:
| dock | panels | what they share |
|---|---|---|
/explorer/workbench |
results · atlas · player | ONE Bench search store via createContext — a hit picked in results is the hit the atlas highlights and the player loads |
/lakehouse/workbench |
lineage graph · runs · events · tables · storage | ONE LineageState, polled once, so the DAG and the run board can never be a poll apart; Tables/Storage are the very TableRegistry / ObjectBrowser the catalog pages render |
/compute/workbench |
jobs · cluster · actors · serve | the zone's own getRayJobs/getRayCluster/getActors/getServe remotes on the zone's own poll clock |
ZONE level is load-bearing. The lakehouse's briefly sat at /lakehouse/lineage/workbench, which
buried a zone surface inside one AREA and hid it from anyone standing in catalog or models — it was
reverted for exactly that. dock-reachability.test.ts pins the set EXACTLY, so a nested path is
simply not in the list. A dock is still EARNED by a real multi-panel workflow, never granted by
symmetry: studio is a sandbox, home is the catch-all, the annotator is already
a canvas.
A PANEL RENDERS THE PAGE'S COMPONENT — the panel-is-the-page rule. It is not a smaller re-implementation.
This is the rule the estate has broken twice, in opposite directions, and the second time from inside
a zone where nothing was watching. The compositor was retired because an element could not import a
page component and every panel had to be mirrored — then the in-zone panels were hand-written anyway:
/compute/actors was 416 lines with sorting, filters and search; ActorsPanel was 49 lines, four
columns, no controls, sharing ZERO components. They drifted immediately. Fixed 2026-08-04.
The shape to copy:
| the view lives in | $lib/boards/<X>Board.svelte (compute) or beside its domain ($lib/lineage/RunsBoard.svelte) |
| the route is | <svelte:head> + <XBoard /> — nine lines |
| the panel is | <XBoard />, plus a box if it needs one |
When the panel's DATA differs, pass rows as a PROP — do not give the board its own fetch. The
lakehouse run board is the case: the route reads listRuns on the lineage cursor, the dock's panel
reads the same rows out of the shared LineageState that also feeds the graph and the event feed. One
poll for three panels is the property that earns an in-zone dock; a self-fetching board would have
re-mirrored the view and broken it in one move. So the board owns PRESENTATION (sort, filter,
pagination, drill-in) and the caller owns where the rows came from.
A panel with no page counterpart is fine and is NOT a mirror — EventsPanel renders
LineageState.events, which no route renders (/admin/events is the governance feed, a different
plane). Do not invent a pairing to satisfy the rule.
Adding a dock to a zone? Four things fail QUIETLY if you skip them (all four were skipped once,
each surfacing differently): declare @rask/dockview in that zone's package.json (bun hoisting
hides an undeclared import until the clean container build); @import '@rask/dockview/styles.css' layer(base) in its app.css (else the grid never lays out and panels stack as bare text); give the
zone a session handle if its user-state is OIDC-gated (a bearer-less write 401s and the layout
silently never persists); and make sure the dock's parent is a SIZED flex item — flex: 1 1 0
against a block parent mounts every group at ZERO height, which reads as "the dock is broken" rather
than "the dock has no height".
The cross-zone compositor ZONE that briefly existed is DELETED, and with it the whole custom-element
machinery (per-zone src/lib/elements/**, vite.elements.config.ts, element budgets, the
rask:select contract). Its one capability — mixing panels from different zones — was a workflow
nobody had, and it cost fidelity structurally: an element cannot import a remote function
(endpoints are per-app) or a $app-bound component, so every panel had to be MIRRORED.
dock-reachability.test.ts pins the docks the estate ships (sidebar row AND navbar row), so a dock
can never be unnavigable and a compositor cannot return unnoticed.
A dock's persistence goes through its zone's remote functions. The capi/v1/user-state
proxies died in the transport convergence, so makeDockLayoutStore/makeDockViewsStore take a
fetcher shim ($lib/dock/user-state-fetch.ts) that maps their two calls onto the zone's
readUserStateDoc/writeUserStateDoc and answers with a real Response — the store's three
outcomes (ok / absent / unreadable) survive because a status stays a status.
It is a thin binding, not a wrapper: consumers hold the real DockviewApi and call its
documented methods.
Depend on dockview, never dockview-core. Their type entrypoints are identical
(export * from 'dockview-core'), which makes core look like the leaner honest choice — it is not.
dockview's runtime entry is a 37 KB layer that registerModules(...) for ContextMenu,
KeyboardDocking, AdvancedDnD, TabGroupChips and Accessibility. Import core and all five are
silently absent, including the aria-live announcements; the library logs the mistake once, at
runtime, where nothing fails.
Four invariants. Break any and the dock is wrong:
- Panels mount once.
SveltePanelRenderercalls Svelte'smount()ininit()andunmount()only indispose(). Verified in dockview's shipped bundle: a panel's renderer is built once in theDockviewPanelModelconstructor and disposed only from_doRemovePanelwhenskipDisposeis falsy — every move path re-parents the same instance. So a running interval, an@xyflow/svelteviewport and an open subscription all survive a drag. Anything that mounts outsideinit()breaks it. defaultRenderer: 'always'. Component state survives a move under either renderer, but DOM-held state (scrollTop, focus,<video>position) does not — the defaultonlyWhenVisibleremoves the element from the document. Measured: a list scrolled to 260 px returned at 0 while the panel's own counter ticked straight through.'always'parks panels in the overlay container instead.- Layout is per-subject, not localStorage. One
@rask/api/dock-layoutstore for the estate, over the catalog'sdock-layoutuser-state document on the Dapr state store. Three outcomes —ok/absent/unreadable— andunreadablemust refuse to save. Treat it as empty and the next autosave overwrites a workspace that is still there. - The dock is dynamically imported. ~100 KB gzipped in-bundle, on one route of ~11–25. It must
land in
deferredGzipKB, never the entry graph. Its stylesheet is imported statically (10 KB, and deferring it buys a flash of unstyled dock).
Context crosses the mount boundary: <Dock> captures its own tree with getAllContexts() and hands
it to every panel mount, so a zone uses ordinary createContext above the dock and panels call the
getter. Layout is SSR-read via a remote query() and passed as initial, so the saved arrangement is
the first paint rather than a replacement for seeded defaults.
Chrome shipped 2026-08 (the G1–G4 wave): direction-split menus (SplitMenu, split.ts), the
"+" add-panel picker with search (PanelPicker, registries carry label/icon), whole-panel
watcher alerts (alerts.svelte.ts — bounded, released on dispose, surfaced via PanelProps.alert),
and named views (DockViews store + ViewSidebar — list/active/diverged as runes, persisted in
the catalog's dock-layout-library user-state document, a SEPARATE envelope because DockLayouts
is extra="forbid"). Dock chrome popovers use the native Popover API; scope display under
:popover-open or the author rule beats the UA's closed-state display:none by origin and the
closed popover eats clicks. No GSAP on dock chrome. Popout and floating groups remain unwired —
dndStrategy: 'pointer' (chosen for Linux reliability and Playwright testability) disables
cross-window drag, which is exactly what popout needs; resolve that trade before wiring it.
Fetching data — remote functions are the direction, BFF only where payloads demand it
experimental.remoteFunctions: true + compilerOptions.experimental.async are set in every zone's svelte.config.js. The standing rule (2026-08-03) is one transport per payload KIND:
| Payload | Transport |
|---|---|
| Typed app values (configs, verdicts, registries, small lists) | remote functions — valibot at the boundary, query.live where a change signal exists, single-flight mutations |
| Tabular / bulk / binary (row batches, query results at scale, tiles) | Arrow IPC / raw bytes on +server.ts, streamed |
Both halves are idiomatic SvelteKit — +server.ts is the framework's own tool for non-HTML resources, not a legacy dialect. The reason bytes never ride remote functions is measured, not categorical: devalue (5.8.1, verified in-tree) does carry ArrayBuffer/TypedArrays — as base64 inside the payload string (stringify.js:308) — which costs +33% on the wire, triple-buffers the whole payload (bytes → base64 string → bytes, no streaming), and loses HTTP semantics (content-type, ETags, ranges). Same reason nobody serves images inside JSON. The rule cuts both ways: a JSON route carrying big tabular rows is the mirrored mistake — promote it to Arrow, don't convert it to a remote function (known candidate: lakehouse's table-detail row query returns JSON rows while the explorer reads the same class of data as Arrow). When you touch a JSON value surface still on createBffClient, converge it; do not add new BFF JSON routes. Also permanently +server.ts: the OIDC endpoints (redirect flows, not function calls).
(a) Remote query()/command() — every zone's JSON value plane (the transport-ruling convergence, landed 2026-08-03). A .remote.ts function runs on the zone server and reaches its upstream with the session bearer via getRequestEvent(). On every polled refresh, .refresh().catch(() => {}) is mandatory: one uncaught rejection evicts the query from cache and silently kills the poll loop (compute/src/lib/remote/compute.remote.ts:25-40). The FGA workbench (home/src/lib/remote/access.remote.ts) is the reference migration — it moved to home with the governance surface at #105 and this line pointed at its old lakehouse path until 2026-08-16, where there is no access.remote.ts at all (lakehouse/src/lib/admin/remote/ holds admin.remote.ts + jetstream.remote.ts): queries + a write/delete-tuple command() pair with a single-flight fetchStore().refresh(), ApiResult<T> union returns on the dock-layout precedent (status-driven UI states, not exception flow), valibot parsing at the wire boundary, contracts kept in a sibling non-remote module (a .remote.ts may export only remote functions).
(b) Same-origin BFF — permanently the binary/Arrow planes and the OIDC flow. The JSON convergence is done in six zones: requestJSON has zero call sites in home, explorer, annotator, compute, models, studio. The lakehouse is the residual — THREE requestJSON call sites: the object browser's listObjects/headObject (lib/storage/storage.ts:91,97, over the /api/explorer/** seam) and fetchTableHistory (lib/data/catalog.ts:125, the #113 commit log over the capi/v1/table/[id]/[...rest] proxy). insertRows was a fourth and is not one any more — it converged onto requestBytes (bytes up, JSON ack down), which is what that write always was; its sibling createTableWithRows (catalog.ts, POST /v1/table/{id}/create, the FIRST write of a table) was born on that helper. Converge the remaining three when you touch them; do not add new BFF JSON routes. Elsewhere createBffClient survives only where the payload is bytes (Arrow, blobs, multipart).
(c) Bell only — none left. train held this row and no longer exists; models carries real remotes. The shape it described was: lib/live/feeds.remote.ts, the estate-wide query.live bell. studio left this row when Flows landed: it now carries the bell plus lib/flows/remote/{serve,engine}.remote.ts (Serve-app discovery over /api/serve, the flows engine's catalog) and ONE keep-bytes +server.ts (api/infer, raw image bytes → ALTO XML through @rask/api/serve-proxy), so it is a normal two-transport zone — and it grew the hooks.server.ts it had been the only zone without, because that BFF's 401 guard reads locals the hook populates.
The media-plane BFF reads are AUTHORIZED now, not just proxied (2026-08-04). makeViewerProxy — the api/[...path] catch-all that explorer and annotator both mount — carries requireSession: true (packages/api/src/bff.ts:307-322), so a bearer-less page-image or atlas read 401s at the BFF on an auth-enabled stack. The bearer path is sealed cookie → the zone's session handle → makeBackendProxy (authorization: Bearer …) → gateway → viewer, where /api/page checks can_read_data and /api/pages can_get_metadata on table:<catalog id>, and the S3 object routes behind /api/explorer/** check can_browse_storage. The lakehouse's /api/explorer/[...rest] forwards the caller's bearer but does not requireSession, so an empty /lakehouse/catalog/storage on a governed stack is an authz answer, not an outage.
(d) The bell renders a SUBJECT'S INBOX, and it does so in all seven zones (S3, 2026-08-10). The
surface is split in two and the badge moved with it: an Inbox tab (rows addressed to you, durable
per subject) and an Activity tab (the GET /runs projection, unchanged), with the badge counting
the inbox alone. Until S3 the panel rendered /runs only — dataset-governed, so the count was
everyone's work, and the read state a zone persisted spoke for rows the inbox had never heard of:
mark one read and it came back unread on the next reload, because there was no pointer to write to.
Rendering inbox rows makes the two sets one set, and a dismissed row is simply absent rather than a
set to reconstruct.
Each zone owns a thin $lib/live/inbox.remote.ts (four remote functions: readInboxState,
readInboxFeed, markSeen, dismiss) over the shared @rask/api/inbox; @rask/ui takes the rows
structurally (InboxNotificationLike) and never imports @rask/api. Four facts a reader needs:
- The base is
RASK_GATEWAY_URL, absolute, never a relative/api/…—home/lakehouseproxy/apitoLANCE_GATEWAY_URL(:8001, LINEAGE) andexplorer/annotatorhave no/apiproxy at all, so a relative call 404s in dev and survives in prod only because the chart happens to aim both names at one Service. - Hold the query, don't re-call it. A remote
query()re-called returns the CACHED value, soreadInboxFeed()after a write re-read exactly what was already there and the badge never moved. The zones hold the query object and call.refresh()— the estate's one polling idiom (compute/src/routes/+page.svelte), also flicker-free because the previous value stays readable. inbox === undefinedis the un-wired case and is NOT the same as an empty inbox. No session or no service ⇒ the bell renders NO tabs and falls back to the run feed with per-tab memory, exactly as before S3, which is what keepsmake dev-zoneworking with no cluster behind it.- The badge's server
unreadwins over any local derivation — the rows are one page, so deriving it would shrink the badge as a reader pages.
Gated by @rask/zone-contract's notification-surface.test.ts, now a loop over zoneDirs()
rather than S1's named constant: per zone it asserts the transport exists over the shared client, that
it addresses RASK_GATEWAY_URL, that the seam is bound, that the callbacks REACH the transport, and —
the half no prop-name check can reach — that the layout passes inbox/inboxUnread at all, since a
zone can bind every callback and still hand the bell nothing but runs.
Estate-wide: command() 73 across 22 remote modules (mutations single-flight their reads: void query().refresh() in the handler), form() 0, query.batch() 0, {#await} 0. query.live is the LIVENESS spine, not just the bell: every zone's feeds.remote.ts (the bell) and lakehouse's controlEvents/controlCursor/jetstreamCursor. (The explorer's service-health is NOT one — it is a single deduped poll in lib/service-health.svelte.ts, whose own comment rejects a cursor because liveness has no event; see § When a poll is the right answer below, which is the general form of that exception.) Consume cursors through $lib/live/tick.svelte.ts (liveRead + lineageTick/controlTick) — it replaced thirteen hand-rolled $effect+setInterval pollers, and its rules (open on mount, cursor arrival is not a change) each exist because breaking them broke a test. Data mutations move the LINEAGE cursor; governance mutations (grants, warehouses, tenants — including raw /v1/access/tuples writes, which emit grant_added) move the CONTROL cursor.
When a poll is the right answer
query.live is a cursor over a change signal, not a polling primitive — so the question is never
"can this be a cursor" but "does anything PUBLISH this fact". Audited estate-wide 2026-08-26 across
every setInterval in the zones; 13 call sites, 7 survive, and none of the survivors was the
illegitimate case. Three reasons are legitimate:
| reason | example | why no cursor |
|---|---|---|
| No publisher | the Ray plane (dashboard REST is snapshot-only introspection, no subscribe verb, Event Export deferred to NATS and unwired); liveness probes | nothing emits the change. Riding lineageFeed is actively WORSE: on an idle estate it never moves, so a board renders a dead node alive under a pulsing "live" dot — a surface that LIES beats one that blanks |
| Time itself is the change | LeaseChip (a lease countdown), models Experiments (rate(...[5m]) over a MOVING window) |
the value decays as the clock advances with nothing happening; no event could mean "the window moved" |
| Progress inside a unit of work | ingest units_done, the annotator's publish saga |
climbs between commits and commits once, so no cursor moves for it |
Every surviving timer carries a POLL REASON: marker, enforced by
packages/zone-contract/src/poll-reason.test.ts (which also refuses an empty one). The gate strips
comments before looking for a call, so prose ABOUT the migration is not a false positive.
THE PARTIAL SHAPE IS THE COMMON CORRECT ANSWER, not a compromise: a page usually wants BOTH — the
cursor for the terminal transition (instant, and it is a published fact) and a bounded timer for
progress (which is not). Reference: compute/src/routes/ingest/[run_id]/+page.svelte. The annotator's
tasks/[id] had only the timer half until 2026-08-26, and the omission meant two reviewers on one
project never saw each other's submissions.
A shared clock, not a timer per component. compute/src/lib/live/ray-clock.svelte.ts is the
pattern: a ref-counted module singleton (same shape as explorer's service-health.subscribe()) that
exposes itself as a LiveCursor, so consumers use the ordinary liveRead idiom and the zone owns ONE
interval and ONE phase. Two properties fall out and both were real defects before it:
- A bound. With no subscriber the clock does not tick, so a page with nothing to watch issues nothing — a terminal job stopped re-reading the heaviest Ray call every 5 s forever.
- De-duplication, via
rayClock.refresh(name, query). Key it on an explicit NAME: calling a no-arg remotequery()from two components returns two different WRAPPERS, so aWeakMapon the handle can never collide. That version type-checked, passed 1281 gates and SHIPPED, and deduplicated nothing — measured 14 vs 7 on/compute/workbench. Manual "refresh now" buttons and any timer on a different cadence must NOT go through it.
Verify by counting requests in a browser. The gates here check shape, not effect; both mistakes
above survived every one of them and were caught only by performance.getEntriesByType('resource')
against a deployed zone.
The SSR hairpin
Under svelte-adapter-bun a relative /api/* resolves against the incoming external origin, so a server-side fetch leaves the cluster and comes back. makeGatewayHandleFetch (packages/api/src/gateway.ts:33-44) rewrites origin + '/api/' → gatewayBase + path during SSR.
⚠️ The two wirings disagree on the env var. compute/src/hooks.server.ts:11 reads RASK_GATEWAY_URL; home/lakehouse go through makeZoneHooks(env, {gateway:true}), which reads LANCE_GATEWAY_URL and defaults to http://localhost:8001 (bff.ts:241,267) — the lineage port, not the gateway. Local dev sets only RASK_GATEWAY_URL. Treat a "works in compute, fails in lakehouse" SSR fetch as this.
Develop ONE zone, no cluster — make dev-zone ZONE=<z>
make dev-zone ZONE=lakehouse starts that zone plus its own seed-driven mock upstreams and
nothing else. Measured 2026-08-07: zone serving in 7 s, every route 200 (/lakehouse/,
catalog/tables, lineage, workbench), styled (OKLCH tokens loaded, 13 sidebar links) — with
no k3s, no uvicorn fleet, no container runtime. That last part is the point: it is the only
frontend loop that runs in a cloud sandbox (claude.ai/code, CI), and it is how CI already exercises
5 of 7 zones on a stock ubuntu-latest runner.
make frontend-<zone> is NOT isolated, despite its name. It calls bun run dev:<zone> →
turbo run dev --filter=<zone>..., and turbo 2.10.7 answers that by also starting its built-in
microfrontends proxy on :3024 (the filter closure reaches microfrontends.json). So it dies with
Microfrontends proxy error: Port is not available whenever anything already holds :3024, and the
... closure additionally starts @rask/ui's svelte-package -w — the exact writer
make dev-frontends filters out. dev-zone runs the zone's own vite dev instead: one port, no
proxy, no watcher, safe beside a running composition.
The five zones with mocks are home, lakehouse, explorer, annotator and — since
2026-08-22 — models. compute and studio have no e2e/ and no test:e2e — dev-zone still
starts them and says so, but their /api is unmocked, and it is the same gap that leaves them outside
every local gate.
models was the worst of the three and is now the best-documented fix of the class. Its
package.json declared "test:e2e": "playwright test" against a directory that did not exist in the
tracked tree, so CI ran the script over zero files and PASSED — worse than no gate, because it read as
coverage. dev-zone.ts meanwhile already referenced e2e/mock-upstreams.ts, a file only present on a
dev host. Committing the harness (17 specs, one seed-driven mock upstream on 5284) immediately earned
its first real gate failure: APP_PORT 5285 was already home's notifications mock, and because
reuseExistingServer is on locally that is silent ADOPTION — the suite would have driven a real
server while calling it a mock. Moved to 5298. The lesson generalises: a test:e2e script is not a
gate until something asserts it has files, and a port is not free until the manifest says so.
Four things it deliberately does not give you — none is a bug:
| Populated data is per zone | Mocks answer 404 until seeded (by design — a mock with baked-in fixtures cannot tell a live surface from a dead one), so a zone renders EMPTY unless it ships e2e/dev-seed.ts. lakehouse, annotator, explorer and models have one, all verified rendering real rows (explorer additionally needed VIEWER_API pointed at the mock — its descriptor boot gate rides the [...path] catch-all to a dead :8101 nothing sets, masked in e2e by page.route). The launcher announces a zone with none rather than leaving blank ambiguous. A seed cannot supply the ACTIVE PROJECT, though: it is the host-wide rask_active_project cookie (#103) that opening /projects/<slug> in the HOME zone stamps, and a one-zone stack has no home zone — so the annotator's landing renders its honest "No project is active" card under dev-zone until you set the cookie by hand (document.cookie = 'rask_active_project=default;path=/'); its other surfaces are seeded as before. |
| SEED THE CURSOR OR NOTHING LOADS | The trap, and it cost a debugging round. Most surfaces read on the lineage cursor, not on page load (liveRead(lineageTick, () => refresh())). A hydrated browser showed the first two requests are GET /events?limit=1&summary=true — contract LineageProbeSchema = {events:[{seq:number}]} — and GET /runs. Unseeded, the cursor never opens, liveRead never fires, and the zone requests nothing while showing "Loading…": five correct data seeds, empty page. The cursor env also defaults to a dead :8001, so a zone's stack must set LINEAGE_API to its own mock. curl cannot diagnose this — no hydration, no mount, no requests; only a real browser shows it. |
home is not seedable |
Not a gap in the launcher — a zone decision. Its project gallery is identity-scoped, so under auth-off it answers "No projects to show — sign-in is not configured on this stack" without reading. Its mock served the seeds correctly; the page declined to ask. Needs a real session, not a fixture. |
| Auth is OFF — but mocks still see an identity | dev-zone omits OIDC_*, so locals.authEnabled is false and the zone forwards no bearer — which the mocks 401 by design ("exactly like the real catalog"), so seeded reads would resolve to nothing. The launcher hands the mocks MOCK_DEV_BEARER out of band; unset, every mock behaves exactly as under Playwright (proven: full lakehouse suite, 182 passed, after the patch). For the real governed path — sealed cookie, login-first redirect — run the zone's Playwright suite. |
| A mock may not take the generic envelope | routes uses POST /__mock/seed {bearer, routes}, which the catalog and observability mocks implement. The lakehouse's lineage mock is stateful with its own API (POST /__mock/runs) and 502s anything else, so a seed group can override path/body. Assuming one envelope for all of them earned that 502 on the first run. |
| Cross-zone links 404 | The shared navbar renders all seven entries with data-sveltekit-reload; one zone is listening. Use make dev-frontends + :3024 to cross a boundary. |
| No Dapr / cascade / FGA | Cluster-only. Dapr logic is sandbox-friendly (flows degrades to an inline lane when DAPR_GRPC_PORT is unset); Dapr wiring — component scopes: per app-id, actorStateStore, JetStream delivery — fails silently and fail-closed, and only in-cluster. |
Port numbers are not restated in the launcher: each mock reads its own from <zone>/e2e/ports.ts,
dev-zone.ts imports that same module, and the zone's dev port comes from its vite.config.ts. The env
MAPPING does exist twice (launcher + playwright.config.ts), so dev-zone.test.ts fails if they
disagree — verified by deleting NATS_MONITOR_API from the lakehouse stack and watching it go red.
Also useful: cd frontend/microfrontends/<zone> && CI=1 bunx playwright test needs no setup at all —
it starts its own dev servers and mocks. CI=1 is not optional: reuseExistingServer is on
locally, so without it Playwright silently ADOPTS a foreign dev server and calls it the mock.
Composition — dev and prod share only the base path
One local loop for UI: make dev-frontends — Vite HMR, sub-second, /api mocked or proxied.
(For a single zone use make dev-zone ZONE=<z> above — make dev-frontends binds :3024 and all seven.)
For anything only reproducible IN-CLUSTER (auth/OIDC, FGA, Dapr, the gateway's real routing) there is
no hot loop: build the zone image with dagger call zone-image --zone=<zone> publish … and roll it
out. That costs minutes, not seconds, and it is the deliberate trade — the in-cluster hot-reload
(Tilt) was removed 2026-08-04 because it was a second writer to the cluster and nobody was using it.
Dev. make dev-frontends builds @rask/ui + @rask/api first, then runs turbo run dev --filter='./microfrontends/*'. That filter is load-bearing: an unfiltered turbo run dev also starts @rask/ui's svelte-package -w, which rewrites dist/ while zones read it, and turbo tears the run down.
Turborepo 2.10.7 has a built-in microfrontends proxy. It reads microfrontends/home/microfrontends.json and binds :3024. @vercel/microfrontends is not installed and is not needed. Flow: browser → :3024 → longest-prefix match → 127.0.0.1:517x (vite, strictPort) → SvelteKit with paths.base=/<zone>. No path stripping.
A second, hand-rolled proxy sits at
packages/zone-contract/src/proxy.ts(PROXY_PORT ?? 5200). Itsdev:proxyturbo task is invoked by no root script and no Makefile target, and its claim thatbun run devstarts it is false.:3024is the live dev origin;:5200survives only becauseexplorer's e2e defaults to it.
Prod. One Ingress per release, rules specific-first: /api → rask-gateway:8888, /<zone> → rask-web-<zone>:3000, / → rask-web-home:3000 last. pathType: Prefix, no rewrite-target — the pod receives /compute/jobs and paths.base consumes it. Images are tagged web-<zone>:<tag>.
This works only because of patches/svelte-adapter-bun@1.0.1.patch: upstream roots sirv at client/<base>, but SvelteKit already emits base-prefixed assets inside build/client/, so /compute/_app/x.js resolved to client/compute/compute/_app/x.js → 404. Probes are TCP, not httpGet, because a zone's / 404s under its base.
The estate has TWO levels, and the navbar says which one you are on
Ruled 2026-08-03. isMainMenu(pathname) in nav-config.ts decides which bar renders, and the two
are different functions, not one filtered list:
| Level | Where | Bar |
|---|---|---|
| estate | /, /projects, /settings |
mainMenuNav() → Home · Projects · Settings |
| inside a project | every zone route, and /projects/<id> |
topNav() → Lakehouse · Compute ⟵gap⟶ Explorer · Annotate · Train · Studio |
The boundary that trips people: /projects is the estate level, /projects/<id> is not — opening
a project is what puts you inside one, so its page gets the zone bar. Scoping is by CONTEXT, never
URL: no zone's paths.base, ingress rule or microfrontends.json key encodes a project. (Rejected on
cost, not taste — prefixing zone paths with /projects/<id>/ rewrites eight zones and ~50 links to
express what the switcher already carries. projectFromHost still parses a host-scoped project.)
tier: 'primary'(Lakehouse, Compute) renders a visible gap before the rest — one spacer derived from the d
…(truncated)