Mayavi
Platform & pipeline·6 min read

From Notebook to Vercel: Mayavi's Deployment Architecture

Modal for the FastAPI service. Vercel for the dashboard and landing. DuckDB for run persistence. Auto-deploy on every push to main. Bearer-token auth — and the leak we caught before going public. The deployment that ties the engine to the dashboard. Series closes.

Part of the Platform & pipeline series.

This is the last post in the 15-post series. It walks the deployment: what runs where, how a git push origin main becomes a live update on app.mayavi.sambhal-labs.com minutes later, and the one security incident the deployment design caught before it shipped.

The topology

Four services, three vendors, one auth secret:

ServiceHostURLSource
FastAPI (mayavi.api)Modalmorellato26--mayavi-api-fastapi-app.modal.runmayavi/api/modal_app.py
Dashboard (Next.js)Vercelapp.mayavi.sambhal-labs.comweb/
Marketing landingVercelmayavi.sambhal-labs.comlanding/ (separate Vercel project)
Umbrella siteVercelsambhal-labs.comexternal repo (sambhal-labs/sambhal-labs-website)

Three things to notice:

  1. The dashboard talks to the API across origins. app.mayavi.sambhal-labs.commorellato26--mayavi-api-fastapi-app.modal.run. Bearer-token auth.
  2. api.mayavi.sambhal-labs.com is deliberately not provisioned. Modal gates custom domains behind a paid tier. We use the default Modal URL until a paying customer or funding round justifies the upgrade.
  3. The marketing landing is a separate Vercel project from the dashboard, with its own design tokens (mirrored in landing/styles.css). Brand parity, deploy independence.

Run persistence: DuckDB on a Modal Volume

The API service writes every submitted run + per-step actions + market snapshots into a DuckDB file at data/runs/runs.duckdb. On Modal, that path is inside a named persistent Volume (the same Volume mechanism Post 8 uses for model checkpoints; the precise mechanics are in [[reference_modal_volume_persistence]]). The Volume outlives container scale-to-zero — a queued run submitted at 11 PM survives until the worker container rehydrates at 2 AM.

DuckDB-specific subtleties:

  • Schema is forward-migratable. mayavi/sim/runner.py:_ensure_runs_schema runs ALTER TABLE on connect to add new columns; a contributor pulling a new branch doesn't need to nuke their local runs.duckdb.
  • No native ON DELETE CASCADE. Phase 5's PR N caught a retention-policy leak where the 5000-row ceiling deleted from runs but left orphaned rows in actions + market_snapshots. The fix is application-side cascade: truncate_runs_to_max_rows deletes from the child tables first, then the parent.
  • Read-after-write consistency on the same connection. API submission, run execution, and load_run all share one connection per request; transactions are explicit.

The auth model — and the bearer-token leak we caught

The dashboard's bearer-token auth has one rule: the token never reaches the client bundle.

The naive Next.js pattern would set NEXT_PUBLIC_API_KEY in the Vercel env, then read it from a client component to attach an Authorization header before fetch. That ships the token in the JavaScript the browser downloads. Anyone viewing the page source can grep it out.

