Skip to content

Self-hosting (sovereign deployment) ​

Futuros is designed to be redeployed in-region. This is Pillar 2 (the Sovereign Model): a LATAM government or multilateral can run the whole platform, the atlas, the baked corpus, the public API, and the AI assistant, on infrastructure inside its own jurisdiction, pointing inference at an in-region open-weight gateway so the corpus and every user query stay on sovereign soil.

Because the platform is a static SPA + baked JSON corpus + a small set of serverless functions, self-hosting comes in two tiers.

Two tiers, one repo

The repo's Dockerfile + Caddyfile produce a static deployment: the SPA, the baked public/data/ corpus, the Public API v1, and the embeds. That container (Caddy serving dist/) does not run the api/ serverless functions, so the chat, MCP server, data-trust contribute/revoke, consensus, and alerts endpoints are not served by it. To run those interactive services you need a host that executes the api/* Node functions (Vercel, or any Node/functions runtime), plus the env below.

Tier 1 — static sovereign mirror (Docker + Caddy) ​

The included multi-stage Dockerfile builds with Bun and ships a tiny Caddy image serving the static build:

dockerfile
FROM oven/bun:1.3 AS build
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build

FROM caddy:2-alpine
COPY --from=build /app/dist /srv
COPY Caddyfile /etc/caddy/Caddyfile
EXPOSE 8080
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]

Build and run:

bash
docker build -t futuros .
docker run -p 8080:8080 -e PORT=8080 futuros
# → http://localhost:8080

Since the accounts release (October 2026)

