{"id":"deployment","relativePath":"deployment.md","title":"Deployment — Vercel + Render","markdown":"# Deployment — Vercel + Render\n\nHow the Meta Museum system maps onto hosted infrastructure.\n\n| Piece | Host | Config |\n|---|---|---|\n| Next.js web app | **Vercel** | `vercel.json` |\n| Postgres (system of record) | **Neon** or Vercel Postgres | `DATABASE_URL` |\n| Validation service (pySHACL) | **Render** | `render.yaml` |\n| Reconciliation service (+ Redis) | **Render** | `render.yaml` |\n| AG2 worker (optional, disabled by default) | **Render** | `render.yaml` |\n| Background workers (outbox / publish queue) | **Vercel Cron**, opt-in drains | `vercel.json`, `/api/cron/*`, `/workers` |\n| Solr / GraphDB (Era C search + graph) | Off until projection readiness says `enable` | skipped unless `OUTBOX_PROJECT_TO_*` is explicitly enabled |\n\nA read-only public demo needs only **Vercel + Neon**. The Render validation and reconciliation services are now deployed for readiness evidence; the workers add async projection/publishing.\n\nRun `pnpm ops:profile` for one read-only summary of the current operating mode.\nIt separates the required Next.js + Postgres baseline from optional Python,\nAG2, Solr, GraphDB, and publication-worker services, and warns when enabled\nprojection lacks its readiness check or scheduled drain. Use\n`pnpm ops:profile:check` in a configured production shell before changing the\nruntime topology.\n\nRun `pnpm projection:readiness` before enabling Solr/GraphDB target flags. The\nportable path remains correct until record volume, search/graph p95, or active\ndiscovery workflow counts cross the documented thresholds.\n\n<!-- BEGIN:PROJECT_STATS -->\n<!-- Generated by `pnpm docs:stats`; do not edit by hand. -->\n\n| Generated project stats | Current value |\n|---|---|\n| Next.js | `16.2.12` |\n| React | `19.2.4` |\n| App page files | root homepage + `41` non-root page files (`42` total) |\n| API route handlers | `149` `app/api` route handlers |\n<!-- END:PROJECT_STATS -->\n\n---\n\n## Part A — Next.js on Vercel\n\n### One-time setup\n1. Import the repo in Vercel. Framework auto-detects as **Next.js**.\n2. `vercel.json` already overrides the build:\n   - `buildCommand: next build` — **critical.** The repo's `pnpm build` runs `session:closeout:check` first, which fails 24h after the last close-out and would break every deploy. `next build` skips that guard. Local `pnpm build` is unchanged and still enforces the guard.\n3. Provision **Neon Postgres** (or Vercel Postgres) and copy the **pooled** connection string.\n4. Set the env vars below (Project → Settings → Environment Variables), then deploy.\n\n### Vercel environment variables\n| Var | Required | Value |\n|---|---|---|\n| `DATABASE_URL` | ✅ | Neon **pooled** connection string |\n| `METAMUSEUM_STORAGE_MODE` | ✅ | `postgres` |\n| `AUTH_SECRET` | ✅ | `openssl rand -base64 32` |\n| `BASE_URL` | ✅ | shareable production URL, for example `https://www.metamuseum.org` |\n| `METAMUSEUM_PUBLIC_READ_BASE_URL` | ✅ | exactly the same URL as `BASE_URL` |\n| `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET` | ⛔ optional | enables GitHub sign-in; omit for public read-only |\n| `VALIDATION_SERVICE_URL` | ✅ for launch readiness | `https://metamuseum-validation.onrender.com/validate` (Part B) |\n| `RECONCILIATION_SERVICE_URL` | ✅ for launch readiness | `https://metamuseum-reconciliation.onrender.com` (Part B / ops probes) |\n| `METAMUSEUM_EXTERNAL_SERVICE_PROBE_TIMEOUT_MS` | ⛔ optional | default `45000`; allows Render cold starts in preflight |\n| `METAMUSEUM_VALIDATION_SERVICE_CAPACITY` | ✅ if strict validation is public on Render | `paid` after upgrading the validation service |\n| `METAMUSEUM_VALIDATION_SERVICE_MAX_HEALTH_MS` | ⛔ optional | default `5000`; user-facing strict-validation health budget |\n| `VALIDATION_TIMEOUT_MS` | ⛔ optional | default `15000`; runtime timeout for `/api/validate` proxy calls |\n| `METAMUSEUM_AG2_BRIDGE_ENABLED` | ⛔ optional | leave unset (bridge stays off) |\n| `CRON_SECRET` | ✅ for Vercel Cron | long random value; Vercel sends it as `Authorization: Bearer ...` |\n| `METAMUSEUM_OUTBOX_CRON_ENABLED` | ⛔ optional | `1` only when Solr/GraphDB projection is enabled |\n| `METAMUSEUM_PUBLISH_QUEUE_CRON_ENABLED` | ⛔ optional | `1` only when wiki/publication queue processing is enabled |\n\nNotes:\n- **Storage self-heals on a fresh DB.** Core stores (`records.ts`) catch a missing Postgres document and seed an empty `[]`, so an empty Neon DB won't crash the app.\n- **Runtime is Node, not Edge** for API routes (required by `pg` + Auth.js v5) — this is the App Router default; no action needed.\n- **Read-only FS guard:** request-path JSON writes must go through the managed\n  storage registry in `src/utils/storage.ts`, which routes those documents to\n  Postgres in production. `tests/quality/route-storage-write-guard.test.ts`\n  walks `app/api` imports and fails when a production route can reach raw\n  filesystem JSON writes or a JSON target not listed in\n  `MANAGED_STORAGE_DOCUMENT_FILES`.\n- **Social preview base URL:** `BASE_URL` and `METAMUSEUM_PUBLIC_READ_BASE_URL`\n  must stay identical in production. Next.js resolves Open Graph image URLs\n  against the public-read metadata base, and `pnpm launch:preflight:production`\n  now fails if the two variables diverge or if the public-read base is missing.\n  After every domain change, redeploy, run `pnpm smoke:crawler-preview`, and\n  refresh the homepage in Facebook Sharing Debugger plus the equivalent\n  LinkedIn/X crawler preview tools.\n- **External service mode:** validation and reconciliation Render services are\n  optional, but their mode is no longer implicit. When service URLs are unset,\n  deployment preflight records the local fallback path: non-strict validation\n  falls back to the local Linked Art profile checker, strict `/api/validate`\n  still needs a running validation service, and the app uses in-process\n  deterministic reconciliation. When either URL is set, preflight probes\n  `/health` with `METAMUSEUM_EXTERNAL_SERVICE_PROBE_TIMEOUT_MS` and records\n  status plus duration for launch evidence. Health probes intentionally tolerate\n  cold starts, but user-facing strict `/api/validate` has a separate runtime\n  timeout and production preflight fails an `onrender.com` validator unless\n  `METAMUSEUM_VALIDATION_SERVICE_CAPACITY` proves paid/no-sleep capacity and\n  the `/health` duration stays within `METAMUSEUM_VALIDATION_SERVICE_MAX_HEALTH_MS`.\n\n---\n\n## Part B — Python services on Render\n\n`render.yaml` is a Blueprint defining the deployed FastAPI validation and\nreconciliation services plus the Redis cache used by reconciliation.\n\nCurrent deployed endpoints:\n\n- Validation health: `https://metamuseum-validation.onrender.com/health`\n- Validation API: `https://metamuseum-validation.onrender.com/validate`\n- Reconciliation health: `https://metamuseum-reconciliation.onrender.com/health`\n- Reconciliation API base: `https://metamuseum-reconciliation.onrender.com`\n\n### Setup\n1. In Render: **New → Blueprint**, point at this repo. It reads `render.yaml`.\n2. Each service: own `rootDir`, `pip install -r requirements.txt`, and\n   `uvicorn main:APP --host 0.0.0.0 --port $PORT` (bypasses the hardcoded\n   `127.0.0.1` in each `__main__`). Health check: `/health`.\n3. Redis (`metamuseum-reconcile-cache`) is wired into the reconciliation\n   service via `RECONCILIATION_REDIS_URL` automatically.\n4. **AG2 worker is optional** — delete that block from `render.yaml` unless you\n   plan to enable the bridge.\n\n### Wire services back to Vercel\nSet these URLs on Vercel so deployment preflight captures live Render health\nevidence:\n- `VALIDATION_SERVICE_URL = https://metamuseum-validation.onrender.com/validate`\n- `METAMUSEUM_VALIDATION_SERVICE_CAPACITY = paid` after the validation service\n  is upgraded off sleeping/free-tier capacity for user-facing strict validation\n- `RECONCILIATION_SERVICE_URL = https://metamuseum-reconciliation.onrender.com`\n  when operator tooling or smoke evidence should use the deployed reconciliation service\n\nThe reconciliation service is invoked by operator scripts/ops tooling rather\nthan the Next runtime; point those at `https://metamuseum-reconciliation.onrender.com`.\n\n> **Free-tier caveat:** Render free web services sleep after inactivity and\n> cold-start in ~30–60s. Fine for a demo; upgrade validation to paid/no-sleep\n> capacity before exposing strict `/api/validate` to production users, then set\n> `METAMUSEUM_VALIDATION_SERVICE_CAPACITY=paid` and rerun preflight.\n\n---\n\n## Part C — Background workers\n\nVercel cannot run persistent Node processes, so the app uses secured Vercel\nCron routes for bounded drain cycles:\n\n| Worker | Script | Needed when |\n|---|---|---|\n| Outbox projector | `pnpm outbox:projector` | Solr/GraphDB projection is enabled (off in this deploy) |\n| Publish queue worker | `pnpm publish:queue:worker` | Wiki/publication publishing is used |\n\nConfigured cron routes:\n\n- `/api/cron/outbox` every 5 minutes.\n- `/api/cron/publish` every 10 minutes.\n\nBoth routes return `cache-control: no-store`. The schedules may be deployed\nsafely while the workers are disabled: disabled drains return `200` no-op JSON\ninstead of `503`, so Vercel Observability does not count intentional idle\nschedules as production errors. Once a worker drain is enabled, that route\nrequires `Authorization: Bearer $CRON_SECRET` before any work can run:\n\n- `METAMUSEUM_OUTBOX_CRON_ENABLED=1` drains one bounded outbox projector batch.\n  Tune with `METAMUSEUM_OUTBOX_CRON_LIMIT` and optional\n  `METAMUSEUM_OUTBOX_CRON_WORKER_ID`. Projection still requires the usual\n  `OUTBOX_PROJECT_TO_SOLR` / `OUTBOX_PROJECT_TO_GRAPHDB` target env vars; the\n  cron route will not claim events until at least one target is explicitly `1`.\n  Direct projector scripts use the same target gates and skip Solr/GraphDB when\n  those vars are unset.\n- `METAMUSEUM_PUBLISH_QUEUE_CRON_ENABLED=1` processes a bounded publish queue\n  batch. Tune with `METAMUSEUM_PUBLISH_QUEUE_CRON_MAX_ITEMS`. The default\n  queue document is `storage/publish-queue.json`, which is a managed storage\n  document and therefore Postgres-backed in Vercel `postgres` mode.\n\nScale enablement guard:\n- Keep `OUTBOX_PROJECT_TO_SOLR` and `OUTBOX_PROJECT_TO_GRAPHDB` off until\n  `/api/cron/outbox` is deployed, `CRON_SECRET` is set, and\n  `METAMUSEUM_OUTBOX_CRON_ENABLED=1` is ready to drain at the configured\n  cadence. First run `pnpm projection:readiness`; production preflight fails if\n  projection targets are enabled without that scheduler.\n- Keep live MediaWiki/Wikibase API URLs and bot tokens out of production until\n  `/api/cron/publish` is deployed, `CRON_SECRET` is set, and\n  `METAMUSEUM_PUBLISH_QUEUE_CRON_ENABLED=1` is ready. Production preflight\n  fails if live publication endpoints or bot tokens are configured without the\n  publish queue scheduler.\n\nOperator status:\n- `/workers` renders worker lag, backlog, next Vercel Cron wakeup, effective\n  next drain time, and disabled projection/publish mode.\n- `/readiness` renders a compact editor-gated status view over the latest\n  review-goals, launch, long-term, ActivityStreams, pilot, DR, and\n  disabled-system drill artifacts.\n- `/api/workers/status` exposes the same no-store JSON snapshot for probes or\n  agent checks. Use it after worker env changes so scheduled-but-disabled\n  features do not look as if they are progressing.\n- `.github/workflows/staging-disabled-systems-drill.yml` runs\n  `pnpm staging:disabled-drill:check` weekly (`20 10 * * 1`) while AG2,\n  Solr/GraphDB projection, and publication drains are intentionally off; run\n  the same command manually after staging env changes. It writes\n  `artifacts/staging-disabled-systems/latest.json`, uploads timestamped run\n  artifacts, confirms disabled paths do not make external network calls,\n  mock-exercises the first enabled AG2, Solr/GraphDB projection, and\n  publication webhook branches, and keeps latent worker/queue regressions\n  visible before real enablement.\n\nFor heavier projection loads, keep these cron routes disabled and run the same\nscripts as a Render background worker instead:\n\n- `pnpm outbox:projector:once`\n- `pnpm publish:queue:worker:once`\n\n---\n\n## Quick path to a live demo\n1. Neon DB → copy pooled `DATABASE_URL`.\n2. Vercel import → set `DATABASE_URL`, `METAMUSEUM_STORAGE_MODE=postgres`,\n   `AUTH_SECRET`, `BASE_URL`, `METAMUSEUM_PUBLIC_READ_BASE_URL` → deploy.\n3. Render Blueprint services are live → set `VALIDATION_SERVICE_URL` and\n   `RECONCILIATION_SERVICE_URL` on Vercel → redeploy.\n4. Run `BASE_URL=https://<shareable-url> METAMUSEUM_PUBLIC_READ_BASE_URL=https://<shareable-url> VALIDATION_SERVICE_URL=https://metamuseum-validation.onrender.com/validate METAMUSEUM_VALIDATION_SERVICE_CAPACITY=paid RECONCILIATION_SERVICE_URL=https://metamuseum-reconciliation.onrender.com pnpm launch:preflight:production`; it will capture configured Render service `/health` status/duration and fail if strict validation is still on sleeping capacity or outside the user-facing health budget. Then run `BASE_URL=https://<shareable-url> pnpm smoke:crawler-preview` and re-scrape social previews in crawler debug tools.\n5. Set `CRON_SECRET`; leave worker enable flags off until projection/publishing is needed.\n","sections":[{"level":2,"heading":"Part A — Next.js on Vercel","anchor":"part-a-next-js-on-vercel"},{"level":3,"heading":"One-time setup","anchor":"one-time-setup"},{"level":3,"heading":"Vercel environment variables","anchor":"vercel-environment-variables"},{"level":2,"heading":"Part B — Python services on Render","anchor":"part-b-python-services-on-render"},{"level":3,"heading":"Setup","anchor":"setup"},{"level":3,"heading":"Wire services back to Vercel","anchor":"wire-services-back-to-vercel"},{"level":2,"heading":"Part C — Background workers","anchor":"part-c-background-workers"},{"level":2,"heading":"Quick path to a live demo","anchor":"quick-path-to-a-live-demo"}],"html":"<h1 id=\"deployment-vercel-render\">Deployment — Vercel + Render</h1>\n<p>How the Meta Museum system maps onto hosted infrastructure.</p>\n<p>| Piece | Host | Config |</p>\n<p>|---|---|---|</p>\n<p>| Next.js web app | <strong>Vercel</strong> | `vercel.json` |</p>\n<p>| Postgres (system of record) | <strong>Neon</strong> or Vercel Postgres | `DATABASE_URL` |</p>\n<p>| Validation service (pySHACL) | <strong>Render</strong> | `render.yaml` |</p>\n<p>| Reconciliation service (+ Redis) | <strong>Render</strong> | `render.yaml` |</p>\n<p>| AG2 worker (optional, disabled by default) | <strong>Render</strong> | `render.yaml` |</p>\n<p>| Background workers (outbox / publish queue) | <strong>Vercel Cron</strong>, opt-in drains | `vercel.json`, `/api/cron/*`, `/workers` |</p>\n<p>| Solr / GraphDB (Era C search + graph) | Off until projection readiness says `enable` | skipped unless `OUTBOX_PROJECT_TO_*` is explicitly enabled |</p>\n<p>A read-only public demo needs only <strong>Vercel + Neon</strong>. The Render validation and reconciliation services are now deployed for readiness evidence; the workers add async projection/publishing.</p>\n<p>Run `pnpm ops:profile` for one read-only summary of the current operating mode.</p>\n<p>It separates the required Next.js + Postgres baseline from optional Python,</p>\n<p>AG2, Solr, GraphDB, and publication-worker services, and warns when enabled</p>\n<p>projection lacks its readiness check or scheduled drain. Use</p>\n<p>`pnpm ops:profile:check` in a configured production shell before changing the</p>\n<p>runtime topology.</p>\n<p>Run `pnpm projection:readiness` before enabling Solr/GraphDB target flags. The</p>\n<p>portable path remains correct until record volume, search/graph p95, or active</p>\n<p>discovery workflow counts cross the documented thresholds.</p>\n<p>&lt;!-- BEGIN:PROJECT_STATS --&gt;</p>\n<p>&lt;!-- Generated by `pnpm docs:stats`; do not edit by hand. --&gt;</p>\n<p>| Generated project stats | Current value |</p>\n<p>|---|---|</p>\n<p>| Next.js | `16.2.12` |</p>\n<p>| React | `19.2.4` |</p>\n<p>| App page files | root homepage + `41` non-root page files (`42` total) |</p>\n<p>| API route handlers | `149` `app/api` route handlers |</p>\n<p>&lt;!-- END:PROJECT_STATS --&gt;</p>\n<p>---</p>\n<h2 id=\"part-a-next-js-on-vercel\">Part A — Next.js on Vercel</h2>\n<h3 id=\"one-time-setup\">One-time setup</h3>\n<ol><li>Import the repo in Vercel. Framework auto-detects as <strong>Next.js</strong>.</li></ol>\n<ol><li>`vercel.json` already overrides the build:</li></ol>\n<ol><li>Provision <strong>Neon Postgres</strong> (or Vercel Postgres) and copy the <strong>pooled</strong> connection string.</li></ol>\n<ol><li>Set the env vars below (Project → Settings → Environment Variables), then deploy.</li></ol>\n<ul><li>`buildCommand: next build` — <strong>critical.</strong> The repo&#39;s `pnpm build` runs `session:closeout:check` first, which fails 24h after the last close-out and would break every deploy. `next build` skips that guard. Local `pnpm build` is unchanged and still enforces the guard.</li></ul>\n<h3 id=\"vercel-environment-variables\">Vercel environment variables</h3>\n<p>| Var | Required | Value |</p>\n<p>|---|---|---|</p>\n<p>| `DATABASE_URL` | ✅ | Neon <strong>pooled</strong> connection string |</p>\n<p>| `METAMUSEUM_STORAGE_MODE` | ✅ | `postgres` |</p>\n<p>| `AUTH_SECRET` | ✅ | `openssl rand -base64 32` |</p>\n<p>| `BASE_URL` | ✅ | shareable production URL, for example `https://www.metamuseum.org` |</p>\n<p>| `METAMUSEUM_PUBLIC_READ_BASE_URL` | ✅ | exactly the same URL as `BASE_URL` |</p>\n<p>| `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET` | ⛔ optional | enables GitHub sign-in; omit for public read-only |</p>\n<p>| `VALIDATION_SERVICE_URL` | ✅ for launch readiness | `https://metamuseum-validation.onrender.com/validate` (Part B) |</p>\n<p>| `RECONCILIATION_SERVICE_URL` | ✅ for launch readiness | `https://metamuseum-reconciliation.onrender.com` (Part B / ops probes) |</p>\n<p>| `METAMUSEUM_EXTERNAL_SERVICE_PROBE_TIMEOUT_MS` | ⛔ optional | default `45000`; allows Render cold starts in preflight |</p>\n<p>| `METAMUSEUM_VALIDATION_SERVICE_CAPACITY` | ✅ if strict validation is public on Render | `paid` after upgrading the validation service |</p>\n<p>| `METAMUSEUM_VALIDATION_SERVICE_MAX_HEALTH_MS` | ⛔ optional | default `5000`; user-facing strict-validation health budget |</p>\n<p>| `VALIDATION_TIMEOUT_MS` | ⛔ optional | default `15000`; runtime timeout for `/api/validate` proxy calls |</p>\n<p>| `METAMUSEUM_AG2_BRIDGE_ENABLED` | ⛔ optional | leave unset (bridge stays off) |</p>\n<p>| `CRON_SECRET` | ✅ for Vercel Cron | long random value; Vercel sends it as `Authorization: Bearer ...` |</p>\n<p>| `METAMUSEUM_OUTBOX_CRON_ENABLED` | ⛔ optional | `1` only when Solr/GraphDB projection is enabled |</p>\n<p>| `METAMUSEUM_PUBLISH_QUEUE_CRON_ENABLED` | ⛔ optional | `1` only when wiki/publication queue processing is enabled |</p>\n<p>Notes:</p>\n<p>  storage registry in `src/utils/storage.ts`, which routes those documents to</p>\n<p>  Postgres in production. `tests/quality/route-storage-write-guard.test.ts`</p>\n<p>  walks `app/api` imports and fails when a production route can reach raw</p>\n<p>  filesystem JSON writes or a JSON target not listed in</p>\n<p>  `MANAGED_STORAGE_DOCUMENT_FILES`.</p>\n<p>  must stay identical in production. Next.js resolves Open Graph image URLs</p>\n<p>  against the public-read metadata base, and `pnpm launch:preflight:production`</p>\n<p>  now fails if the two variables diverge or if the public-read base is missing.</p>\n<p>  After every domain change, redeploy, run `pnpm smoke:crawler-preview`, and</p>\n<p>  refresh the homepage in Facebook Sharing Debugger plus the equivalent</p>\n<p>  LinkedIn/X crawler preview tools.</p>\n<p>  optional, but their mode is no longer implicit. When service URLs are unset,</p>\n<p>  deployment preflight records the local fallback path: non-strict validation</p>\n<p>  falls back to the local Linked Art profile checker, strict `/api/validate`</p>\n<p>  still needs a running validation service, and the app uses in-process</p>\n<p>  deterministic reconciliation. When either URL is set, preflight probes</p>\n<p>  `/health` with `METAMUSEUM_EXTERNAL_SERVICE_PROBE_TIMEOUT_MS` and records</p>\n<p>  status plus duration for launch evidence. Health probes intentionally tolerate</p>\n<p>  cold starts, but user-facing strict `/api/validate` has a separate runtime</p>\n<p>  timeout and production preflight fails an `onrender.com` validator unless</p>\n<p>  `METAMUSEUM_VALIDATION_SERVICE_CAPACITY` proves paid/no-sleep capacity and</p>\n<p>  the `/health` duration stays within `METAMUSEUM_VALIDATION_SERVICE_MAX_HEALTH_MS`.</p>\n<ul><li><strong>Storage self-heals on a fresh DB.</strong> Core stores (`records.ts`) catch a missing Postgres document and seed an empty `[]`, so an empty Neon DB won&#39;t crash the app.</li><li><strong>Runtime is Node, not Edge</strong> for API routes (required by `pg` + Auth.js v5) — this is the App Router default; no action needed.</li><li><strong>Read-only FS guard:</strong> request-path JSON writes must go through the managed</li><li><strong>Social preview base URL:</strong> `BASE_URL` and `METAMUSEUM_PUBLIC_READ_BASE_URL`</li><li><strong>External service mode:</strong> validation and reconciliation Render services are</li></ul>\n<p>---</p>\n<h2 id=\"part-b-python-services-on-render\">Part B — Python services on Render</h2>\n<p>`render.yaml` is a Blueprint defining the deployed FastAPI validation and</p>\n<p>reconciliation services plus the Redis cache used by reconciliation.</p>\n<p>Current deployed endpoints:</p>\n<ul><li>Validation health: `https://metamuseum-validation.onrender.com/health`</li><li>Validation API: `https://metamuseum-validation.onrender.com/validate`</li><li>Reconciliation health: `https://metamuseum-reconciliation.onrender.com/health`</li><li>Reconciliation API base: `https://metamuseum-reconciliation.onrender.com`</li></ul>\n<h3 id=\"setup\">Setup</h3>\n<ol><li>In Render: <strong>New → Blueprint</strong>, point at this repo. It reads `render.yaml`.</li></ol>\n<ol><li>Each service: own `rootDir`, `pip install -r requirements.txt`, and</li></ol>\n<p>   `uvicorn main:APP --host 0.0.0.0 --port $PORT` (bypasses the hardcoded</p>\n<p>   `127.0.0.1` in each `__main__`). Health check: `/health`.</p>\n<ol><li>Redis (`metamuseum-reconcile-cache`) is wired into the reconciliation</li></ol>\n<p>   service via `RECONCILIATION_REDIS_URL` automatically.</p>\n<ol><li><strong>AG2 worker is optional</strong> — delete that block from `render.yaml` unless you</li></ol>\n<p>   plan to enable the bridge.</p>\n<h3 id=\"wire-services-back-to-vercel\">Wire services back to Vercel</h3>\n<p>Set these URLs on Vercel so deployment preflight captures live Render health</p>\n<p>evidence:</p>\n<p>  is upgraded off sleeping/free-tier capacity for user-facing strict validation</p>\n<p>  when operator tooling or smoke evidence should use the deployed reconciliation service</p>\n<ul><li>`VALIDATION_SERVICE_URL = https://metamuseum-validation.onrender.com/validate`</li><li>`METAMUSEUM_VALIDATION_SERVICE_CAPACITY = paid` after the validation service</li><li>`RECONCILIATION_SERVICE_URL = https://metamuseum-reconciliation.onrender.com`</li></ul>\n<p>The reconciliation service is invoked by operator scripts/ops tooling rather</p>\n<p>than the Next runtime; point those at `https://metamuseum-reconciliation.onrender.com`.</p>\n<blockquote><strong>Free-tier caveat:</strong> Render free web services sleep after inactivity and</blockquote>\n<blockquote>cold-start in ~30–60s. Fine for a demo; upgrade validation to paid/no-sleep</blockquote>\n<blockquote>capacity before exposing strict `/api/validate` to production users, then set</blockquote>\n<blockquote>`METAMUSEUM_VALIDATION_SERVICE_CAPACITY=paid` and rerun preflight.</blockquote>\n<p>---</p>\n<h2 id=\"part-c-background-workers\">Part C — Background workers</h2>\n<p>Vercel cannot run persistent Node processes, so the app uses secured Vercel</p>\n<p>Cron routes for bounded drain cycles:</p>\n<p>| Worker | Script | Needed when |</p>\n<p>|---|---|---|</p>\n<p>| Outbox projector | `pnpm outbox:projector` | Solr/GraphDB projection is enabled (off in this deploy) |</p>\n<p>| Publish queue worker | `pnpm publish:queue:worker` | Wiki/publication publishing is used |</p>\n<p>Configured cron routes:</p>\n<ul><li>`/api/cron/outbox` every 5 minutes.</li><li>`/api/cron/publish` every 10 minutes.</li></ul>\n<p>Both routes return `cache-control: no-store`. The schedules may be deployed</p>\n<p>safely while the workers are disabled: disabled drains return `200` no-op JSON</p>\n<p>instead of `503`, so Vercel Observability does not count intentional idle</p>\n<p>schedules as production errors. Once a worker drain is enabled, that route</p>\n<p>requires `Authorization: Bearer $CRON_SECRET` before any work can run:</p>\n<p>  Tune with `METAMUSEUM_OUTBOX_CRON_LIMIT` and optional</p>\n<p>  `METAMUSEUM_OUTBOX_CRON_WORKER_ID`. Projection still requires the usual</p>\n<p>  `OUTBOX_PROJECT_TO_SOLR` / `OUTBOX_PROJECT_TO_GRAPHDB` target env vars; the</p>\n<p>  cron route will not claim events until at least one target is explicitly `1`.</p>\n<p>  Direct projector scripts use the same target gates and skip Solr/GraphDB when</p>\n<p>  those vars are unset.</p>\n<p>  batch. Tune with `METAMUSEUM_PUBLISH_QUEUE_CRON_MAX_ITEMS`. The default</p>\n<p>  queue document is `storage/publish-queue.json`, which is a managed storage</p>\n<p>  document and therefore Postgres-backed in Vercel `postgres` mode.</p>\n<ul><li>`METAMUSEUM_OUTBOX_CRON_ENABLED=1` drains one bounded outbox projector batch.</li><li>`METAMUSEUM_PUBLISH_QUEUE_CRON_ENABLED=1` processes a bounded publish queue</li></ul>\n<p>Scale enablement guard:</p>\n<p>  `/api/cron/outbox` is deployed, `CRON_SECRET` is set, and</p>\n<p>  `METAMUSEUM_OUTBOX_CRON_ENABLED=1` is ready to drain at the configured</p>\n<p>  cadence. First run `pnpm projection:readiness`; production preflight fails if</p>\n<p>  projection targets are enabled without that scheduler.</p>\n<p>  `/api/cron/publish` is deployed, `CRON_SECRET` is set, and</p>\n<p>  `METAMUSEUM_PUBLISH_QUEUE_CRON_ENABLED=1` is ready. Production preflight</p>\n<p>  fails if live publication endpoints or bot tokens are configured without the</p>\n<p>  publish queue scheduler.</p>\n<ul><li>Keep `OUTBOX_PROJECT_TO_SOLR` and `OUTBOX_PROJECT_TO_GRAPHDB` off until</li><li>Keep live MediaWiki/Wikibase API URLs and bot tokens out of production until</li></ul>\n<p>Operator status:</p>\n<p>  next drain time, and disabled projection/publish mode.</p>\n<p>  review-goals, launch, long-term, ActivityStreams, pilot, DR, and</p>\n<p>  disabled-system drill artifacts.</p>\n<p>  agent checks. Use it after worker env changes so scheduled-but-disabled</p>\n<p>  features do not look as if they are progressing.</p>\n<p>  `pnpm staging:disabled-drill:check` weekly (`20 10 <em> </em> 1`) while AG2,</p>\n<p>  Solr/GraphDB projection, and publication drains are intentionally off; run</p>\n<p>  the same command manually after staging env changes. It writes</p>\n<p>  `artifacts/staging-disabled-systems/latest.json`, uploads timestamped run</p>\n<p>  artifacts, confirms disabled paths do not make external network calls,</p>\n<p>  mock-exercises the first enabled AG2, Solr/GraphDB projection, and</p>\n<p>  publication webhook branches, and keeps latent worker/queue regressions</p>\n<p>  visible before real enablement.</p>\n<ul><li>`/workers` renders worker lag, backlog, next Vercel Cron wakeup, effective</li><li>`/readiness` renders a compact editor-gated status view over the latest</li><li>`/api/workers/status` exposes the same no-store JSON snapshot for probes or</li><li>`.github/workflows/staging-disabled-systems-drill.yml` runs</li></ul>\n<p>For heavier projection loads, keep these cron routes disabled and run the same</p>\n<p>scripts as a Render background worker instead:</p>\n<ul><li>`pnpm outbox:projector:once`</li><li>`pnpm publish:queue:worker:once`</li></ul>\n<p>---</p>\n<h2 id=\"quick-path-to-a-live-demo\">Quick path to a live demo</h2>\n<ol><li>Neon DB → copy pooled `DATABASE_URL`.</li></ol>\n<ol><li>Vercel import → set `DATABASE_URL`, `METAMUSEUM_STORAGE_MODE=postgres`,</li></ol>\n<p>   `AUTH_SECRET`, `BASE_URL`, `METAMUSEUM_PUBLIC_READ_BASE_URL` → deploy.</p>\n<ol><li>Render Blueprint services are live → set `VALIDATION_SERVICE_URL` and</li></ol>\n<p>   `RECONCILIATION_SERVICE_URL` on Vercel → redeploy.</p>\n<ol><li>Run `BASE_URL=https://&lt;shareable-url&gt; METAMUSEUM_PUBLIC_READ_BASE_URL=https://&lt;shareable-url&gt; VALIDATION_SERVICE_URL=https://metamuseum-validation.onrender.com/validate METAMUSEUM_VALIDATION_SERVICE_CAPACITY=paid RECONCILIATION_SERVICE_URL=https://metamuseum-reconciliation.onrender.com pnpm launch:preflight:production`; it will capture configured Render service `/health` status/duration and fail if strict validation is still on sleeping capacity or outside the user-facing health budget. Then run `BASE_URL=https://&lt;shareable-url&gt; pnpm smoke:crawler-preview` and re-scrape social previews in crawler debug tools.</li></ol>\n<ol><li>Set `CRON_SECRET`; leave worker enable flags off until projection/publishing is needed.</li></ol>","updatedAt":"2018-10-20T01:46:40.000Z","checksum":"d45b5fd8d1c0e705febbccd3550b912562499d1eddf178a6ebca8a0ae4200236","checksumPrefix":"d45b5fd8d1c0","anchorCount":8,"lineCount":222,"rawUrl":"/api/docs/content?path=deployment.md","htmlUrl":"/docs?doc=deployment.md","apiUrl":"/api/docs/content?path=deployment.md"}