Stack: MIE Create-a-Container (frontend + backend containers) + Neon Postgres 16 + Cloudflare R2 (evidence, backups) + OpenAI. Cost target: under about $25/month.
Backups, restores and disaster recovery: docs/BACKUP_DR_RUNBOOK.md. The deploy skill lists the
traps to read first. Rationale lives in the ADRs cited inline.
All stacks run on MIE's container platform (os.mieweb.org, site id 1). Backend: the TypeScript worker
(backend-ts/, MIEWEB_TARGET=local, port 8080). Frontend: Next.js (port 3000). The workwell_spike
schema self-creates on boot; there are no migration files.
| Stack | Frontend | Backend API | Trigger | Self-heal |
|---|---|---|---|---|
| TWH (demo, primary) | twh.os.mieweb.org |
twh-api-ts.os.mieweb.org |
push to main + dispatch |
reconcile-twh-mieweb.yml |
| Maui (pilot sandbox) | maui.os.mieweb.org |
maui-api-ts.os.mieweb.org |
push to main + dispatch |
reconcile-maui-mieweb.yml |
| Staging (live WebChart) | twh-staging.os.mieweb.org |
twh-staging-api-ts.os.mieweb.org |
dispatch only | none |
Images: backend ghcr.io/taleef7/workwell-api-ts (shared; tags namespaced per stack), frontends
…/workwell-twh-frontend and …/workwell-maui-frontend.
A push to main runs deploy-twh-mieweb.yml and deploy-maui-mieweb.yml, unless it changes only docs/
or root *.md files (no image contains them). Each:
- Builds the backend: vendors official terminology (Step 1), runs the reproducibility gate
git diff --exit-code backend-ts/measures/official, bakesWORKWELL_BUILD_SHAinto the image. TWH tagslatest+sha-<SHA>; Maui pushes onlymaui-sha-<SHA>. - Builds the frontend with the stack's build args.
- Deploys the backend: validates required secrets, builds the container env as a fixed
jqarray, runs.github/scripts/deploy-mieweb-container.sh(delete + recreate, ~30–120 s of downtime). - Deploys the frontend only if the backend deploy succeeded.
Each stack's deploy and reconcile share a concurrency group (twh-mieweb-container-ops,
maui-mieweb-container-ops), so a heal never races a deploy. A push always replaces containers; a
manual dispatch needs replace_existing=true (false is for first creation only).
measures/official/<catalogId>/terminology.json is gitignored on purpose (VSAC/CPT-derived content
must not be redistributed). Every build re-fetches it from the pinned upstream commit; the committed
manifest's SHA-256 pins the bytes. Both deploy workflows run, per vendored measure (cms122, cms125, cms2,
cms68, cms951, cms138, cms130, cms165, plus cms137 on Maui):
node scripts/vendor-official-measure.mjs --measure CMS122FHIRDiabetesAssessGT9Pct --catalog-id cms122 --strip-elm-annotations --complete-terminologyPlain node, no install; retries transport errors and 5xx, a 4xx is final. Without the sidecar the
fidelity diff silently degrades from literal to subset/estimate, and a routed measure refuses.
Locally: pnpm vendor:official --measure <MADiE name> --catalog-id <id> --strip-elm-annotations.
Upstream caps expansions at 1000 codes and omits some value sets; a capped or empty set narrows a
population silently, so routing refuses one. This flag completes them from VSAC at the release the
content names (old name --complete-capped-expansions still accepted).
-
WORKWELL_VSAC_API_KEY_VENDORmissing, or VSAC unreachable ⇒ capped codes ship, the manifest no longer matches the committed one, and the deploy fails at the reproducibility gate. So every deploy needs NLM VSAC reachable: any post-ADR-041 SHA, forward or rollback, fails during an outage. -
_VENDOR(build time) andWORKWELL_VSAC_API_KEY_TWH(runtime resolver, ADR-023) are separate secrets on purpose (ADR-036), even though they hold the same UMLS key. -
Landing order — secrets before manifests. Add or change the secret in the same change that commits the regenerated manifests, or CI fails on every PR and every push to
mainfails its deploy:cd backend-ts WORKWELL_VSAC_API_KEY=<umls-api-key> pnpm vendor:official --measure <Name> --catalog-id <id> --strip-elm-annotations --complete-terminology git diff measures/official # sha256 moves, truncated → [], a completion block appears pnpm test:official-cases # then commit the regenerated report
A new completion changes
officialLogicVersion(ADR-040) and invalidates that measure'seval_state. -
pnpm official:terminology-audit [<MADiE name>]lists value sets the ELM retrieves but the bundle does not ship (CMS138 is the known case). Only VSAC fixes those; the MADiE deck is the real check.
gh workflow run vendor-official-measure.yml -f measure=CMS138FHIRTobaccoScrnCessation -f catalog_id=cms138
gh run watch
gh run download <run-id> -n vendored-cms138 -D backend-ts/measures/official/cms138 # -D = the catalog dirIt uploads bundle.json + manifest.json only (never the licensed terminology.json), and fails
without the credential or with an incomplete artifact. Then, in one PR: commit both files and add the
measure to OFFICIAL_GATED_MEASURES, both deploy workflows' vendor lists and fetch-official-cases.ps1.
Routing is WORKWELL_OFFICIAL_MEASURES: TWH cms122,cms125; Maui
cms122,cms125,cms2,cms130,cms165,cms137 (ADR-078). Pass a stack's current list to flip tooling.
A missing artifact, sidecar or complete expansion refuses. A whole roster falling outside the
initial population does not — it completes with MISSING_DATA and a WARN. This catches it:
-
The gate is green. These self-skip without the sidecar, so run them explicitly and expect
skipped 0(a new sidecar-reading test must be added to CI'sofficial-casesjob):cd backend-ts pwsh -NoProfile -File scripts/fetch-official-cases.ps1 WORKWELL_VSAC_API_KEY=<umls-api-key> pnpm vendor:official --measure <Name> --catalog-id <id> --strip-elm-annotations --complete-terminology pnpm exec node --import tsx --test src/wiring/official-corpus-outcomes.test.ts src/engine/ingress/webchart/devdb-official-eval.test.ts pnpm test:official-cases
No authored counterpart (cms2, cms130, cms165, cms137) ⇒ use
flip-gate, notflip-snapshot(ADR-072):WORKWELL_INSTANCE=maui WORKWELL_MAUI_CORPUS_SIZE=20000 WORKWELL_OFFICIAL_MEASURES=<list> pnpm flip-gate --measure <id> --evaluation-date <YYYY-MM-DD> [--subjects 2000|all]. It reports the MADiE deck, the deployment's own roster and the artifact'seffectivePeriod, writes.flip-gate/<id>-<date>.jsonfor the PR, and always exits 0. Use--subjects allfor evidence. cms122/125/130/165 sidecars are VSAC-completed, so locally they read UNAVAILABLE — run the manualflip-gate.ymlinstead. cms165 on real WebChart data also needs profile-stamped BPs (#591). -
Take the before/after snapshot and confirm a NON-ZERO initial population:
pnpm flip-snapshot --measure <id> --source synthetic --eval <YYYY-MM-DD> # stack with no WORKWELL_WEBCHART_* WORKWELL_WEBCHART_BASE_URL=… WORKWELL_WEBCHART_CLIENT_ID=… WORKWELL_WEBCHART_PRIVATE_KEY_B64=… \ pnpm flip-snapshot --measure <id> --source live --eval <YYYY-MM-DD> # WebChart stack
Verdict DO NOT FLIP = official admits nobody while authored finds subjects (a data/mapping gap); INCONCLUSIVE = neither finds anyone; no verdict = proceed. It exits 0; a human decides — never wire it into CI.
--source fixtureis not a substitute forlive;syntheticis an engine-agreement check, not a roster forecast. No roster is needed: measures with an official artifact are never roster-stamped, so a tenant with no qualifying encounters reads INCONCLUSIVE. -
Check the numerator, not just membership (ADR-044: CPT vs LOINC mammograms made screened women OVERDUE).
-
Edit the workflows, never the container: the stack's deploy and reconcile workflow.
official-flip-config.test.tsfails the build if they disagree or a measure lacks its gate (ADR-045). -
Redeploy and check the signals. A misconfiguration does not fail boot: grep
WORKWELL_ALERT {"kind":"OFFICIAL_ROUTING_MISCONFIGURED"(health stays 200 while evaluations 500). Then run one population run:run_logsshowsN subject(s) evaluated in one official batchper routed measure, plus the ADR-043WARNif a roster fell out of the population.
Reversible: remove the id from both workflows and redeploy; logic_version carries the artifact's
identity (ADR-040), so no cache cleanup is needed.
- v1 API (
<manager-origin>/api/v1):{"data": …}envelopes; create bodytemplate+services[]; job polling reads.data.status == "success". - Job poll
DEPLOY_JOB_POLL_ATTEMPTS× 10 s (default 90, max 360) — raise it on a dispatch if a slow pull times out; then container status is polled 18 × 10 s forrunning. - 10 s connect / 30 s total per request.
GETretries 6× 20 s apart;POST/DELETEare sent once. - An ambiguous
DELETEis resolved by reading the manager back (mieweb-delete-confirmed.sh): absent ⇒ continue; could not tell ⇒ fail the job; still registered ⇒ re-issue, which re-targets the id the manager itself just reported. Knobs:MIEWEB_DELETE_ATTEMPTS(3),MIEWEB_DELETE_CONFIRM_ATTEMPTS(6),MIEWEB_DELETE_CONFIRM_DELAY_SECONDS(10). Pinned bymieweb-delete-confirmed{,.integration}.test.shandmieweb-api-request.test.shin CI.
The deploy fails if a required secret is missing. Suffixed secrets map to unsuffixed runtime names
(DATABASE_URL_TWH → DATABASE_URL); a jdbc: prefix is stripped.
| Secret | Purpose |
|---|---|
LAUNCHPAD_API_URL, LAUNCHPAD_API_KEY |
Container Manager API (every deploy/reconcile) |
DATABASE_URL_TWH / _MAUI / _STAGING |
Neon pooled URL per stack; also the backup job. Maui/staging read _TWH only to refuse a same-host paste |
WORKWELL_AUTH_JWT_SECRET_TWH / _MAUI / _STAGING |
JWT signing secret |
WORKWELL_PILOT_PASSWORD_HASH_MAUI |
PBKDF2 hash of the pilot accounts' password (the demo password is public). Rotate with a long random password: run cd backend-ts; corepack pnpm@10 exec node --import tsx -e "import('./src/auth/password.ts').then(async (m) => console.log(await m.hashPassword(require('fs').readFileSync(0, 'utf8').replace(/\r?\n$/, ''))))", type the password and end the input (Ctrl+D; Ctrl+Z then Enter on Windows) so it stays out of shell history and the process list, and paste its output into gh secret set WORKWELL_PILOT_PASSWORD_HASH_MAUI (never --body "…": the hash contains $), then redeploy with workflow_dispatch and replace_existing=true (a restart in place keeps the old env). Sessions opened under the old password end within 15 minutes of the redeploy: their refresh tokens carry the old credential version and cannot refresh. Unset on the deployed stack, pilot sign-in is disabled |
OPENAI_API_KEY |
AI surfaces |
WORKWELL_VSAC_API_KEY_VENDOR |
build-time VSAC (Step 1a); CI, deploys, flip-gate, vendor, cross-engine-sweep |
WORKWELL_VSAC_API_KEY_TWH |
runtime VSAC resolver on TWH and Maui |
WORKWELL_BUCKET_S3_ACCESS_KEY_ID_TWH / SECRET_ACCESS_KEY_TWH, …_MAUI |
R2 token per evidence bucket |
WORKWELL_R2_S3_ENDPOINT |
R2 endpoint (secret because it embeds the account id; the repo is public) |
WORKWELL_BACKUP_S3_ACCESS_KEY_ID / SECRET_ACCESS_KEY |
R2 token for workwell-backups |
WORKWELL_WEBCHART_PRIVATE_KEY_STAGING |
teatea SMART key (staging) |
WORKWELL_ALERT_WEBHOOK_URL |
Maui's failed-run alert webhook (optional; see "Failed-run alerts") |
NPM_TOKEN |
publish-packages.yml |
Both live stacks ship: MIEWEB_TARGET=local, WORKWELL_ENVIRONMENT=production, DATABASE_URL,
OPENAI_API_KEY, WORKWELL_CORS_ALLOWED_ORIGINS + CORS_ALLOWED_ORIGINS (the frontend URL),
WORKWELL_AUTH_COOKIE_SAME_SITE=None, WORKWELL_AUTH_COOKIE_SECURE=true, WORKWELL_AUTH_JWT_SECRET,
WORKWELL_INSTANCE, WORKWELL_SCHEDULER_ENABLED=true, WORKWELL_OFFICIAL_MEASURES,
WORKWELL_VSAC_API_KEY and the five WORKWELL_BUCKET_S3_*; Maui adds the corpus variables and
WORKWELL_ALERT_WEBHOOK_URL.
WORKWELL_EMAIL_PROVIDER is unset, so email stays simulated. The reconcilers duplicate this array —
keep-in-sync.
WORKWELL_ENVIRONMENT=production arms config/startup-safety.ts: auth on, JWT secret ≥32 chars and not a
demo value, exact CORS origins, refresh cookie SameSite=None; Secure (frontend and API are different
origins; otherwise silent refresh fails).
Frontend: env NODE_ENV=production, NEXT_PUBLIC_API_URL, NEXT_PUBLIC_API_BASE_URL; build args
NEXT_PUBLIC_API_URL, _APP_NAME, _APP_TAGLINE, _APP_DESCRIPTION, _CODIFY_INDEX_URL (repo
variable); Maui adds NEXT_PUBLIC_SUBJECT_TERM=patient and NEXT_PUBLIC_PUBLIC_DEMO=off.
WORKWELL_INSTANCE (unset/default/twh, or maui) selects visible and evaluable tenants and runnable
measures, and the backend subjectTerm (employee | patient) used in AI prompts, outreach and CSV
headers; NEXT_PUBLIC_SUBJECT_TERM must match.
Deploy and reconcile must ship identical values (official-flip-config.test.ts checks).
| Variable | Maui | TWH | Notes |
|---|---|---|---|
WORKWELL_MAUI_CORPUS_SIZE |
20000 |
unset (48) | Changing it is a recreate, not a migration; the first N patients never change. |
WORKWELL_MAUI_CORPUS_SEED |
unset (maui-py2027-v1) |
unset | Changing it makes different people under the same ids. |
WORKWELL_RUN_CHUNK_SIZE |
500 |
unset (500) | Subjects per chunk; bounds memory. |
WORKWELL_SCHEDULER_ANCHOR_HOUR_UTC |
12 |
unset (12) | Nightly hour (12 UTC = 02:00 HST). The 23.5-hour debounce is a floor beneath the anchor, not the cadence. |
WORKWELL_SCHEDULER_DAYS |
1-5 |
unset (every day) | UTC weekdays the nightly runs on, cron day-of-week form (0 or 7 = Sunday). Maui skips the weekend: each nightly is metered compute. A bad value means every day. The Programs freshness banner reads it (GET /api/runs/schedule), so a weekend is not a missed update. When 31 December falls on a weekend (2028) the year's last nightly is the Friday before: start a run by hand to score the last days of the year. |
WORKWELL_OUTCOME_RETENTION_DAYS |
90 |
unset (off) | ADR-073/077. 90 days on the sandbox since 2026-09-30 (was 400) to stop storage growing; revisit before a real performance year. Keeps each subject's newest row per (measure, period), run rows and case-cited rows. Needs the keep-set indexes schema-pg.ts creates. |
Clinical facts follow the calendar year of each run's evaluation date. Turning retention on deletes a lot
at once: run the first pass by hand with pnpm outcomes:compact, then restart the backend (below).
Case creation is gated by segment applicability, and the baseline segment (All Employees / All Patients) lists sites and measures. A fresh database derives it; an already-seeded one never changes
(seeding never mutates). So after adding a tenant, site or routed measure, widen it via the audited
PUT /api/segments/:id (/admin → Groups, writes SEGMENT_UPDATED) — never edit the tables. A run
writes a WARN containing no segment makes them applicable when this is owed. Done on both live stacks
(TWH 2026-06-29; Maui #536).
- Start runs with
POST /api/runs/manual. A barePOST /api/runscreates an unclaimableQUEUEDrun that sits until the boot sweep fails it after 6 h. - Stuck runs: at boot, a
RUNNINGrun created before this process started is failed and audited (RUN_RECOVERED). One backend container at a time is assumed — a second would sweep live runs. - Read-model warm (ADR-087): a deploy empties every in-process memo; boot warms them in the
background. Grep
read models warmed at boot in <ms>msorboot read-model warm failed after …, or readlastWarmon/health(every pass:boot,nightly,run) andwarms, with the error, on/api/admin/runtime(#615). The cold path's worst statement, the winners probe, is served byspike_outcomes_run_measure_idx(#615; created at boot by the schema DDL). - Measure performance on a quiet, warm worker — not during the nightly (12:00 UTC, ~90 min on Maui) and not in the first minutes after a deploy.
ALL_PROGRAMS, SITE and MEASURE are scheduled: 201 with status: RUNNING and a runId — poll
GET /api/runs/:id. EMPLOYEE runs in the request. Never retry a run you think failed: the retry
also runs, and two 20,000-patient runs exhaust the 10-connection pool (503 pool_exhausted). On a 504,
check GET /api/runs?status=RUNNING first.
Authored measures yield after every subject. Official measures run each chunk in one fqm-execution
call that cannot yield; the only lever is WORKWELL_RUN_CHUNK_SIZE (smaller = shorter stalls, more DB
round trips). Measure it on your deployment:
curl -s "$BASE/api/runs/manual" -X POST -H "$AUTH" -d '{"scopeType":"ALL_PROGRAMS"}'
curl -s "$BASE/api/runs/$RUN_ID" -H "$AUTH" | jq -r '.logs[] | select(.message|test("ms/subject"))'TLS completes but nothing answers = a blocked event loop (/health does no I/O). Read the log
before healing — a recreate discards it. WORKWELL_ALERT {"kind":"EVENT_LOOP_STALL_ONGOING",…} is
written by a watchdog thread during the stall and names the longest-running requests;
{"kind":"EVENT_LOOP_STALL",…} follows once it ends (max one a minute). Afterwards GET /health shows
build.sha, startedAt/uptimeSeconds and eventLoop; GET /api/admin/runtime (ADMIN) shows what ran.
A stall past 30 s also writes its report to var/stall-evidence.json on the container's disk
(WORKWELL_STALL_EVIDENCE_PATH). Heal by restarting, not recreating: the reconcilers now send a
restart-only request (PUT /sites/{site}/containers/{id} with {"restart": true}) and recreate only if the
API is still down after it. A restart keeps the disk, so the next boot logs
WORKWELL_ALERT {"kind":"PREVIOUS_PROCESS_STALLED",…} and shows the report as previousStall on
/api/admin/runtime (timings on /health). A recreate deletes it, as it deletes the logs.
Each official batch logs WORKWELL_RUNTIME {"kind":"evaluateBatch",…,"batchMs","bundleMs","evalMs", "msPerSubject","outcome"} (separate from WORKWELL_ALERT on purpose). batchMs is one uninterrupted
synchronous stretch; bundleMs points at the bundle source, evalMs at chunk size. The same numbers are
on the run log's per-measure INFO line (GET /api/runs/:id) for anyone without container-log access.
Since #604 that stretch runs in worker threads (wiring/fqm-worker.ts), so it no longer stalls the event
loop; batchMs still measures it. A chunk's measures are calculated side by side on a pool of
WORKWELL_FQM_WORKERS workers (default 2, never more than the cores minus one; ~500 MB each while busy,
released after 5 idle minutes). WORKWELL_FQM_WORKERS=1 is one worker; WORKWELL_FQM_WORKER=off runs the
calculation in-process again (the escape hatch if the workers misbehave). cpus and memoryLimitMb are on
/api/admin/runtime.
A FAILED/PARTIAL_FAILURE run (any scope, a single patient's included), a stuck-run recovery, a scheduler
tick throw or an unreachable evidence bucket emits one WORKWELL_ALERT {"kind":…} log line with the whole
alert, plus a JSON POST to WORKWELL_ALERT_WEBHOOK_URL when set. The POST carries only fields that cannot
name a patient (kind, time, status, run id, scope type, counts; never the scope label or error text) and
a one-line summary as text and content, so a Slack, Teams or Discord incoming-webhook URL works as it
is (#623). A non-2xx answer is logged as a failed delivery; a value that is not an https URL turns the
webhook off with a log line that does not print it.
Set on Maui only, from the WORKWELL_ALERT_WEBHOOK_URL secret (the deploy and the self-heal both carry
it; a test holds them together). It points at a Google Apps Script web app that emails the owner the
summary line, deployed with "Execute as: Me" and access "Anyone" (the URL is the only credential):
function doPost(e) {
const alert = JSON.parse(e.postData.contents);
MailApp.sendEmail(Session.getEffectiveUser().getEmail(), "WorkWell alert: " + alert.kind, alert.text);
return ContentService.createTextOutput("ok");
}A new Apps Script deployment gets a new URL: update the secret, then redeploy Maui (a restart in place
keeps the old environment). On TWH the alert reaches nobody. Either way, /programs shows a banner when
the latest overnight update failed, finished with errors, or has not run for 36 hours.
From backend-ts/, with DATABASE_URL set. Idempotent and resumable. Undo SQL is schema-qualified
workwell_spike.; delete tagged outcomes before runs (no cascade).
| Command | Writes | Status | Undo |
|---|---|---|---|
pnpm seed:scale --subjects N --as-of <date> [--workers n] |
the mhn scale tenant (real batch evaluation) |
TWH: 5,000 (done) | rows of runs with triggered_by='seed:scale' |
pnpm seed:quality-history --months 12 --as-of <YYYY-MM> |
past quality_snapshots |
TWH: done | DELETE FROM quality_snapshots |
pnpm seed:trend-history --weeks N --as-of <date> |
synthetic weekly runs | refuses under retention | triggered_by='seed:trend-history' |
pnpm resolve-valuesets --manifest <canonical> [--official <id>] |
VSAC expansions into value_sets (descriptive) |
TWH: done | DELETE FROM value_sets WHERE source='VSAC' |
pnpm outcomes:compact |
one retention pass | also runs after each Maui nightly | none |
A crashed seed:scale leaves RUNNING runs that are not auto-swept — roll back before resuming.
These run in their own process, so the running backend keeps the Run History counts it had already read
(kept until compaction or a restart, run/run-counts.ts). After seed:scale, seed:trend-history or
outcomes:compact against a live database, restart the backend (the re-deploy below).
Actions → the stack's deploy workflow → Run workflow, replace_existing: true.
The pilot group's sandbox (deploy-maui-mieweb.yml), with its own Neon project, JWT secret and R2 bucket
(workwell-evidence-maui); env as above with WORKWELL_INSTANCE=maui and the six routed measures.
- Isolation guard: deploy and reconcile refuse when
DATABASE_URL_MAUIhas the same host asDATABASE_URL_TWH. - Backend tags are namespaced — do not "simplify" this. Maui deploys
maui-sha-<SHA>and must never publish:latest, which TWH's reconciler heals from. - Recovery tags name the last SUCCESSFUL DEPLOY. After a container is up, its deploy job re-points
maui-latest(backend) or the frontend's:latestat the deployed digest (docker buildx imagetools create).reconcile-maui-mieweb.ymlheals from those, never onto a failed build. - Demo accounts are profile-scoped (#520): only
@maui.workwell.devaccounts sign in on Maui, and they are refused elsewhere. Password: the stack's own, fromWORKWELL_PILOT_PASSWORD_HASH_MAUI(local runs and CI keep the demo password inbackend-ts/src/auth/demo-users.ts).
| Identifier | Role |
|---|---|
quality-lead@maui.workwell.dev, quality-staff@maui.workwell.dev |
ROLE_CASE_MANAGER |
clinician@maui.workwell.dev |
ROLE_VIEWER |
admin@maui.workwell.dev |
ROLE_ADMIN |
deploy-staging-mieweb.yml (dispatch, from the branch to test) runs live against the teatea WebChart
trial (synthetic data). Separate Neon project (the deploy refuses TWH's host; create it on Postgres 16,
us-east-1, 0.25–2 CU), staging-* tags, scheduler off, no self-heal.
- The only WebChart secret is the plain PEM
WORKWELL_WEBCHART_PRIVATE_KEY_STAGING(set it from the file:Get-Content key.pem -Raw | gh secret set WORKWELL_WEBCHART_PRIVATE_KEY_STAGING); the workflow base64-encodes it. teatea 403s a bareGET /Patient, so the workflow setsWORKWELL_WEBCHART_PATIENT_SEARCH=birthdate=le9999-12-31. - Residual gap: the client detects a truncated fetch (
Bundle.total) but not a query that under-matches, and no birthdate bound reaches a record with nobirthDate; teatea has no$export. - Verify: staging's seam line reads
webchart=on; TWH and Maui still readwebchart=off. When the trial lapses, live runs fail and the prior population stays authoritative.
Live stacks. Restart-on-reboot is Proxmox onboot, which the manager API neither exposes nor lets us
set. The reconcilers cover it regardless: every 15 min (cron) or on dispatch they probe the frontend
and /actuator/health (6 tries over ~3 min) and recreate a down container from its recovery tag
(:latest for TWH, maui-latest for Maui). The cron is not a recovery-time guarantee — GitHub
delivers scheduled runs hours apart; to heal now, run the reconcile workflow by hand.
Self-hosted / VM / local. infra/docker-compose.yml services are restart: unless-stopped, and
infra/systemd/workwell.service starts the stack on boot (infra/systemd/README.md):
sudo systemctl enable docker && sudo systemctl enable --now workwell.
B = backend container env, F = frontend build arg/env. .env.example mirrors the names.
| Var | Purpose | |
|---|---|---|
DATABASE_URL |
B | Pooled Neon URL ⇒ Pg ceiling; unset ⇒ SQLite floor (WORKWELL_SQLITE_PATH). |
WORKWELL_ENVIRONMENT |
B | production arms startup safety (SPRING_PROFILES_ACTIVE=prod and NODE_ENV=production also count). |
WORKWELL_INSTANCE |
B | Deployment profile. |
OPENAI_API_KEY |
B | AI surfaces (deterministic fallbacks, AI_GUARDRAILS.md §5); WORKWELL_AI_OPENAI_MODEL / _FALLBACK_MODEL override models; both must accept the options in AI_PROMPTS.md §3. |
WORKWELL_AUTH_ENABLED, WORKWELL_AUTH_JWT_SECRET |
B | Auth defaults on; false refused in production. |
WORKWELL_AUTH_COOKIE_SAME_SITE / _SECURE |
B | None / true in production; defaults Lax / false locally. |
WORKWELL_CORS_ALLOWED_ORIGINS |
B | Exact origins; production refuses wildcard/blank/localhost with 503 unsafe_configuration. First origin = Studio link in CDS cards; add a browser CDS client's origin deliberately (ADR-067). |
WORKWELL_SCHEDULER_ENABLED |
B | true enables the nightly scheduler. |
| corpus / chunk / anchor / retention vars | B | See the Maui table. |
WORKWELL_OFFICIAL_MEASURES |
B | Catalog ids run by the official artifact; all refused. Workflow edit only (flip checklist). |
WORKWELL_VSAC_API_KEY, WORKWELL_VSAC_BASE_URL |
B | Runtime VSAC resolver (ADR-023); base defaults to https://cts.nlm.nih.gov/fhir. |
WORKWELL_BUCKET_S3_BUCKET, _ACCESS_KEY_ID, _SECRET_ACCESS_KEY |
B | Evidence bucket; all three or evidence falls back to the in-container fs (lost on recreate). |
WORKWELL_BUCKET_S3_REGION, _ENDPOINT |
B | auto on R2; a non-empty endpoint also switches to path-style. |
WORKWELL_ALERT_WEBHOOK_URL |
B | Optional POST of each WORKWELL_ALERT. |
WORKWELL_INCREMENTAL_EVAL |
B | true enables outcome reuse (ADR-035); off on live stacks; undo DELETE FROM eval_state. |
WORKWELL_EMAIL_PROVIDER, WORKWELL_EMAIL_SENDGRID_API_KEY |
B | Simulated on the live stacks (hard rule). SendGrid only with both set, non-demo. |
WORKWELL_IMMZ_ICE_BASE_URL / _API_KEY |
B | Real ICE forecaster (below); unset on live stacks. |
WORKWELL_WEBCHART_BASE_URL + _CLIENT_ID + _PRIVATE_KEY_B64 |
B | SMART Backend Services (ADR-028); unset on TWH and Maui. Deployed stacks use _B64 (whole PEM, base64 -w0) — a multi-line value is truncated at the first newline. _PRIVATE_KEY is for local use. |
WORKWELL_WEBCHART_TOKEN_URL, _SCOPE, _KID, _API_KEY, _DISABLE_COUNT, _PATIENT_SEARCH, _ENROLLMENT_JSON |
B | Optional WebChart knobs (token URL override, scope default system/*.rs, JWK kid, legacy bearer, skip _count, population query, enrollment map). |
WORKWELL_WEBCHART_LIVE_TEST_BASE_URL |
test | Gates live-HTTP tests. Never on a deployed stack. |
NEXT_PUBLIC_API_BASE_URL |
F | Backend origin — no /api suffix, no trailing whitespace. |
NEXT_PUBLIC_APP_NAME |
F | Display name. |
NEXT_PUBLIC_DEMO_MODE |
F | Local login prefill; true fails the production build. |
NEXT_PUBLIC_SUBJECT_TERM |
F | employee or patient; anything else silently becomes employee. |
NEXT_PUBLIC_PUBLIC_DEMO |
F | off (Maui) hides public demo links and is pilot mode: non-admins lose engineering surfaces (frontend/lib/public-demo.ts). |
docker compose -f infra/docker-compose.yml up -d hapi-fhir
Set-Location backend-ts; corepack pnpm load:hapi
$env:WORKWELL_WEBCHART_BASE_URL = "http://localhost:8081"; $env:WORKWELL_WEBCHART_API_KEY = "local-dev"
corepack pnpm devAn ALL_PROGRAMS run should show 56 wc| rows and All Systems = Σ tenants. Live-HTTP tests: set
WORKWELL_WEBCHART_LIVE_TEST_BASE_URL and run hapi-live.test.ts / hapi-app-live.test.ts.
docker compose -f infra/docker-compose.yml --profile wcdb up -d wcdb wcdb-fhir-shim, then the HAPI
recipe with WORKWELL_WEBCHART_BASE_URL=http://localhost:8085. Local only (wcdb-fhir-shim/README.md).
Outreach is simulated: each attempt lands in outreach_delivery_log with status=SIMULATED. SendGrid
(backend-ts/src/case/email-service.ts) is an inert stub. POST /api/admin/demo-reset truncates demo
tables (including audit_events) and returns 403 in production.
docker run -d -p 32775:8080 --memory=3g hlnconsulting/ice:latest, then
WORKWELL_IMMZ_ICE_BASE_URL=http://localhost:32775/opencds-decision-support-service. Needs 2–3 GB and a
slow cold start. ICE is dialed only for a subject with a dose history, and there is no history source
yet (#628), so today it is never dialed. Calls time out at 3 s and answer an empty forecast (60 s
circuit breaker); grep ICE forecast failed if forecasts are missing. Advisory only.
Evidence goes through resolveBucket(env) (backend-ts/src/case/resolve-bucket.ts). Buckets (all
private, one token each, so the app cannot reach the dumps): workwell-evidence-twh,
workwell-evidence-maui, and workwell-backups (db-dumps/twh/, db-dumps/maui/, lifecycle
expire-dumps-30d). The WORKWELL_BUCKET_S3_* keys must match between a stack's deploy and reconcile
workflows. Attachments from before 2026-09-11 that pointed at the old AWS bucket are unrecoverable; reads
fail with EvidenceMissingError.
probeEvidenceBucket (backend-ts/src/case/bucket-health.ts) lists an empty prefix once per process
(never a get, which reads NoSuchBucket as "not found"). Failures log
WORKWELL_ALERT {"kind":"EVIDENCE_BUCKET_UNREACHABLE","status":"EVIDENCE_BUCKET_UNREACHABLE"|"EVIDENCE_BUCKET_NOT_CONFIGURED",…}
— the second means a bucket is named but a credential is empty.
All DDL is IF NOT EXISTS in schema-pg.ts / schema.ts, run on boot; a fresh database needs no
manual step. DDL is owner-approved (CLAUDE.md). Panels (ADR-080) and attributed lists (ADR-082) need
no backfill; map panels in-app at /worklist?tab=panels.
Attributed lists (ADR-082): import answers 403 whenever WebChart is configured; on synthetic stacks
an identifier outside the namespace (pat-… Maui, emp-… TWH) refuses the whole upload. Ops
stopgap: keep every report handed to the ACO as a dated CSV in the R2 evidence bucket — a live
re-render is refused once its runs pass the retention cutoff (ADR-077).
Pre-column rows are NULL (read as in-population); only official outcomes carry the flag. Applied on
Maui 2026-09-11. Not recorded as run on TWH, whose cms122/cms125 rows from before 2026-09-10 are still
NULL. Fresh databases don't need it. populationResults is an array of {populationType, result}.
On the direct URL, keeping statements free of leading comment lines (a splitter dropped them once):
BEGIN;
UPDATE workwell_spike.outcomes o SET out_of_population = TRUE
WHERE o.out_of_population IS NULL AND o.status = 'MISSING_DATA'
AND o.evidence_json ? 'official' AND NOT (o.evidence_json ? 'evaluationError')
AND NOT EXISTS (
SELECT 1 FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(o.evidence_json #> '{official,rates}') = 'array'
AND jsonb_array_length(o.evidence_json #> '{official,rates}') > 0
THEN o.evidence_json #> '{official,rates}'
ELSE jsonb_build_array(o.evidence_json #> '{official,populationResults}') END) AS rate
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(rate) = 'array' THEN rate ELSE '[]'::jsonb END) AS pop
WHERE pop ->> 'populationType' = 'initial-population'
AND COALESCE((pop ->> 'result')::boolean, FALSE));
UPDATE workwell_spike.outcomes o SET out_of_population = FALSE
WHERE o.out_of_population IS NULL
AND o.evidence_json ? 'official' AND NOT (o.evidence_json ? 'evaluationError');
SELECT measure_id,
COUNT(*) FILTER (WHERE out_of_population) AS not_in_population,
COUNT(*) FILTER (WHERE out_of_population IS FALSE) AS in_population,
COUNT(*) FILTER (WHERE out_of_population IS NULL) AS still_unrecorded,
COUNT(*) FILTER (WHERE status = 'MISSING_DATA' AND out_of_population IS FALSE) AS in_population_missing
FROM workwell_spike.outcomes GROUP BY measure_id ORDER BY measure_id;
COMMIT; -- or ROLLBACKBefore COMMIT, in_population must equal each measure's initial population (any rate's IPP, excluded
subjects included). Then restart the backend (dispatch the deploy with replace_existing=true): the
read models memoize by winning run and would keep serving old numbers. Rates rise sharply — warn anyone
holding earlier figures. Rollback: UPDATE workwell_spike.outcomes SET out_of_population = NULL;.
Neon bills CU-hours while the compute is awake; it suspends only after an idle timeout. A timer that
queries more often than that pins it awake 24/7 (~182 CU-hours/month at 0.25 CU with no users) — it once
exhausted the Free plan and every DB route returned {"error":"internal_error"} (HTTP 402 from the
pooler). If every DB route fails at once, check the Neon quota before reading code.
Invariant: a recurring task that queries the database runs less often than the suspend timeout, or
gates itself behind a DB-free check. schedulerTick keeps shouldSkipTickWithoutDb() as its first
statement; keep the setInterval in backend-ts/src/server.ts well above the suspend timeout.
Neon console → Settings: a spending limit (e.g. $10–20/mo, the real backstop), suspend timeout 60 s, autoscaling 0.25–1 CU to start.
/actuator/health is deliberately DB-free — never add a query to it, or the reconciler becomes a
compute-pinning loop. So the reconciler reads green through a database outage (and through a misrouted
measure or a dead bucket). The real database check is backup-neon-nightly.yml (03:17 UTC, both stacks):
on failure it opens or comments on the issue "Nightly Neon backup is failing" (Maui: "… (Maui pilot)")
and closes it on the next success. Treat that issue as a production incident.
One project per stack, Postgres 16 (TWH: workwell-twh, AWS us-east). The pooled URL goes in the
stack's DATABASE_URL_* secret; the direct URL is for owner-run psql and scripts. neonctl projects create defaults to Postgres 17 — pass 16 or use the console. The app pool is max: 10.
Neon's proxy silently drops a statement_timeout in the startup packet and rejects options=-c …, so the
pool sets none. Done on both live projects 2026-09-20; required on any new one. Deploy the app first
(the nightly compaction opts out via withStatementTimeoutDisabled).
psql "<direct url>" -c "ALTER ROLE <app role> SET statement_timeout = '30s';"
psql "<direct url>" -c "show statement_timeout" # expect 30s
psql "<direct url>" -c "ALTER ROLE <app role> RESET statement_timeout;" # rollback- One pooled check reads
0, and that is not failure — warm pooler backends predate the default. Hold ~10–20 concurrent connections and readselect current_setting('statement_timeout'), (select source from pg_settings where name='statement_timeout');— new backends show30s/user. - Prove it fires:
select pg_sleep(35)must fail with SQLSTATE57014, which the worker maps to a 503. - 30 s sits under the 60 s gateway cut.
pg_dumpsets its own timeout to 0; run one backup smoke test after.
Set a hard monthly usage limit and store the key only as the OPENAI_API_KEY secret.
ci.yml(every branch push, dispatch; nopull_requesttrigger, so a PR's checks are its head commit's push run): frontend lint/test/build; backend typecheck, gates and tests in 3 shards; the credentialed MADiE gate; packages; the Maui Playwright suite. Never deploys.- Deploy:
deploy-twh-mieweb.yml,deploy-maui-mieweb.yml(push + dispatch; docs-only pushes skip),deploy-staging-mieweb.yml(dispatch). Self-heal: the tworeconcile-*workflows. backup-neon-nightly.yml(nightly). Credentialed manual jobs:vendor-official-measure.yml,flip-gate.yml,cross-engine-sweep.yml.publish-packages.yml(dispatch, dry run by default; irreversible —docs/PACKAGES.md).
- Backend:
GET /api/version→{"api":"v1",…};/actuator/health→{"status":"UP"};/healthaddsbuild.sha— confirm it is the commit you deployed. - Frontend:
/→ 200. DB:psql "<direct url>" -c "SELECT 1". - All health endpoints are DB-free, so also grep the log for
WORKWELL_ALERTand the boot warm line. scripts/smoke-shadow.sh https://<api-host>runs the post-deploy checklist (health, runs, open cases, the four CSV exports, integrations, MCP sync, outreach delivery) as PASS/FAIL/WARN; WARNs are known limitations (its evidence line predates R2;/sseis the ingress caveat below).
No migration to undo — the schema is additive. Roll back by redeploying an earlier image:
- TWH: dispatch
deploy-twh-mieweb.ymlat the good SHA withreplace_existing: true, orgit revert <bad-merge-sha>onmain. The next TWH heal recreates from:latestand undoes a dispatch rollback — revert onmaintoo, or disable the reconciler meanwhile. - Maui: dispatch
deploy-maui-mieweb.ymlat the good SHA withreplace_existing: true; the recovery tag follows it, but the next push supersedes it, so revert onmain. - During an NLM VSAC outage only a pre-ADR-041 SHA can be rebuilt (Step 1a).
- Routing: remove the measure from both workflows. Data:
docs/BACKUP_DR_RUNBOOK.md.
Check the Neon dashboard (compute + storage) and OpenAI usage daily while a stack is live; fix anything near a limit the same day.
- Every DB route returns
{"error":"internal_error"}→ Neon quota (see Database compute cost). 503 pool_exhausted→ a second run in flight (GET /api/runs?status=RUNNING).503 statement_timeout→ a read hit the 30 s role default, often a cold read model after a deploy.- Green container, every evaluation 500s → grep
OFFICIAL_ROUTING_MISCONFIGURED. - Evidence fails, health green → grep
EVIDENCE_BUCKET_UNREACHABLE. - MCP/SSE drops every ~60 s (504) → MIE nginx defaults; the fix is an MIE vhost change on the SSE/MCP
locations (
proxy_buffering off,proxy_read_timeout 3600s,proxy_http_version 1.1, emptyConnection). MCP is at/sse+/mcp/**and needs a WorkWell JWT (docs/MCP.md). - Deploy fails at the manager API (curl 7/28) → check TCP 443 to
manager.os.mieweb.org; don't loop state-changing requests by hand; when it recovers,gh run rerun <run-id> --failed. Container is 'offline', expected runningafter the ~3 min poll → the container did not start: check the image tag, env and secrets (startup safety throws on unsafe config), then dispatch the reconciler.