CancerIndex — Architecture
CancerIndex.io is a provenance-first oncology knowledge platform: a canonical cancer ontology
connected to epidemiology, genomics, biomarkers, therapies, clinical trials, regulatory status and
literature, with transparent, reproducible rankings. This document describes how the system is
layered, which packages implement each layer, how data flows, and how the platform is deployed.
The condensed rules every contributor follows are in CLAUDE.md; the full specification is
docs/SPEC-original.md.
1. Layers
The platform is organised as six separable layers (CLAUDE.md §2). Each layer only reads from the layer below it and writes to its own tables; nothing is ever overwritten across layers.
| Layer | What lives here | Tables (examples) | Written by |
|---|---|---|---|
| RAW | Exact upstream payloads, gzip JSON Lines in the data lake, plus a hash per record | source_records, files under data/raw/{source}/{date}/{entity}/{runId}-{part}.jsonl.gz |
connectors (ctx.upsertSourceRecord) |
| NORMALIZED | Source-native structures kept intact but typed and validated (CIViC evidence items, ClinVar interpretations, trial conditions before mapping) | civic_evidence_items, variant_clinical_significance, trial_conditions, trial_interventions, connector_field_stats |
connectors |
| CANONICAL | One row per real-world concept with stable CI-* identifiers, aliases, cross-reference codes, multi-dimensional hierarchy |
cancers, cancer_aliases, cancer_codes, cancer_hierarchy, genes, variants, drugs, clinical_trials, publications, geographies, anatomical_sites |
connectors through CancerResolver reconciliation |
| DERIVED | Numbers computed from canonical data, always with provenance and a formula version | epidemiology_observations, survival_observations, cancer_gene_frequencies, literature_counts, entity_counters, knowledge_edges, trial_pulse |
connectors (observations) and @cancerindex/ranking (refreshCounters) |
| RANKED | Ranking snapshots per metric × scope × formula version with per-row lineage | metric_definitions, ranking_snapshots, rankings |
@cancerindex/ranking (computeAllRankings) |
| AI | Cached, database-grounded syntheses with model and prompt version (not implemented in Phase 1, see docs/AI.md) |
ai_answers, entity_embeddings |
— |
Cross-cutting tables: sources (registry + license status), ingest_runs, connector_cursors,
provenance, unresolved_labels, change_events, audit_log, entity_merges, api_keys.
Two invariants hold across every layer:
- Provenance first. No scientific number exists without a
provenancerow (source, dataset, version, retrieval time, URL, evidence type, license). Derived values also carryformula_versionand their inputs.traceValue()in@cancerindex/rankingwalks a ranked value back to the observation, the provenance row and the raw record. - Never fake data. Missing data is absent, never zero. The API returns
counters: nullwhen counters have not been computed, and a ranking without a snapshot returnsstatus: "not_available".
2. Repository and packages
cancerindex/
├── apps/
│ ├── web/ Next.js 16 (webpack), Tailwind v4, server components; proxies /api/v1/* → API
│ └── api/ Fastify 5 public API (/v1), OpenAPI from zod route schemas, rate limiting, admin
├── workers/ pg-boss scheduler (connector cron, counters, rankings, health probes)
├── packages/
│ ├── shared/ ids (CI-*), provenance types, normalization, pino logger, env helpers
│ ├── database/ Drizzle schema (snake_case), migrations, seed (metrics + geographies)
│ ├── ontology/ qualifier rules, CancerResolver, TOP_LEVEL_CANCERS, NCIt roots
│ ├── connectors/ SDK (manifest, HttpClient, RawLake, RunContext, validators) + connectors/<id>/
│ └── ranking/ counters, ranking engine (snapshots + lineage), trace
├── scripts/ci.ts operator CLI (`pnpm cix …`)
├── deploy/ mld manifest, first-run bootstrap, deployment notes
├── docs/ this folder
└── data/raw data lake (gitignored)Dependency direction (no cycles): shared ← database ← ontology ← connectors ← ranking;
apps/api depends on database, shared, ranking, ontology; workers additionally depends
on connectors; apps/web never imports connectors (CLAUDE.md conventions).
Connector SDK
A connector is a class extending Connector with a validated manifest (zod, see
packages/connectors/src/sdk/manifest.ts), a healthCheck(ctx) and a sync(ctx). The RunContext
provides a rate-limited HttpClient (token bucket, bounded concurrency, exponential backoff,
Retry-After), a RawLake writer, idempotent upsertSourceRecord (sha256 payload hash),
addProvenance, recordUnresolved (curation queue), recordChange, a restartable cursor, and a
time budget checked with shouldStop(). Schema drift is detected by recording observed field
names/types per entity (connector_field_stats). runConnector() handles bookkeeping in
ingest_runs / connector_cursors, the credentials gate (awaiting_credentials) and the license
gate (licenseStatus: blocked never ingests).
Reconciliation
Labels are mapped to canonical cancers by CancerResolver: identifiers first (NCIt, DOID, OncoTree,
UMLS, MeSH…), then curated aliases, then normalized strings. Every mapping stores a match_type
(EXACT_IDENTIFIER, CURATED_EXACT, ONTOLOGY_EXACT, CURATED_BROADER, CURATED_NARROWER,
ALIAS, PROBABILISTIC, UNRESOLVED). Unknown labels are stored in unresolved_labels with an
optional trigram suggestion and are resolved by a curator through the admin API, which adds a curated
alias so the next connector run picks it up.
3. Data flow
upstream source ──HTTP──▶ connector.sync(ctx)
│ │ upsertSourceRecord → source_records + data/raw/*.jsonl.gz (RAW)
│ │ validate / typed rows (NORMALIZED)
│ │ CancerResolver → canonical ids, match_type; misses → unresolved_labels
│ │ addProvenance → provenance; observations / evidence / edges (CANONICAL, DERIVED)
│ └ recordChange → change_events ; ingest_runs finalised
▼
workers: maintenance.counters → refreshCounters() → entity_counters (DERIVED)
maintenance.rank → computeAllRankings() → ranking_snapshots + rankings (RANKED)
▼
apps/api (/v1) ── envelope { data, sources, dataRelease, generatedAt } ──▶ apps/web, API consumersRuns are scheduled by the worker from each manifest's schedule (cron, UTC) and can be triggered
by an operator (pnpm cix run <id> or POST /v1/admin/connectors/:id/run). A connector.run job is
stately per connector id (never queued twice, never concurrent). When a run creates or updates
records, the worker enqueues maintenance.counters with thenRank: true, so derived and ranked
layers follow canonical changes. A daily counters (06:00 UTC) and rank (06:30 UTC) pass covers
everything else; an hourly health.probe refreshes connector_cursors.health.
4. Public API
apps/api is a read-only Fastify 5 service. Route schemas are written in zod (via
fastify-type-provider-zod) and the OpenAPI 3.1 document is generated from them (/v1/openapi.json,
Swagger UI at /v1/docs) — documentation is never maintained by hand (CLAUDE.md §357).
Every /v1 response uses the envelope { data, sources, dataRelease, generatedAt, …pagination }.
sources lists the distinct upstream sources behind the payload with license and attribution;
dataRelease is CancerIndex <YYYY-MM> derived from the latest successful ingest.
Other properties: request correlation id (x-request-id, honoured when supplied), pino structured
logs, @fastify/rate-limit (anonymous 60/min per IP; API keys looked up by sha256 hash with
per-key limits, X-RateLimit-* headers), ETag + gzip/brotli, CORS. Admin endpoints under
/v1/admin/* require x-admin-token (constant-time compare) and write audit_log rows for every
mutation; they never execute ingestion in-process, they enqueue pg-boss jobs.
See docs/API.md for endpoint examples.
5. Web application
apps/web (Next.js 16, server components) renders the public site. It talks to the API through a
/api/v1/* proxy to http://127.0.0.1:${API_PORT}/v1/*, so the browser only ever sees one origin.
The UI follows the scientific-editorial aesthetic and shows, for every number: source badge, unit,
population, period and freshness; empty states read "Data not yet available".
6. Ports, processes and environment
| Process | Command | Port | Notes |
|---|---|---|---|
cancerindex-web |
next start -p 8250 -H 0.0.0.0 |
8250 | public entry (ngrok → www.cancerindex.io) |
cancerindex-api |
tsx apps/api/src/server.ts |
8251 (127.0.0.1) | reached only via the web proxy / localhost |
cancerindex-worker |
tsx workers/main.ts |
— | pg-boss schema pgboss in the same database |
| PostgreSQL 17 | database cancerindex, extensions pg_trgm, unaccent, vector |
5432 | single node |
Key environment variables (see .env.example): DATABASE_URL, API_HOST/API_PORT, WEB_PORT,
CI_API_URL, CI_DATA_DIR (data lake root), ADMIN_TOKEN, NCBI_TOOL/NCBI_EMAIL/NCBI_API_KEY,
SEER_API_KEY, WORKER_CONCURRENCY, CI_MAX_RUN_MINUTES, LOG_LEVEL, CI_SERVICE (log label).
7. Deployment
Production runs on the MacLustr cluster through the mld orchestrator (gateway M1M32). The
manifest is deploy/mld-manifest.cancerindex.json (copied to M1M32:~/dispatch/apps/cancerindex.json);
mld stage <dir> cancerindex uploads a git archive of the repository and mld deploy cancerindex
places it on the preferred node (M4M64b: Postgres 17 + pgvector, pnpm, large disk), runs the
post_sync hooks (pnpm install, createdb if missing, migrate, seed, sources:sync, web build)
and starts three PM2 processes plus the ngrok tunnel. deploy/first-run.sh bootstraps the data in
the recommended connector order. Details in deploy/README.md.
7b. Operations: checkpoints, anomaly guard, alerts, backups, doctor
Reference: docs/connectors/README.md (runtime guarantees), deploy/README.md (day-2 procedures),
docs/schema-changes-ops.md (ops tables and indexes added outside the generated migrations).
- Checkpoints (§90).
RunContextpersistsconnector_cursors.cursor(andingest_runs.cursor_after) automatically everymanifest.checkpointEveryupserted records (default 2 000) or 60 s when the cursor changed, onctx.saveCursor(), and on SIGTERM/SIGINT (runConnectorinstalls handlers for the duration of the run: first signal → cursor saved, runaborted, connector stops at its nextshouldStop(); second signal → immediate exit). A killed run therefore resumes from the last completed page; payload hashes make the overlap idempotent.RawLakeserialises raw writes through one promise chain (single shared drain, no listener pile-up) and canflush()gzip blocks before a kill. - Anomaly guard (§171).
ctx.guardCount(entity, total)compares a declared total with the previous successful run'srecords_fetched(anomalyGuard.minRatioOfPrevious, default 0.5) and refuses to continue: runfailed+ingest_runs.anomaly, critical alert, previous data untouched. Wired into OncoTree, NCIt and the ClinicalTrials.gov full crawl; connectors keep their fixed floors (HGNC, GDC, USCS, CIViC…). Nothing is ever mass-deleted on a shrunken response. - Alerts (§170). Table
system_alerts(kind,severity info|warn|critical,connector_id,message,detail,first_seen_at,last_seen_at,count,status open|acknowledged|resolved), deduplicated on kind + connector + message. Raised byrunConnector(failure, anomaly, aborted, schema drift) and by the worker's hourly health probe (source_failing,source_stale= no success within 2× the cron interval of an active connector); resolved on the next success. Surfaces:pnpm cix alerts,pnpm cix doctor,GET /v1/admin/alerts. - Backups (§172). PM2 process
cancerindex-backup(cron_restart "20 5 * * *",autorestart: false) runsdeploy/backup.sh:pg_dump -Fcto~/apps/cancerindex/backups/cancerindex-YYYYMMDD-HHMM.dump, verified withpg_restore --list, 14 daily + 8 weekly retained,logs/backup.log, non-zero exit on failure.deploy/restore.sh <dump>restores into a freshcancerindex_restore_<ts>database (never over production). The raw lake (data/raw) is mirrored separately with rsync; both are needed for TRACE. - Doctor.
pnpm cix doctorprints the readiness report (environment, database + extensions + pending migrations vsdrizzle.__drizzle_migrations, table sizes, every connector's status / license / health / last success age / last run status-anomaly-drift / cursor summary / stale flag, unresolved-label backlog,data/rawdisk usage and free space, ranking-snapshot freshness, open alerts) and exits 1 on a hard failure (unreachable database, missing required extension, pending migration, unwritable data dir, missing ops schema, critical alert). - Indexes. Indexes that drizzle cannot express or that were added by the performance review are
created idempotently in
packages/database/src/migrate.tsafter the SQL migrations (createPerformanceIndexes): GIN oncivic_evidence_items.gene_ids/variant_ids/therapy_ids(the counters use the containment form@> ARRAY[id]::text[]),trial_conditions(cancer_id, trial_id),clinical_trials(overall_status, study_type)and(overall_status, last_update_posted_date),cancer_aliases(normalized text_pattern_ops),ingest_runs(connector_id, status, started_at).
8. Phase 1 boundaries
- Rankings are count-based (trials, literature, curated evidence, genes, cohorts) for all entities and burden/lethality/gap metrics only where epidemiology observations exist for the scope.
- No composite score (ADR-006). No AI synthesis (docs/AI.md). No HGVS normalisation / liftover (ADR-002 plans Python/DuckDB workers for that).
- GLOBOCAN stays in license review and SEER in
awaiting_credentials; the platform is honest about the resulting gaps ("awaiting license review").