The SPA requires sign-in: the static container above serves the UI, but the access wall (middleware.ts) and /api/auth/* need the server functions, Postgres (Neon or equivalent) and transactional email. See Accounts & sign-in. The full deployment guide with database and migrations ships with the organizations and machine-access release; until then a static self-host is only useful with AUTH_WALL_DISABLED=1 in a private environment.

The Caddyfile listens on :{$PORT:8080}, gzips/zstd-compresses, and behaves, matcher by matcher:

MatcherBehaviour
/assets/*Cache-Control: public, max-age=31536000, immutable — hashed bundles
/data/*max-age=600, s-maxage=3600 — the baked corpus turns over within minutes of a redeploy
/research/*same as /data/* — JSON for the research library, regenerated on every build
/atlas/… with an extension, /geo/*max-age=86400, immutable — static geo bundles. The /atlas/ rule is a path_regexp that requires a file extension, on purpose: /atlas/<ISO3> is an SPA route, not a file, and marking it immutable pinned the app shell in every cache for a full day; after the next deploy it asked for /assets/ chunks that no longer exist, and a reload could not clear it. A glob would not do: Caddy's trailing * crosses / while a mid-pattern one does not, so /atlas/*.json would miss /atlas/adm1-shapes/MEX.json. Same rule as vercel.json.
/embed/*removes X-Frame-Options and restates the whole CSP with frame-ancestors * as one directive among many: script-src, object-src 'none' and base-uri 'self' stay enforced on embeds. A Caddy header set replaces the whole field, so writing only frame-ancestors * would have dropped the rest of the policy across /embed/* (which is not a static subtree, it is SPA routes)
everything elseThe full production header set, identical to vercel.json: an enforcing Content-Security-Policy (default-src 'self', origin allowlists for script/style/font/connect, object-src 'none', frame-ancestors 'self', base-uri 'self', form-action 'self'), Strict-Transport-Security: max-age=63072000; includeSubDomains, Cross-Origin-Opener-Policy: same-origin-allow-popups, X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy denying camera/mic/geolocation, and the Server header stripped

The self-host is no longer weaker than the Vercel deploy

The Caddyfile ships the same security posture as Vercel's edge config, header for header. It previously shipped none of the production headers (no CSP, no HSTS, no COOP), so a sovereign mirror inherited a weaker site than the one we publish. The policy's trade-offs are documented in the Caddyfile itself: 'unsafe-inline' in script-src is the honest trade for a static deploy that cannot mint a per-request nonce; 'wasm-unsafe-eval' and cdn.jsdelivr.net are DuckDB-WASM; the font origins in connect-src are the service worker re-fetching them; and COOP is same-origin-allow-popups (not same-origin) because PDF export needs the opener relationship. Edit one of the two files and you edit the other: divergence is exactly the failure this fixes.

Resolution order is try_files {path} {path}/ /spa.html: a real file always wins over the SPA fallback. That is why the baked /api/v1/* JSON artifacts are served even on Tier 1 (they are plain files in dist/), while a dynamic path like /api/chat has no handler there and falls through to the SPA shell. This mirrors vercel.json's rewrite of every extensionless, non-reserved path to /spa.html, with one deliberate difference: Vercel's pattern also excludes /api/*, because on Vercel the functions live there.

The fallback target matters: postbuild renames dist/index.html→spa.html and dist/landing.html→index.html (so /index.html is the landing page, not the app shell), then prerenders the landing (scripts/prerender-landing.mjs) and deletes any CLAUDE.md that leaked into dist/. railway.json wires the same Dockerfile for Railway: builder DOCKERFILE, health check /intro (30 s timeout), restart policy ON_FAILURE with 3 retries.

This tier is a complete, offline-capable data-sovereignty mirror: the full atlas UI and the entire cited corpus, hosted wherever you run the container. Anything that needs a server (below) degrades honestly, the app stays usable, the interactive features announce they are not configured rather than faking a result.

Tier 2 — full platform (serverless functions + sovereign inference) ​

The interactive surfaces live in api/* as Node serverless functions (chat, mcp, contribute, revoke, consensus, intake, alerts-subscribe, alerts-digest, o, auth/*: intake receives visitor pilot proposals, introduction requests, and inconsistency reports, persisting to an Upstash queue with a Mailgun notify, honest-when-unconfigured like the rest). Deploy them on a runtime that executes them (Vercel is the reference target) and set the env vars below. The static assets and corpus are the same build as Tier 1.

Environment variables ​

Grouped by feature. Everything is optional: each unset key degrades a specific feature honestly (see the table further down). Client keys are VITE_-prefixed and baked into the build; the rest are server-side only.

Note: the repo's env.example documents only the map, analytics, chat/ retrieval, and trust-ledger salt keys. The Upstash, alerts (Mailgun / webhooks), and Perplexity keys below are read by the code but are not in env.example: the authoritative list is here.

Build / client (baked, VITE_-prefixed):

VarFeatureWithout it
VITE_MAPBOX_TOKENDead config, do not set it. No code reads it. The atlas map is pure offline SVG (src/components/MapPane.tsx: no Mapbox, no MapLibre, no token, no tiles), and the only trace left is the type declaration in src/vite-env.d.tsNo effect: the atlas renders exactly the same. env.example lists it only so an operator who finds it in an inherited project knows it can be deleted; a credential pasted into a var nothing reads is pure downside
VITE_POSTHOG_HOSTPostHog region overrideDefaults to US Cloud

Analytics on a self-host: the PostHog project key is hardcoded in src/lib/analytics.ts (a VITE_POSTHOG_KEY env var is deliberately ignored), and initialization is gated to futuros.xyz / *.futuros.xyz hostnames, a sovereign mirror on another domain emits no analytics regardless of env. The docs site (docs/.vitepress/posthog-snippet.ts) uses the same hardcoded key and hostname gate. Pointing analytics at your own PostHog project means editing those files, not setting a variable.

The gates that fail closed ​

Before any provider key, three explicit switches govern whether a dynamic endpoint exists at all. All fail closed: with the variable unset, the surface returns a clean refusal rather than staying open. An empty .env is, by design, a correct posture, never a leak.

VarWhat it opensWithout it
CHAT_ENABLEDOpens POST /api/chat (also needs a provider key and the Upstash rate limit). On-site SPA chat stays complimentary; programmatic / other-origin callers need a prepaid ft_live_… key from /acceso when the access lever is on403 — the assistant does not answer. This is the most-forgotten step when self-hosting: with ANTHROPIC_API_KEY configured but this switch unset, chat still returns 403
MCP_ENABLEDOpens POST /api/mcp (also needs the Upstash rate limit). Callers must present a prepaid API key (Authorization: Bearer ft_live_…) when the access lever is on403 {"error":"mcp_disabled"}. GET /api/mcp?health=1 still answers and reports it in enabled
SCRAPE_WRITE_ENABLEDThe scrape write pathObserve-only mode: writes are rejected

Each switch must be literally the string "true".

These three live alongside the rest in server/http/env-levers.ts, the canonical list of 20 production levers (each with its id, the variables that compose it, what it opens, and a public probe to verify it). That list is served at GET /api/env-levers and published on /confianza, so an operator (or a journalist) can audit a deployment's posture without access to its environment. leverStatus() reports only a set boolean per lever: never the secret's value.

AI assistant, inference provider (server-side; first match wins):

Var(s)Feature
SOVEREIGN_INFERENCE_URL + SOVEREIGN_INFERENCE_KEYPillar 2 sovereign path. Points the same Anthropic-Messages agent loop at an in-region / self-hosted open-weight gateway (LiteLLM, or vLLM/TGI behind a Messages-compatible shim, serving Llama/Qwen/DeepSeek/Mistral). When both are set they win over OpenRouter/Anthropic and the chat shows a "sovereign mode" badge — the query + corpus never leave the host.
SOVEREIGN_INFERENCE_MODELYour gateway's served-model-name (default local-model)
SOVEREIGN_INFERENCE_LABELUI label for the sovereign provider (default Soberano)
SOVEREIGN_INFERENCE_REGIONDeclares the gateway's region; the only way the badge asserts the stronger "in-region" residency claim
OPENROUTER_API_KEYManaged fallback: routes via OpenRouter's Anthropic-native endpoint (default model anthropic/claude-sonnet-4.6)
ANTHROPIC_API_KEYCalls api.anthropic.com directly (default claude-sonnet-4-6)
CHAT_MODEL / CHAT_FOLLOWUP_MODELOverride answer / follow-up model ids for the active provider

Providers form an ordered failover chain: sovereign → OpenRouter → Anthropic. The first configured provider is primary; every additional key becomes a transparent fallback the handler switches to mid-request if the primary fails (model ids are re-resolved per provider; CHAT_MODEL / CHAT_FOLLOWUP_MODEL bind to the primary only). One key works fine; a second buys redundancy. With none set, the chat emits a not_configured event and declines rather than erroring. SOVEREIGN_INFERENCE_REGION (optional) declares the gateway's geographic region, it is the only way the chat asserts the stronger "in-region" claim behind the sovereign badge; residency is never inferred from an endpoint's mere presence.

AI assistant, retrieval & external web (server-side):

Var(s)FeatureWithout it
VOYAGE_API_KEY (+ CHAT_EMBED_MODEL, CHAT_RERANK_MODEL)Semantic search_corpus over the baked index + cross-encoder rerankDegrades to keyword/BM25 search (no hard failure)
EXA_API_KEYChat's last-resort web fallback (web_search/fetch_url) + pulse ingestionChat answers from the corpus only and declines out-of-corpus questions
PERPLEXITY_API_KEY (+ PERPLEXITY_MODEL)perplexity_search live web synthesis (chat + MCP)Tool returns a "not configured" note; answers stay corpus-only

Data Trust (Pillar 1) & consensus: Upstash Redis (server-side):

Var(s)FeatureWithout it
KV_REST_API_URL + KV_REST_API_TOKEN or UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKENAppend-only trust ledger (/contribuir receipts, /api/revoke, /api/intake), consensus vote store (/api/consensus), and cross-instance API rate limiting. Same precedence in all three (KV_* first; see server/http/redis-env.ts)Returns a valid receipt with persisted:false; consensus degrades to local-only; rate limits fall back to per-instance memory — never fakes persistence
TRUST_ID_SALTSecret salt for the pseudonymous contributor id on ledger receipts (contributor_id = sha256(salt:email); the ledger never stores the raw email; the accounts user table does, because passwordless login needs it)Falls back to the public, in-repo default salt: anyone can recompute the hash and confirm whether a given email matches a ledger record. Set a high-entropy secret in production
RATE_LIMIT_KEY_SALTSalt for the HMAC-SHA256 that keys the per-address rate limit on /api/alerts-subscribe (alerts-subscribe-addr:<32 hex digest>), so no subscriber email ever appears in the Redis keyspace (a keyspace shared by every endpoint and visible in any REST listing or backup)Falls back to TRUST_ID_SALT, and failing that to a public, in-repo default salt. The per-recipient partition is identical in all three cases; the only loss is that the digest stops being unpredictable to someone who already knows an address. The address stored, sent to and echoed back stays exactly what the user typed
RATE_LIMIT_REQUIRE_STORESet to 1, it hardens the paid surfaces (chat, MCP, the callers that ask for failClosed): "no rate-limit store configured" becomes a hard fault on a managed deployment, and a broken store stops falling back to the degraded ceiling. Either way the answer is "temporarily unavailable" instead of serviceOff by default. With no store configured, limits are per-instance (that is, multiplied by the number of live instances) and only a log warning marks it. A store that is configured and then fails never returns to the full cap: without this var it falls back to a much tighter per-instance ceiling (chat: 6/min per IP, 30/min and 120/h global; MCP: 10/min per IP); with it, it refuses. It is opt-in so that turning on strict mode is a deliberate act once you know Redis is provisioned, not a side effect of deploying. It does nothing off a managed deployment: bun test, a local vite, and your own self-host stay fail-open
TRUST_ID_SALT_PREVIOUSSalt rotation: move the old value here (comma-separated if several); revocation matches each old pledge to its original salt via the public salt_version label, so rotating never breaks revocabilityRotating TRUST_ID_SALT without retaining the old salt leaves pledges written under it unrevocable

The Vercel KV aliases KV_REST_API_URL / KV_REST_API_TOKEN and the native UPSTASH_REDIS_REST_URL / _TOKEN names are accepted by the Data-Trust ledger, the consensus store, and the rate limiter (same precedence: KV_* first). One pair is enough; a KV-only deployment (the typical Marketplace case) no longer leaves trust receipts at persisted:false.

Accounts: Better Auth + Neon Postgres + AgentMail (server-side):

User accounts (email code, magic link; Google optional and off by default), sessions, conversation history and saved items live in Postgres, not Redis. The code assumes Neon (@neondatabase/serverless, HTTP driver, no pool to warm), but any Postgres 15+ reachable over HTTPS with Neon's protocol, or a classic Postgres behind a compatible proxy, works the same: the connection string is the only configuration.

Var(s)FeatureWithout it
DATABASE_URLPooled (PgBouncer) connection string used by the functions at runtimeFail-closed: every /api/auth/* answers 503 {"error":"auth_not_configured"}; the rest of the site is unchanged
DATABASE_URL_UNPOOLEDDirect connection string for migrations (Neon documents that DDL over the pooled host fails intermittently)Migrations use DATABASE_URL; with Neon, set both
BETTER_AUTH_SECRETSession and token signing; at least 32 characters (openssl rand -base64 32). A shorter secret counts as unsetFail-closed: the same 503
BETTER_AUTH_URLExact public origin (https://futuros.xyz): Google rejects a redirect_uri that differs in host or schemeDerived from VERCEL_PROJECT_PRODUCTION_URL in production and VERCEL_URL on previews; locally http://localhost:5173
AGENTMAIL_API_KEY (+ AUTH_EMAIL_INBOX, default acceso@futuros.xyz)Sends the sign-in email (six-digit code + magic link in one message) through AgentMail; the inbox is the sender address and must be on a verified domain. Takes precedence over ResendThe endpoint answers identically (it never reveals whether the address exists) but no mail leaves; a [auth] email_failed log line and an email_failed row in audit_event remain
RESEND_API_KEY (+ AUTH_EMAIL_FROM, default Futuros <acceso@futuros.xyz>)Alternative to AgentMail: the same email through Resend; used only when AGENTMAIL_API_KEY is unsetSame as above when AGENTMAIL_API_KEY is also unset
VITE_AUTH_GOOGLE (build-time variable) + GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET"Continue with Google" (prompt=select_account, account linking restricted to Google). Optional and off by default: without VITE_AUTH_GOOGLE=1 neither the button nor the link in /cuenta is shown. Register https://<your-domain>/api/auth/callback/google as the redirect URIGoogle is not offered; code sign-in keeps working. Vercel previews never offer it (Google accepts no *.vercel.app wildcard)
AUTH_WALL_DISABLEDEmergency switch for the middleware login wall (lands in the next release). Set to 1 the site behaves as todayWith the wall deployed, everything except the landing and the docs requires a session
AUTH_SIGNUP_DISABLEDSet to 1, refuses new accounts (403 SIGNUP_DISABLED) without touching existing onesOpen signup
AUTH_GOOGLE_DISABLEDSet to 1, hides Google without deleting the clientGoogle active when a client exists

The abuse ceilings are not configurable: 5 code sends per address per hour, 20 per IP per hour, 3 attempts per code, 10 failed verifications per IP per 15 min, 10 new accounts per IP per day, and a disposable-domain list in server/auth/disposable-domains.ts. Every refusal writes an audit_event row (IP hashed with SCRAPE_HASH_SALT, never the address) and returns the same generic error (429 AUTH_THROTTLED). Counters live in the rate limiter's Redis (KV_REST_API_* or UPSTASH_REDIS_REST_*; server/http/redis-env.ts accepts either pair, no need to copy names); without Redis, or with Redis down, they fall back to a much tighter per-instance ceiling, never to "unlimited".

Migrations. The schema is Drizzle (server/db/schema/*.ts) and the generated SQL migrations are committed under server/db/migrations/. They apply at the end of prebuild:full (bun run db:check && bun run db:migrate) only whenVERCEL_ENV=production or DB_MIGRATE=1: preview and production share one DATABASE_URL (no Neon branch per PR), so a preview migrating would apply unmerged schema to production. With DATABASE_URL unset, or outside that gate, the step is skipped with a notice. With the gate open, a migration failure fails the build. Migrations must be additive and backward-compatible, because preview deploys run against the production schema. Self-hosted:

bash
bun run db:generate --name <change>   # after editing server/db/schema/*.ts
bun run db:check                      # conflicting snapshots (offline)
DB_MIGRATE=1 DATABASE_URL_UNPOOLED=postgres://... bun run db:migrate

Residency: the reference project runs in AWS São Paulo (sa-east-1); a sovereign mirror picks its own region when creating the Neon project (or its own Postgres), and nothing in the code assumes otherwise.

Alerts & email (server-side):

Var(s)FeatureWithout it
MAILGUN_API_KEY + MAILGUN_DOMAIN + MAILGUN_FROMEmail delivery for /api/alerts-subscribe confirmations and operator notices from /api/contribute and /api/intake (revoke sends no email), via Mailgun over fetchalerts-subscribe returns a mailto: fallback instead of pretending to subscribe
MAILGUN_NOTIFY / ALERTS_NOTIFY_EMAILInternal copy of subscribe/contribution/intake noticesNo internal copy
ALERTS_WEBHOOKSThe weekly digest cron (/api/alerts-digest, Mon 13:00 UTC) dispatches the global anomaly digest to these comma-separated webhook URLsDigest dispatches to nobody
CRON_SECRETAuthenticates the cron trigger to POST /api/alerts-digest (Vercel sends Bearer $CRON_SECRET)Fail-closed: every POST returns 401 — the fan-out is disabled, not unguarded. (GET /api/alerts-digest?lang=es|en|pt remains a public, localized digest preview either way.)

Scrape detection (server-side):

VarFeatureWithout it
SCRAPE_HASH_SALTSalt for the pseudonymous ip_hash (SHA-256 truncated to 16 hex) carried by every scrape_flag event in PostHog, the per-scraper join keyIn production the event ships with no ip_hash and a shared literal distinct_id (scrape:unsalted): detection is unaffected, what is lost is grouping by scraper in PostHog. The code refuses the public, in-repo default salt in production, because a salted IPv4 hash with a known salt is not pseudonymous (2^32 preimage space) and it is exactly what we ship to a third party. Non-production still uses that default, since nothing is delivered off-box there. The ops alert still carries the raw IP for triage
SCRAPE_ALERT_MAX_PER_HOURHourly ceiling on outbound ops alerts (Mailgun email + the ALERTS_WEBHOOKS fan-out), counted across all clients togetherDefaults to 20 per hour (a non-numeric or ≤ 0 override falls back to 20). The ceiling sheds only the push leg: the PostHog capture is never capped, so under an alert flood the operator loses the ping, never the record

The weekly alerts digest is webhook-based (ALERTS_WEBHOOKS), not email, > Mailgun handles the subscribe confirmation and Data-Trust notices. Both exist; don't conflate them.

Outreach hop ​

VarFeatureWithout it
OUTREACH_REDIRECTSServer-only JSON { "<token>": "https://…" } for GET /o/{token} → 302. Token is opaque (UUID or similar). Destination allowlist, https only: futuros.xyz, docs.futuros.xyz, v2.futuros.xyz. The hop identifies in PostHog with the token as distinct_id (identify_reason=hop_mint) and emits hop_redirect. Sets the first-party futuros_o cookie so a later product visit re-identifies. No mint API.Every token returns 404 — no open redirect, no map enumeration
OUTREACH_ROSTERServer-only JSON { "<token>": { name?, institution?, email?, campaign?, internal? } }. Writes person properties on the hop; distinct_id stays the token. internal: true marks testers for PostHog's test-account filter. Lives with Data, not in the repo.The hop identifies with outreach_token only — no name, email, or institution

Exact shape (one object, not an array; opaque keys; https allowlisted values):

json
{
  "550e8400-e29b-41d4-a716-446655440000": "https://docs.futuros.xyz/plataforma/recorrido",
  "7c9e6679-7425-40de-944b-e07fc1f90ae7": "https://futuros.xyz/atlas"
}

Where it lands. The stored destination is not always where the visitor ends up. If it resolves to one of the static marketing shells (/, /en, /pt), the hop re-points it at /atlas, keeping the shell's language (?lang=) and any query the destination already carried. Those three paths are served straight from the filesystem (postbuild swaps landing.html→index.html), so the SPA never boots there: no client session, no chat. hop_redirect records the real landing rather than the stored destination, which makes https://futuros.xyz/ and https://futuros.xyz/atlas the same link. The rewrite covers every allowlisted host except docs.futuros.xyz, which has no /atlas: there the destination is honored as stored and the token travels in ?o= alone. The static shell itself also reads ?o= and the futuros_o cookie, so a return visit to the root stitches to the same person instead of staying anonymous.

On Vercel: Production env OUTREACH_REDIRECTS = that JSON on one line; OUTREACH_ROSTER is the same pattern (a second object). Data adds tokens without a code change. An unknown, malformed, or disallowed-destination token is the same 404 (Not found), the response does not reveal whether other tokens exist. Identify runs on the futuros.xyz hop before the 302 (token as distinct_id; optional roster as properties). The docs site inits the same PostHog project (VitePress snippet) and can re-persist the token; the hop does not depend on that. Do not set VITE_POSTHOG_KEY. The Tier 1 Caddy mirror does not run /o/{token} (it falls through to the SPA shell); the hop needs the api/o function.

Ingestion (build-time scripts only, not needed to serve the app): the refresh pipeline reads keys such as OPENAQ_API_KEY, CLOUDFLARE_RADAR_TOKEN, YOUTUBE_API_KEY, REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET, SERPAPI_KEY, plus the EXA_/PERPLEXITY_/GDELT_ tuning vars. These matter only when you re-run ingestion (below), never to serve an already-baked corpus.

The corpus is static, baked at build time ​

There is no database in the serving path. The entire corpus under public/data/ is baked at build time and shipped as static files (the same files the Public API v1 exposes). Serving Futuros means serving those files, which is why Tier 1 (Caddy over dist/) is a complete data mirror.

To refresh the data you re-run the ingestion/bake scripts and rebuild:

bash
bun run ingest:all     # multi-source ingest → parameter-cache + citations + signals
bun run bake           # derived narratives etc.; then the specific bakes you need:
bun run bake:scores    #   composite index → scores.json
bun run bake:metrics   #   metric registry
bun run bake:signals   #   cited signals
bun run bake:api       #   public API v1 artifacts
bun run build          # tsc + vite; prebuild re-runs bake:health/bake:ledger/bake:api
                       # plus the provenance gates — any failure stops the build

(There is no aggregate bake:all; baking is deliberately split so a refresh can touch one family without re-deriving everything.)

prebuild is the honesty gate, in order (from package.json): typecheck of the script layer, the full bun test suite, then the check scripts, build-positions-index --check, check-traced, check-signals, check-source-links, check-pilots, check-citations, check-scores, check-provenance, check-emdash-drift, check-catalog, check-search-index --warn, check-i18n: then three re-bakes (bake-data-health, bake-source-ledger, bake-api) sealed by check-api. Every step except the --warn search-index pass fails the build: a figure that lost its source cannot ship. So a self-hosted rebuild preserves the honesty contract by construction, and a sovereign operator can run the whole pipeline in-region: baked corpus in-region, inference in-region, nothing leaves the host.

  1. Stand up an in-region open-weight gateway (LiteLLM in front of vLLM/TGI serving Llama/Qwen/DeepSeek/Mistral) that speaks the Anthropic Messages API.
  2. Deploy the SPA + baked corpus (Tier 1 container) and the api/* functions on in-region infrastructure.
  3. Set SOVEREIGN_INFERENCE_URL + SOVEREIGN_INFERENCE_KEY (+ _MODEL, _LABEL). The chat badges "sovereign mode"; corpus + queries never leave your jurisdiction.
  4. Add UPSTASH_REDIS_REST_* only if you want the trust ledger / consensus persisted; otherwise those features degrade honestly.

Every figure with its source — traceability is the contract.