Phase-5 PR E caught exactly this. The pre-PR-E build was inlining NEXT_PUBLIC_API_KEY into the static _next/static/chunks/*.js. Any visitor to app.mayavi.sambhal-labs.com could open DevTools, grep the chunks, and exfiltrate the token. With that token they could submit arbitrary runs to the Modal-deployed API, exhausting our compute budget and persisting attacker-controlled data into our DuckDB.

The fix landed three changes together:

  1. Server-side proxy routes under web/app/api/ (/api/runs, /api/runs/[id], /api/runs/[id]/report, /api/scenarios). The Next.js route handlers run in Vercel's Node runtime, read MAYAVI_API_KEY from the server-only env, attach the Authorization: Bearer <key> header server-side, and proxy upstream to Modal. The client only ever sees same-origin URLs.
  2. Webpack DefinePlugin audit in next.config.ts to fail the build if any NEXT_PUBLIC_API_KEY reference survives. Defense in depth — even if a developer accidentally reads from the public-env name, the build won't compile.
  3. CI assertion: tests/web/test_no_secrets_in_client_bundle.py (run on every Vercel preview deploy) curls a representative chunk and asserts the bearer-key pattern isn't present.

The bearer never reaches the client. Iframe + PDF downloads work the same way: <iframe src="/api/runs/<id>/report"> is same-origin, the Next.js route handler proxies upstream with the bearer attached, the iframe just sees an HTML body. PDF download is identical (server-side Playwright render, streamed back).

The env-var contract

Every "misconfigured" case has to fail fast, not silently. The FastAPI service reads:

VariableDefaultBehavior on misconfigure
MAYAVI_API_KEY(required)Service won't start without it.
MAYAVI_API_RUNS_DBdata/runs/runs.duckdbPath the API reads/writes. In prod, points at the Modal Volume mount.
MAYAVI_API_RUNS_MAX_ROWS5000Retention ceiling. Non-int or <1 raises at submission.
MAYAVI_API_LOG_FORMATjsontext for local dev. Misconfigured raises at startup.
MAYAVI_API_CORS_ORIGINSunsetComma-separated extras beyond the *.mayavi.sambhal-labs.com defaults.
MAYAVI_API_RATE_LIMIT_REQUESTS_PER_MINUTE60Token-bucket per bearer key on POST /runs only.
SENTRY_DSNunset → no-opSentry stays off until set. Sample rates outside [0.0, 1.0] raise at startup.
MAYAVI_API_TEST_FAKE_EXECUTEunsetTest escape hatch — short-circuits to complete in <500 ms without forking an EVM. Production deploys must leave this unset. PR N added a log.error + sentry_sdk.set_tag when the flag is set, so an accidentally-enabled prod deploy fires a Sentry-tagged error on every run.

The contract is enforced by tests/api/test_env_var_contract.py — every variable in this table has a "misconfigured raises" assertion. A future env var that doesn't fail-fast can't be merged without adding the test row.

The deploy pipeline

git push origin main:

  1. GitHub Actions: ci.yml runs lint, type-check, unit tests + 6 per-layer coverage gates (sim ≥85, agents ≥80, svm ≥70, chains ≥90, api ≥85, protocols ≥90). The fork job runs nightly only — fork tests pin a block, hit Alchemy, and cost real CUs.
  2. GitHub Actions: deploy.yml fires in parallel after CI passes:
    • Modal job: uv run modal deploy mayavi/api/modal_app.py. Modal keeps the previous deploy live until the new one is healthy.
    • Vercel job: vercel deploy --prebuilt --prod against the web/ directory.
  3. Smoke test: curl -fsS https://morellato26--mayavi-api-fastapi-app.modal.run/healthz. 5 retries, 5 s backoff. The pytest equivalent is tests/api/test_modal_deploy.py marked @pytest.mark.modal.

Concurrency is grouped so a second push within the deploy window cancels the in-flight job and supersedes it — last-merged-wins, no half-deployed states.

Rollback

Modal:

modal app history mayavi-api
modal app rollback mayavi-api <revision-id>

Vercel:

vercel rollback <previous-deployment-url>

Both vendors keep the previous N deploys reachable. Rollback is a one-command operation that completes in seconds.

The dashboard's run lifecycle

Submitting a run from app.mayavi.sambhal-labs.com/scenarios/<type>/new:

  1. Client POSTs the scenario YAML to /api/runs (same-origin Next.js route handler).
  2. Route handler reads MAYAVI_API_KEY from server-env, attaches bearer, proxies to Modal's POST /runs.
  3. FastAPI service writes a queued row into DuckDB, returns the run_id.
  4. Client redirects to /runs/<run_id> and polls /api/runs/<run_id> via the same same-origin proxy.
  5. Modal's run-executor worker picks up the queued row, forks the EVM, runs the scenario, persists the result, transitions the row to complete.
  6. Client's poll sees complete, fetches /api/runs/<run_id>/report (iframe) — same-origin proxy, Plotly HTML body streams back.
  7. PDF download: /api/runs/<run_id>/report.pdf triggers a server-side Playwright render, streams the PDF.

On a cold Modal container, step 5 takes ~10–30 s (the EVM-init cost). On a warm container, it's under 2 s for the bundled demo scenarios.

What this whole architecture buys

Two properties worth naming explicitly:

  1. Compute scales to zero. Modal's containers idle at zero replicas when no runs are queued. We pay for the CPU-seconds of actual run execution, not for an always-on host. For an early-stage project, that's the difference between a $30/month bill and a $300/month bill.
  2. The credibility wall extends to the dashboard. Every claim a user sees — the run's report, the JSON eval, the saved historical run — is the same bundle the engine's CLI produces. There's no "marketing version" of the data on the dashboard. What you see is what mayavi artifact rendered.

The 15-post series, closed

This is post 15. Reading order if you came in cold:

  1. Introducing Mayavi — what it is.
  2. The Credibility Wall — why we fork mainnet.
  3. Inside the Engine — architecture tour.
  4. Aave V3 Across Six Chains
  5. Compound V3 Comet
  6. SparkLend Thin Adapter
  7. Curve Depeg Cascade
  8. PPO Aave Borrower Saturation
  9. Vesting Saturation
  10. Liquidator vs Heuristic
  11. EIGEN Season 1 Replay
  12. ENA Vesting Cliff Replay
  13. Multichain Matrix Pipeline
  14. Determinism Is a Feature
  15. From Notebook to Vercel — this post.

The series ships across five themes (foundations, protocols, RL findings, replays, platform). Every post embeds a real on-chain bundle or quotes a real eval JSON. Every claim traces to a file in the repo. No fictional improvements. That's the contract.

The next set of posts won't be a numbered series. They'll be on-the-record write-ups of new scenarios, new chains, new protocol integrations, new RL findings. Subscribe via the RSS feed to catch them as they ship.