Runbook
Runbook
Section titled “Runbook”Local startup
Section titled “Local startup”Backend:
python -m pip install -r app/backend/requirements.txt # runtime only# development (tests, lint, typecheck): use requirements-dev.txt insteadpython -m uvicorn app.backend.app.main:app --host 127.0.0.1 --port 8008Frontend dev:
cd app/frontendnpm installnpm run devDocker:
docker compose up --buildDocker with API auth:
set AB_API_TOKEN=your-secret-tokendocker compose up --buildDocker with split write/read tokens:
set AB_API_TOKEN=write-secret-tokenset AB_READONLY_API_TOKEN=readonly-secret-tokendocker compose up --buildDocker with signed workspace backups:
set AB_WORKSPACE_SIGNING_KEY=replace-with-a-long-random-secretdocker compose up --buildDocker with Slack App credentials:
set AB_SLACK_CLIENT_ID=your-client-idset AB_SLACK_CLIENT_SECRET=your-client-secretset AB_SLACK_SIGNING_SECRET=your-signing-secretdocker compose up --buildSecurity-hardening knobs:
set AB_RATE_LIMIT_ENABLED=trueset AB_RATE_LIMIT_REQUESTS=240set AB_AUTH_FAILURE_LIMIT=20set AB_MAX_REQUEST_BODY_BYTES=1048576set AB_MAX_WORKSPACE_BODY_BYTES=8388608docker compose up --buildRunning behind a reverse proxy
Section titled “Running behind a reverse proxy”The rate limiter and the auth-failure throttle bucket callers by IP address. X-Forwarded-For is
client-controlled, so it is ignored by default (AB_TRUSTED_PROXY_HOPS=0) and both limits key off the
direct peer. Leave it at 0 whenever the app is reachable directly — otherwise a caller can rotate the
header and get a fresh bucket on every request.
When the app sits behind reverse proxies, set the hop count to how many of them append to the header
(one nginx / one load balancer = 1; CDN in front of a load balancer = 2). The caller is then read as
the N-th entry from the right; everything to its left is client-supplied and never trusted.
set AB_TRUSTED_PROXY_HOPS=1# Optional: only honour the header when the direct peer is one of these addresses / CIDR blocks.set AB_TRUSTED_PROXIES=10.0.0.0/8,192.0.2.7Never set the hop count higher than the number of proxies that actually append. A caller can prepend any number of entries to the header, so an over-declared count reads one of its entries and hands it back control of its rate-limit bucket. Under-declaring is safe by comparison: it resolves to a proxy address, which merely buckets more traffic together. If in doubt, measure the chain before raising it.
Measure the chain with the diagnostics endpoint, which echoes the server-side view of the calling request. Send a marker entry and see what the infrastructure appends to its right:
curl -s -H "X-Forwarded-For: 203.0.113.7" https://<host>/api/v1/diagnostics | jq .networkEverything to the right of 203.0.113.7 in forwarded_for_chain was appended by a real proxy — that
count is the correct AB_TRUSTED_PROXY_HOPS value, and the leftmost appended entry should be your own
egress address (curl -s https://ipinfo.io/ip). After raising the hop count, repeat the call: the
response must show resolved_from: "forwarded_header" and resolved_client equal to your egress
address, not something you supplied in the marker.
Re-measure before changing hop counts on any reverse-proxied deployment: an over-declared hop count lets a visitor prepend entries and pick their own rate-limit bucket.
Setting AB_TRUSTED_PROXIES without a non-zero hop count is a startup error.
API-key traffic is unaffected either way — it buckets by api_key:{id}, not by address.
Postgres deployment
Section titled “Postgres deployment”Use Postgres when the backend must support concurrent writers, multiple app instances, or database-native replication. Leave AB_DATABASE_URL empty to keep the default SQLite workflow.
Minimal env:
set AB_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/abtestset AB_DB_POOL_SIZE=10python -m uvicorn app.backend.app.main:app --host 127.0.0.1 --port 8008Notes:
- schema bootstrapping is automatic on startup via idempotent
CREATE TABLE IF NOT EXISTS - SQLite-specific snapshot sync stays disabled on Postgres runtimes
AB_DB_POOL_SIZEcontrols the psycopg connection pool; increase it for multi-worker deploysAB_DB_PATH,AB_SQLITE_BUSY_TIMEOUT_MS,AB_SQLITE_JOURNAL_MODE, andAB_SQLITE_SYNCHRONOUSstill apply only to SQLite
Legacy SQLite remote snapshot (unsupported publication path)
Section titled “Legacy SQLite remote snapshot (unsupported publication path)”Optional HF Dataset snapshot code remains in the repository (AB_HF_* env vars and
SnapshotService) and is covered by the in-repo unit suite. It is not a supported
publication, demo, or production backup target (owner decision 2026-07-30) and is outside
project closure. Do not use it as an operational recovery recipe; prefer signed workspace
exports for portable SQLite demos and managed PostgreSQL backups for production.
Migration guide from SQLite:
sqlite3 D:\AB_TEST\app\backend\data\projects.sqlite3 .dump > workspace.sqlpsql postgresql://postgres:postgres@localhost:5432/abtest -f workspace.sqlDemo seeding (local / container)
Section titled “Demo seeding (local / container)”The supported demo path is the local runner:
python scripts/run_local.py --seed-demoThat populates the local SQLite workspace with four demo projects (checkout conversion, pricing sensitivity, onboarding completion, and feed ad click-through ratio), each with analysis/history data as applicable. Open http://127.0.0.1:8008.
When AB_SEED_DEMO_ON_STARTUP=true, the backend also runs the same idempotent startup seed after SQLite initialization (useful for Docker Compose or other self-hosted containers). The image default remains AB_SEED_DEMO_ON_STARTUP=false.
Disable seeding by setting:
set AB_SEED_DEMO_ON_STARTUP=falseRe-seed by starting from a fresh SQLite file and restarting the process, or by deleting demo projects through DELETE /api/v1/projects/{id} with a write-capable token and restarting with the seed flag.
Quick verification on the local runtime:
curl http://127.0.0.1:8008/api/v1/projectscurl http://127.0.0.1:8008/api/v1/projects/PROJECT_ID/historyQuick checks
Section titled “Quick checks”- health:
http://127.0.0.1:8008/health - readiness:
http://127.0.0.1:8008/readyz - diagnostics:
http://127.0.0.1:8008/api/v1/diagnostics
If AB_API_TOKEN or AB_READONLY_API_TOKEN is enabled, send either:
Authorization: Bearer <token>X-API-Key: <token>
Read-only tokens allow GET/HEAD/OPTIONS plus the stateless calculation endpoints (/calculate, /analyze, /results/*, exports, and similar compute-only POSTs). Anything that changes stored state still requires the write token. Setting AB_PUBLIC_DEMO=true gives anonymous visitors the same read-only scope instead of 401 (self-hosted public-demo mode).
When the frontend is served, enter the token through the “API session token” field; it is stored only in the current browser session.
When throttling is enabled, bursty /api/v1/* traffic and repeated bad tokens return 429 with Retry-After.
Full verification
Section titled “Full verification”Primary Windows entrypoint:
cmd /c scripts\verify_all.cmdWith secure Docker compose verification:
cmd /c scripts\verify_all.cmd --with-dockerNon-destructive Docker verification:
python scripts/verify_docker_compose.py --preserveOr through the main verify wrapper:
cmd /c scripts\verify_all.cmd --with-docker-preserveFocused checks:
python -m pytest app/backend/tests -qnpm --prefix app/frontend run test:unitnpm --prefix app/frontend run buildnpm --prefix app/frontend run test:e2epython scripts/run_local_smoke.py --skip-buildpython scripts/benchmark_backend.py --payload binary --assert-ms 100python scripts/verify_workspace_backup.py --fixtureSigned workspace backup check:
set AB_WORKSPACE_SIGNING_KEY=replace-with-a-long-random-secretpython scripts/verify_workspace_backup.py --fixturenpm --prefix app/frontend run test:e2e builds the frontend if needed and runs against a temporary backend-served build on a free local port.
Workspace backup and restore
Section titled “Workspace backup and restore”Export the full local workspace:
curl http://127.0.0.1:8008/api/v1/workspace/export > workspace-backup.jsonImport a backup:
curl -X POST http://127.0.0.1:8008/api/v1/workspace/import ^ -H "Content-Type: application/json" ^ -d @workspace-backup.jsonThe backup contains:
- saved projects
- analysis run history
- export events
- saved project revisions
- integrity counts and a SHA-256 checksum
- optional
signature_hmac_sha256whenAB_WORKSPACE_SIGNING_KEYis configured
Round-trip verification against a live DB file:
python scripts/verify_workspace_backup.py --db-path D:\AB_TEST\app\backend\data\projects.sqlite3If the target runtime uses AB_WORKSPACE_SIGNING_KEY, rerun the same verification command with that env var set so signature verification is exercised, not only checksum validation.
Saved-project recovery
Section titled “Saved-project recovery”Useful endpoints:
GET /api/v1/projectsGET /api/v1/projects/{project_id}/historyGET /api/v1/projects/{project_id}/revisions
Use revisions to restore an older payload into the wizard, then save to persist it as the latest version.
Multi-project comparison
Section titled “Multi-project comparison”Useful endpoints:
POST /api/v1/projects/comparePOST /api/v1/export/comparison- legacy pairwise:
GET /api/v1/projects/compare?base_id=...&candidate_id=...withDeprecation: true
Open a comparison dashboard for 3 saved projects:
curl -X POST http://127.0.0.1:8008/api/v1/projects/compare \ -H "Content-Type: application/json" \ -d '{"project_ids":["PROJECT_ID_1","PROJECT_ID_2","PROJECT_ID_3"]}'Export the same selection to Markdown:
curl -X POST http://127.0.0.1:8008/api/v1/export/comparison \ -H "Content-Type: application/json" \ -d '{"project_ids":["PROJECT_ID_1","PROJECT_ID_2","PROJECT_ID_3"],"format":"markdown"}'Export the same selection to PDF:
curl -X POST http://127.0.0.1:8008/api/v1/export/comparison \ -H "Content-Type: application/json" \ -d '{"project_ids":["PROJECT_ID_1","PROJECT_ID_2","PROJECT_ID_3"],"format":"pdf"}'Checks:
project_idsmust contain 2 to 5 unique saved projects- every selected project must already have a saved analysis snapshot
- mixed
binaryandcontinuousselections are allowed, but the dashboard marks direct effect comparison as not meaningful - PDF export returns base64-encoded content in the JSON payload; decode client-side before saving to disk
Common failure modes
Section titled “Common failure modes”Readiness returns 503:
- check SQLite path and write access
- check schema version and journal mode in
GET /readyz - if
AB_SERVE_FRONTEND_DIST=true, ensureapp/frontend/dist/index.htmlexists - inspect
GET /api/v1/diagnosticsfor frontend/LLM/storage details
Frontend loads but backend requests fail:
- confirm
VITE_API_BASE_URL - if write-token auth is enabled, confirm that the browser-session token is present and accepted by diagnostics
- if read-only auth is enabled, verify diagnostics/docs work while mutations still reject with
403 - if requests start returning
429, inspectRetry-Afterand tuneAB_RATE_LIMIT_*orAB_AUTH_FAILURE_*for the target runtime - verify CORS env values if frontend is on another origin
- use
request_idandX-Error-Codefrom API failures to correlate UI errors with backend logs - use diagnostics runtime counters and guard settings to confirm whether failures are isolated, rate-limited, or caused by request-size policy on the current process lifetime
Workspace import fails:
- validate JSON shape against
docs/API.md - if the payload is legitimately large, confirm
AB_MAX_WORKSPACE_BODY_BYTESis high enough for the target runtime - ensure referenced analysis runs and projects are consistent
- ensure the integrity checksum still matches and that the bundle was not edited after export
- if the runtime has
AB_WORKSPACE_SIGNING_KEY, ensure the bundle still contains a validsignature_hmac_sha256
Rotating API keys
Section titled “Rotating API keys”Prerequisites:
AB_ADMIN_TOKENis configured on the backend runtime- you can reach
GET /api/v1/keyswithAuthorization: Bearer <AB_ADMIN_TOKEN>
Recommended rotation flow:
- Create a replacement key with the same scope and any required per-key rate-limit override.
- Update the external consumer to use the new plaintext key.
- Verify traffic moved by checking
GET /api/v1/audit?key_id=<new-key-id>&action=api_key_used. - Revoke the previous key with
POST /api/v1/keys/{key_id}/revoke. - Delete the revoked key with
DELETE /api/v1/keys/{key_id}once rollback is no longer needed.
Create:
curl -X POST http://127.0.0.1:8008/api/v1/keys \ -H "Authorization: Bearer YOUR_AB_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"Partner write key","scope":"write"}'Audit verification:
curl "http://127.0.0.1:8008/api/v1/audit?key_id=KEY_ID&action=api_key_used" \ -H "Authorization: Bearer YOUR_WRITE_TOKEN"api_key_used audit entries are written by a background task attached to the
response, not inline with the request. For a single client this is invisible
(the worker process flushes the task before sending the next response), but a
concurrent observer querying the audit endpoint immediately after an
authenticated request may briefly miss the entry. Re-query after ~1s if the
expected entry is missing.
Revoke:
curl -X POST http://127.0.0.1:8008/api/v1/keys/KEY_ID/revoke \ -H "Authorization: Bearer YOUR_AB_ADMIN_TOKEN"Delete:
curl -X DELETE http://127.0.0.1:8008/api/v1/keys/KEY_ID \ -H "Authorization: Bearer YOUR_AB_ADMIN_TOKEN"Notes:
- the plaintext key is returned only once in the create response; do not log or persist it outside the intended secret store
- legacy
AB_API_TOKENandAB_READONLY_API_TOKENcontinue to work during migration and can be retired separately from managed API keys
Webhook troubleshooting
Section titled “Webhook troubleshooting”Useful endpoints:
GET /api/v1/webhooksGET /api/v1/webhooks/{webhook_id}/deliveries?limit=50POST /api/v1/webhooks/{webhook_id}/test
Common checks:
- confirm the subscription uses
https://...; plain HTTP is rejected outsideAB_ENV=locallocalhost targets - verify the subscription is still
enabled - inspect the delivery history for
response_code,attempt_count, anderror_message - if a delivery is stuck in
retrying, wait for the next in-process backoff window before retrying manually - generic consumers must verify
X-AB-Signaturewith the stored shared secret; Slack subscriptions do not include that header - repeated terminal failures move the delivery into the DB-backed dead-letter history with
status=failed
Create a test subscription or re-run a probe:
curl -X POST http://127.0.0.1:8008/api/v1/webhooks/WEBHOOK_ID/test \ -H "Authorization: Bearer YOUR_AB_ADMIN_TOKEN"Inspect recent failed deliveries:
curl "http://127.0.0.1:8008/api/v1/webhooks/WEBHOOK_ID/deliveries?limit=50&status=failed" \ -H "Authorization: Bearer YOUR_AB_ADMIN_TOKEN"Slack App deployment
Section titled “Slack App deployment”Use slack/app-manifest.yml for Slack UI import or slack manifest validate. Replace {DEPLOY_HOST} with the HTTPS backend host before importing.
Required runtime secrets:
AB_SLACK_CLIENT_IDAB_SLACK_CLIENT_SECRETAB_SLACK_SIGNING_SECRET
Useful endpoints:
GET /slack/installGET /slack/oauth/callbackPOST /slack/commandsPOST /slack/interactivePOST /slack/eventsGET /api/v1/slack/status
Smoke check:
curl http://127.0.0.1:8008/api/v1/slack/statusAfter installation, run /ab-test projects in Slack and verify the response lists saved projects. Slack requests are rejected when X-Slack-Signature is invalid or the timestamp is older than five minutes.
Rotate secrets from the Slack App configuration, update runtime secrets, restart the backend, delete the affected slack_installations row when replacing bot tokens, then reinstall through /slack/install.
Slack OAuth tokens at rest
Section titled “Slack OAuth tokens at rest”slack_installations.bot_token and slack_installations.user_token are stored plaintext in SQLite (or Postgres if AB_DATABASE_URL is set). The threat model is local-first single-user use, where access to the SQLite file already implies host compromise. Specifically:
data/projects.sqlite3and any customAB_DB_PATHshould sit on a filesystem that only the application user can read.- Public or disposable demos: do not install Slack in production-grade workspaces from the demo. Use a sandbox Slack workspace or skip the integration.
- For a self-hosted production deployment, restrict access to the database file at the filesystem layer (e.g.
chmod 600, encrypted volume, AWS KMS-backed EFS), and rotate Slack tokens promptly if compromise is suspected. - Token rotation: re-run
/slack/installto overwrite the row; the previous token becomes inactive in Slack as well.
If you need encrypted-at-rest storage, the recommended path is to enable filesystem-level or volume-level encryption on the host rather than column-level encryption in the application — the latter requires its own KEK management and adds a recovery failure mode without changing the practical attacker model for a host with running write access.
Adding a new locale
Section titled “Adding a new locale”The project ships with seven locales: en, ru, de, es, fr, zh, ar. Adding a new one takes a matching pair of JSON files plus registration, switcher, and verification touches.
- Frontend translation - copy
app/frontend/src/i18n/en.jsonto<code>.jsonin the same directory and translate the strings. Current shipped locales (de,es,fr,zh,ar) all keep full leaf-key parity withen; do not ship a partial file unless review explicitly allows fallback coverage. - Frontend registration - add the new code to
app/frontend/src/i18n/index.ts: import the JSON, add it toresources, extendsupportedLngs, and keep thefallbackLngresolver returning<primary> -> enfor regional tags. - Language switcher - extend
SUPPORTED_LANGUAGESinapp/frontend/src/App.tsxso the header switcher renders the new button. Add a label underapp.language.options.<code>to every shipped locale file, not only the new one, because the switcher is visible in every active locale. - RTL locales - if the locale is right-to-left, update
App.tsxso it setsdocument.documentElement.dir = "rtl"for that code and"ltr"otherwise. Auditapp/frontend/src/**/*.cssfor logical properties (inset-inline-*,padding-inline-*,margin-inline-*,border-inline-start-*,text-align: start/end) before shipping. - Backend translation - copy
app/backend/app/i18n/en.jsontoapp/backend/app/i18n/<code>.json. Translate at least theexport.markdown,export.html,warnings, andreportsubtrees, since those feed the/api/v1/export/*payloads and the deterministic report builder. - Backend registration - extend the
Languageliteral andSUPPORTED_LANGUAGEStuple inapp/backend/app/i18n/__init__.py. Theresolve_languagehelper already accepts any supported primary tag, so regional variants likefr-CA,zh-TW, orar-SAfall back automatically. - Tests - extend
app/frontend/src/i18n.test.tsx, keepapp/frontend/src/test/a11y-locales.test.tsxaligned with the current switcher shape, addapp/frontend/src/test/a11y-rtl.test.tsxfor RTL locales, and add backend export assertions toapp/backend/tests/test_export_api.pycovering the translated markdown header and primary-tag fallback.
Verification:
cmd /c scripts\verify_all.cmd --with-e2eThe backend bundle is small (one JSON per locale); the frontend bundle impact depends on whether locales are statically imported or lazy-loaded, so re-check the build output after adding a fully translated locale set.
Release hygiene
Section titled “Release hygiene”- regenerate API contracts:
python scripts/generate_frontend_api_types.py - regenerate API docs:
python scripts/generate_api_docs.py - run workspace roundtrip verification:
python scripts/verify_workspace_backup.py --fixture - run full verify pipeline
- refresh smoke screenshots if UI changed materially
Local cleanup
Section titled “Local cleanup”python scripts/cleanup_test_artifacts.py (use --dry-run first) removes
local pytest temp dirs, .coverage, the cxkm sandbox, and the mkdocs site/
build. Everything it touches is recreated on the next test/build run; no
tracked content is affected.
Screenshots
Section titled “Screenshots”Single source of truth is docs/demo/, populated by
scripts/run_local_smoke.py. The mkdocs site keeps its own copies in
docs-site/assets/screenshots/ because mkdocs only serves files under
docs_dir. After regenerating screenshots, run
python scripts/sync_doc_screenshots.py to mirror them; CI does this
automatically before mkdocs gh-deploy. Files unique to docs-site
(currently comparison-distribution-view.png) are left untouched.