Staging NFR smoke runbook
Owner: backend infra Last updated: 2026-05-27 (#810) Script:
scripts/test-nfr-smoke.shNightly CI:.github/workflows/nightly-nfr-smoke.yml
TL;DRβ
scripts/test-nfr-smoke.sh runs 12 active-active NFR acceptance checks against the deployed environment. Some items are pure HTTP probes the script handles automatically; some need manual / infra steps that this runbook documents in detail.
Run mode:
STAGING_BASE_URL=https://api.h853.work \
STAGING_FRONTEND_URL=https://app.h853.work \
STAGING_TOKEN_ORG_A=eyJ... STAGING_TOKEN_ORG_B=eyJ... \
bash scripts/test-nfr-smoke.sh
Exit code 0 = no failures. Skips are expected for items that need infra ops.
What each item is checkingβ
Item 1 β /health returns 200 on all servicesβ
Sanity probe. If this fails, nothing else matters β fix this first.
| Symptom | Cause | Action |
|---|---|---|
| 502 / 504 | ECS task crash-looping | check CloudWatch task logs for startup-invariant failure |
200 from /health but 503 from /ready | downstream (PG / agent / Redis) degraded | follow per-component runbook |
Item 2 β Sentry alert path β Slack #engineeringβ
The script hits /admin/sentry-test which deliberately throws. Verify in Slack that the event landed in #engineering within ~30 seconds. If it didn't:
- check
SENTRY_DSNenv var on the running task - check Sentry project's Slack integration config
- check
sentry.config.tsbeforeSendisn't accidentally dropping events
Item 3 β Cross-org IDORβ
Org A's token tries to read org B's conversation. Expected 403/404. A 200 here is an active-incident severity β file an immediate Sentry alert and pull staging from public DNS until patched.
Item 4 β Inbound webhook dedupβ
Posts the same Slack event_id twice. Both calls should return 200 (Slack expects 200), but only ONE conversation/message row should land in the DB. Verify in DB:
SELECT count(*) FROM messages WHERE metadata->>'eventId' = '<event_id_from_log>';
-- expected: 1
Item 5 β Outbound failure β DLQ retry (manual)β
The script can't inject a vendor outage from outside. Manual procedure:
- In staging, temporarily set
RESEND_API_KEY=invalid-keyon the API task definition. - Force a deploy of that task.
- Send an expert reply through the workspace UI.
- Verify in CloudWatch:
dispatchEmail.conv=<id>] attempt 3/3 failed+ DLQ push entry. - Restore the real
RESEND_API_KEYand verify the DLQ retry worker re-sends within 5 min. - Confirm the client received exactly one email (no duplicates).
Prometheus check:
channel_failures_pending
channel_failures_total
channels_outbound_jobs_total
channels_outbound_dispatch_duration_ms_count
Expected during this smoke: DLQ queued/retry/dead/drained counters move as rows transition, pending gauges refresh on the 5 min retry tick, and channels-outbound job success/retry/dead counters are present in /metrics.
Item 6 β Socket.io 2-pod 50-socket 5-min soak (manual)β
Cross-pod fan-out test. Manual procedure:
- SSH to two staging pods.
- Run the soak helper (
scripts/load/socketio-fanout-test.mjsβ see alsodocs/runbooks/local-multi-pod.md):node scripts/load/socketio-fanout-test.mjs \
--url wss://api.h853.work/notifications \
--tokens <50 staging tokens> \
--duration 300 - From a third terminal, emit broadcast events via the API. Each event must be received by all 50 sockets per pod = 100 receives per event.
- Acceptance: 5 min, zero gaps in the receive log (use the helper's
--strictmode).
Item 7 β RDS Multi-AZ failover (infra, quarterly)β
Owned by @ethumanity / @grazianognoll. Procedure:
- Open AWS Console β RDS β
humanwork-stagingβ Actions β "Reboot with failover". - Watch the ALB target group health β the API task health checks should fail briefly then recover within ~30s as Postgres comes back on the standby.
- Confirm
/readyrecovers and no client requests returned 5xx for > 60s.
This runs quarterly per ops calendar, not nightly.
Item 8 β Production startup invariantβ
The script probes /ready and fails if the body contains demo_mode: true. The real invariant lives in api/src/main.ts and refuses to start the process if APP_ENV=production && DEMO_MODE=true. The smoke probe is a defense-in-depth check that the deployed env can't be tricked into advertising demo-mode.
Item 9 β Status page / uptime monitor targetβ
The /health endpoint is the target for whatever uptime monitor we wire (Better Stack / Statuspage / etc.). Item 9 just verifies the URL is reachable; the upstream monitor config is owned by ops.
Item 10 β 2-pod 24h soak β 5 crons fire exactly once each (manual)β
Cross-pod cron singleton via BullMQ jobId dedup. Manual procedure:
- After a clean 24h soak window on staging with 2 pods running:
SELECT name, count(*) as fires
FROM bullmq_jobs_archive -- or equivalent
WHERE created_at > now() - interval '24 hours'
AND job_name IN ('daily-summary', 'review-summary', 'inventory-alert', 'weekly-report', 'snooze-resurface')
GROUP BY name; - Expected fires-per-job depends on cron pattern (see
api/src/scheduler/scheduler.constants.ts). Each name should match its expected count exactly β never 2Γ (would indicate both pods fired the same cron).
Item 11 β Pod kill mid-deploy β BullMQ resumes (manual)β
Drain test. Manual procedure:
- While a load generator sends 100 inbound webhooks/min to staging:
scripts/nfr/inbound-loadgen.sh --rate 100/min --duration 60s & - From AWS Console, stop one of the two staging tasks.
- ECS reschedules. The killed task should drain via SIGTERM handler (#808) within
stopTimeout=120s. - After load gen completes, verify in DB:
SELECT count(*) FROM messages WHERE created_at > <load gen start>;
-- expected: 100, not 99 / 98
Item 12 β Idempotent expert respond (10Γ double-click) (semi-manual)β
LB may route consecutive expert clicks to different pods. Each click should be idempotent at the API layer (P0-9j atomic UPDATE...WHERE...status != terminal).
Procedure (full automation tracked under #744):
- Open an expert workspace queue item.
- Click "Send reply" 10 times in rapid succession.
- Expected: only 1 message in DB, only 1 outbound channel call, only 1 audit log entry.
When nightly CI alertsβ
The .github/workflows/nightly-nfr-smoke.yml job runs test-nfr-smoke.sh at 02:00 UTC daily.
- Slack alert on fail: posts a single message to
#engineeringlinking to the failed run. - Skip-only nights: not noisy; alerts only on FAIL.
- Token rotation:
STAGING_TOKEN_ORG_A/BGitHub secrets must be refreshed monthly (they're long-lived staging JWTs).
Adding new checksβ
Each item is a bash function in scripts/test-nfr-smoke.sh:
test_NN_my_check() {
header "[NN/12] description"
# ... probe ...
if [ ok ]; then pass "msg"; else fail "msg"; fi
}
Register it in main(). Keep each function β€ 30 lines; if probing logic gets bigger, extract a helper to scripts/nfr/<name>.sh.
Referenceβ
- MVP P0 NFR FINAL EN β Β§Verification Plan
- ADR-018 β multi-pod safety contract
- #810 β original issue