Cartridges are finite. This decides who gets tested today — and makes sure the test turns into care.
Nigeria notified 458,534 tuberculosis cases in 2025 against an estimated incidence of roughly 510,000. The detection gap that dominated the last decade has narrowed sharply. What has not improved is the economics.
So the binding constraint is not how many people we screen. It is which of the people we screen receive a confirmatory molecular test, given a finite number of cartridges, instrument-hours and transport runs. CASCADE answers that per facility, per day, and tracks every authorised test through to treatment or a documented reason for loss.
Its headline measure is number needed to test — how many presumptive tests it takes to confirm one case.
| Surface | Who it serves | The question it answers |
|---|---|---|
| Triage channel | The person who walked in unwell, or a CHEW sitting with them | What do I do next, and where do I go? |
| Allocation console | Whoever is spending today's cartridges | Who gets tested today? |
| Cascade tracker | The facility, over the following weeks | Did the test turn into care? |
The triage channel asks four danger questions first and stops the interview if one fires. It ends in a single action — where to go, what it costs, what will happen, what to bring — never a score.
This list is the product as much as the feature list is.
A tool earns trust by being clear about its job, not by claiming to do more than it does. So, plainly:
A tool that allocates diagnostic tests earns its place in stages, and saying which stage you are at is what a serious developer does. The software is built and its security model is tested against an adversarial suite. The clinical model is built and awaits refitting on Nigerian data — which is Phase 0, and is the next thing scheduled.
| Area | State | Detail |
|---|---|---|
| Allocation engine | Built | Deterministic, offline, interpretable. Weights are provisional priors |
| Triage channel | Built | English only; no audio |
| Cascade tracker | Built | State machine, SLA windows, enumerated loss reasons |
| Database & RLS | Built & tested | 12 tables, 24 policies, adversarial suite passing |
| Storage | Built & tested | Facility-scoped, private bucket, admin-only deletion |
| PulmIQ bridge | Built | Written; not yet deployed to the pulmiq project |
| Clinical validation | Phase 0 next | Refit on Nigerian register and GxAlert data. Until then the interface shows rank, not probability |
| Field testing | Scheduled | Design rules in the triage channel are reasoned, not yet observed in use. First on the roadmap |
| Languages | English only | Hausa, Yoruba, Igbo and Pidgin require clinician translation with back-translation |
| Offline sync | None | The engine runs offline; nothing syncs afterwards |
| Walk-in mode | By design, next | The engine ranks a batch. Walk-in clinics need a threshold mode — see the red team |
| Regulatory | Not started | NAFDAC pre-submission and a retention period agreed with NTBLCP |
| Recall delivery | Partial | Messages are composed; nothing sends them yet |
Everything needed to stand CASCADE up and run it, in order. This is the technical set-up guide, also available as a PDF. For the people who will actually use the tool day to day, see How to use it instead — regenerate it with python docs/make_guide_pdf.py whenever this page changes.
In the Supabase SQL editor, run the three files in this order:
db/cascade_schema.sql tables, enums, views, seed facilities
db/cascade_rls.sql roles and row level security
db/cascade_storage.sql bucket policies (after step 3)
All three are idempotent. Re-running is how you apply a change, so re-running must never error — if it does, that is a bug worth reporting.
cascade_rls.sql references tables created by the schema, and cascade_storage.sql references helpers created by the RLS file. Running them out of order produces confusing "does not exist" errors.Storage → New bucket, named exactly cascade-documents, and not public. Then run db/cascade_storage.sql, which re-asserts the private flag, sets the 12 MB limit and the allowed types, and installs the facility-scoping policies.
public = false on every run precisely so an accidental toggle in the dashboard is undone the next time you deploy.netlify.toml is already configured — build command, publish directory, functions folder and redirects. Connect the repository and Netlify picks it all up.
The landing page is served at / and the console at /app/.
index.html and app.html — plus netlify.toml and netlify/ live at the top level. The console reaches the /app/ URL through a redirect, not by living in an app/ folder: a one-file subdirectory is exactly the thing that goes missing in a zip upload, and its absence fails the build with Could not resolve entry module.BASE_PATH. That variable exists only for GitHub Pages project sites. Netlify serves from the domain root, and setting it will break every asset path.Site configuration → Environment variables. Nothing here is optional except where marked.
| Variable | Value | Notes |
|---|---|---|
NODE_VERSION | 20 | |
VITE_SUPABASE_URL | Project URL | Public by design |
VITE_SUPABASE_ANON_KEY | Anon key | Public by design — RLS is what protects the data |
SUPABASE_URL | Same URL | Server side |
SUPABASE_ANON_KEY | Same anon key | Used to verify caller tokens |
SUPABASE_SERVICE_ROLE_KEY | Service role key | Never with a VITE_ prefix |
ALLOWED_ORIGIN | Your site URL | |
ANTHROPIC_API_KEY | Your key | Optional — enables AI intake and briefing |
VITE_AI_PROXY_URL | /api/ai | Optional, with the above |
ENGINE_SHARED_SECRET | Mesh secret | Needed for the PulmIQ bridge |
VITE_ prefix means "bake this into the browser bundle." Anything carrying it is readable by anyone who loads the page. That is correct for the URL and anon key, and catastrophic for the service role key. There is one Anthropic key, not two — ANTHROPIC_API_KEY and VITE_ANTHROPIC_API_KEY hold the same value; only the first is safe in a deployment.VITE_* at build time, not runtime. A variable added after a build will not appear until the next one — which looks exactly like a wrong key.Signing in is not enough. A user with no app_user row sees an explicit "no role assigned" screen and nothing else — which is the correct default, not a fault.
Create the account in Supabase Auth, then insert the role. The first one must be an admin:
insert into app_user (id, role, display_name)
values ('<auth-user-uuid>', 'admin', 'Programme admin');
Then the facility-scoped staff:
insert into app_user (id, role, facility_id, display_name)
values ('<uuid>', 'chew',
(select id from facility where code = 'LA-PHC-001'),
'Adebayo O.');
| Role | Scope | Cannot see |
|---|---|---|
chew | One facility | Any other facility |
facility_officer | One facility, plus cartridge allocation and closing losses | Any other facility |
lab_tech | One testing site | The assessment — no triage answers, no HIV status, no risk factors |
dots_officer | One treatment centre | The assessment |
state_officer | One state, read only | Assessments and individual documents; cannot change anything |
admin | Everything | — |
The schema seeds six placeholder facilities across Lagos and Kano so the system is usable immediately. Replace them with your own before anything real happens:
insert into facility
(code, name, state, lga, tier, has_genexpert, has_xray, accepts_swabs)
values
('LA-PHC-014','Ikorodu PHC','Lagos','Ikorodu','phc',false,false,true)
on conflict (code) do nothing;
tier is one of phc, general, tertiary, lab_hub, dots_centre. Get has_genexpert right — the referral logic depends on it.
Work from the lab worklist: what arrived, under which voucher, what to test. Enter the result including the semi-quantitative grade and the rifampicin outcome. An invalid or error result does not close anything — a cartridge was consumed and the person still needs a test.
Work from the DOTS worklist. Enrol confirmed cases the same day, screen the household, and start daily check-ins. Upload liver function tests during treatment; the system flags transaminases at roughly five times normal, or three times with symptoms, for a clinician to review. It never tells anyone to stop the drugs.
Read-only dashboards across your state: number needed to test, where the cascade leaks and why, time to treatment, and what is overdue. You cannot see individual triage answers or clinical documents, by design.
CASCADE reaches the DoktorConnect app only through PulmIQ — it is deliberately not on Core's roster.
CASCADE → PulmIQ → Core → app
Copy integrations/pulmiq/cascadeBridge.js into the pulmiq project's functions/ folder, set CASCADE_ENGINE_URL and ENGINE_SHARED_SECRET, and add one line to the existing export handler:
const { withCascade } = require('./cascadeBridge');
payload = await withCascade(payload, { dcid, pseudonym });
Four fields cross: whether an episode is open, when the next contact is due, the handoff, and whether the person has opted out. TB status, regimen, drug resistance, risk score, risk factors, HIV status and the callback number do not.
package.json has "type": "module" — you get module is not defined in ES module scope, which surfaces as a 502.Against a scratch database, never production:
createdb cascade_test
psql -d cascade_test -f db/cascade_schema.sql
psql -d cascade_test -f db/cascade_rls.sql
psql -d cascade_test -f db/rls_test_seed.sql
bash db/rls_test.sh
bash db/storage_test.sh
The suites create a Lagos CHEW, a Kano CHEW, a lab, a DOTS officer, a state officer and an admin, then try to break the boundaries: cross-facility reads and writes, audit-trail tampering, path traversal, unauthenticated access.
| Symptom | Cause | Fix |
|---|---|---|
type "cascade_state" already exists | An older, non-idempotent copy of the schema | Use the current cascade_schema.sql; it guards every enum |
permission denied for schema app | Grants did not run — usually the RLS file was truncated or run out of order | Re-run cascade_rls.sql whole |
permission denied for sequence | Missing usage on a bigserial sequence | Re-run cascade_rls.sql; the grant is in it |
| A save reports success but nothing changed | RLS refused it. A denied UPDATE returns zero rows, not an error | Every write must go through writeGuarded(). Never call .update() directly |
| AI panels say "AI off" with a key set | Vite reads VITE_* at build time | Redeploy |
| Function returns 502 | CommonJS .js in an ESM repo, or a call over the 10-second ceiling | Check module syntax first; then check for a slow upstream call |
| Uploads fail on a big X-ray | Bytes routed through a function | They must not be — the browser PUTs straight to storage from a signed URL |
| Site-wide 404, no functions run | Files nested one folder deep in the repo | index.html, netlify.toml and netlify/ must sit at the repository root |
Could not resolve entry module "app/index.html" | A subdirectory did not survive the upload to GitHub | Already fixed — the console entry is app.html at the root. If you see this, you are on an older copy |
None of these is optional, and none is currently done.
app.sees_pseudonym() runs on every storage access.